From b149112e633525fc4942027153773bb01f9e6f0b Mon Sep 17 00:00:00 2001 From: Roy Klooster <76492458+royklo@users.noreply.github.com> Date: Mon, 6 Jul 2026 22:47:22 +0200 Subject: [PATCH 1/3] docs: add Required API scope(s) to comment help + drop stale PolicyDiffFormatted mention MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Adds `Required API scope(s):` line to the .SYNOPSIS block of 13 cmdlets that previously lacked it, matching the pattern already used by the Reports cmdlets (Get-InforcerReportRun, Get-InforcerReportType, Invoke-InforcerReport, Save-InforcerReportOutput). `Get-Help ` now surfaces the scope requirement without users needing to open docs/API-REFERENCE.md. - Removes the stale `PolicyDiffFormatted` reference in docs/CMDLET-REFERENCE.md that survived when the property itself was deleted from the module (FINDINGS #63). - 3 cmdlets intentionally excluded from scope lines: Connect-Inforcer, Disconnect-Inforcer, Test-InforcerConnection (no scope-gated API call). Scope values sourced from the existing Cmdlet → Endpoints → Required Scopes table in docs/API-REFERENCE.md. 159/159 Pester tests pass. --- docs/CMDLET-REFERENCE.md | 2 +- module/Public/Compare-InforcerEnvironments.ps1 | 2 ++ module/Public/Export-InforcerTenantDocumentation.ps1 | 2 ++ module/Public/Get-InforcerAlignmentDetails.ps1 | 2 ++ module/Public/Get-InforcerAssessment.ps1 | 2 ++ module/Public/Get-InforcerAuditEvent.ps1 | 2 ++ module/Public/Get-InforcerBaseline.ps1 | 2 ++ module/Public/Get-InforcerGroup.ps1 | 2 ++ module/Public/Get-InforcerRole.ps1 | 2 ++ module/Public/Get-InforcerSupportedEventType.ps1 | 2 ++ module/Public/Get-InforcerTenant.ps1 | 2 ++ module/Public/Get-InforcerTenantPolicies.ps1 | 2 ++ module/Public/Get-InforcerUser.ps1 | 2 ++ module/Public/Invoke-InforcerAssessment.ps1 | 2 ++ 14 files changed, 27 insertions(+), 1 deletion(-) diff --git a/docs/CMDLET-REFERENCE.md b/docs/CMDLET-REFERENCE.md index bf53dab..606b63b 100644 --- a/docs/CMDLET-REFERENCE.md +++ b/docs/CMDLET-REFERENCE.md @@ -104,7 +104,7 @@ When not connected, an error is written (e.g. "Not connected. To connect, run: C ## Get-InforcerTenant -Retrieves tenant information. Optionally filter by `-TenantId` (numeric ID, Microsoft Tenant ID GUID, or tenant name). Licenses are shown as a comma-separated string; PolicyDiff and PolicyDiffFormatted show policy change info when the API provides it. +Retrieves tenant information. Optionally filter by `-TenantId` (numeric ID, Microsoft Tenant ID GUID, or tenant name). Licenses are shown as a comma-separated string; `PolicyDiff` shows policy change info when the API provides it. **Endpoints called**: `GET /beta/tenants` **Required API scope(s)**: `Tenants.Read` diff --git a/module/Public/Compare-InforcerEnvironments.ps1 b/module/Public/Compare-InforcerEnvironments.ps1 index 97a9610..2c5da6d 100644 --- a/module/Public/Compare-InforcerEnvironments.ps1 +++ b/module/Public/Compare-InforcerEnvironments.ps1 @@ -1,6 +1,8 @@ <# .SYNOPSIS Compares the Intune policy configuration of two tenants and generates an HTML report. + + Required API scope(s): Tenants.Read + Baselines.Read + tenants.policies.Read .DESCRIPTION Fetches all policies from two tenants via Get-InforcerTenantPolicies, compares Intune Settings Catalog settings at the settingDefinitionId level, and produces a self-contained diff --git a/module/Public/Export-InforcerTenantDocumentation.ps1 b/module/Public/Export-InforcerTenantDocumentation.ps1 index b563ccd..3b6bd1e 100644 --- a/module/Public/Export-InforcerTenantDocumentation.ps1 +++ b/module/Public/Export-InforcerTenantDocumentation.ps1 @@ -1,6 +1,8 @@ <# .SYNOPSIS Generates comprehensive tenant documentation across all M365 products managed via Inforcer. + + Required API scope(s): Tenants.Read + Baselines.Read + tenants.policies.Read .DESCRIPTION Export-InforcerTenantDocumentation collects configuration data for the specified tenant by calling Get-InforcerBaseline, Get-InforcerTenant, and Get-InforcerTenantPolicies (each using diff --git a/module/Public/Get-InforcerAlignmentDetails.ps1 b/module/Public/Get-InforcerAlignmentDetails.ps1 index 697af88..4964a62 100644 --- a/module/Public/Get-InforcerAlignmentDetails.ps1 +++ b/module/Public/Get-InforcerAlignmentDetails.ps1 @@ -1,6 +1,8 @@ <# .SYNOPSIS Retrieves alignment scores or alignment details from the Inforcer API. + + Required API scope(s): Baselines.Read + AlignmentScores.Read + Tenants.Read + tenants.policies.Read .DESCRIPTION Without -BaselineId: retrieves alignment score summaries (table or raw). With -BaselineId: retrieves detailed alignment data including metrics, diff --git a/module/Public/Get-InforcerAssessment.ps1 b/module/Public/Get-InforcerAssessment.ps1 index 3b6f0e7..931d6f6 100644 --- a/module/Public/Get-InforcerAssessment.ps1 +++ b/module/Public/Get-InforcerAssessment.ps1 @@ -1,6 +1,8 @@ <# .SYNOPSIS Retrieves available assessments from the Inforcer API. + + Required API scope(s): Assessments.Read .DESCRIPTION Lists all assessments available for evaluation (e.g. Copilot Readiness, CIS Benchmarks, Essential Eight). Each assessment has an ID, name, description, tags, and type. diff --git a/module/Public/Get-InforcerAuditEvent.ps1 b/module/Public/Get-InforcerAuditEvent.ps1 index 84e85fc..7eb8506 100644 --- a/module/Public/Get-InforcerAuditEvent.ps1 +++ b/module/Public/Get-InforcerAuditEvent.ps1 @@ -1,6 +1,8 @@ <# .SYNOPSIS Retrieves audit events from the Inforcer API. + + Required API scope(s): Audit.Read (unconfirmed — see docs/API-REFERENCE.md) .DESCRIPTION POST /beta/auditEvents/search. When -DateFrom and -DateTo are omitted, uses a wide default range (10 years ago to now). When -EventType is not specified, uses all event types from the API (or fallback authentication, failedAuthentication). diff --git a/module/Public/Get-InforcerBaseline.ps1 b/module/Public/Get-InforcerBaseline.ps1 index fb723f6..7d2c05a 100644 --- a/module/Public/Get-InforcerBaseline.ps1 +++ b/module/Public/Get-InforcerBaseline.ps1 @@ -1,6 +1,8 @@ <# .SYNOPSIS Retrieves baseline information from the Inforcer API. + + Required API scope(s): Baselines.Read .DESCRIPTION Retrieves baseline groups and members. Optionally filter by -TenantId (owner or member). .PARAMETER Format diff --git a/module/Public/Get-InforcerGroup.ps1 b/module/Public/Get-InforcerGroup.ps1 index 52731a9..641939e 100644 --- a/module/Public/Get-InforcerGroup.ps1 +++ b/module/Public/Get-InforcerGroup.ps1 @@ -3,6 +3,8 @@ function Get-InforcerGroup { .SYNOPSIS Retrieves groups from an Inforcer tenant. + Required API scope(s): Tenants.Groups.Read + Tenants.Read (only when -TenantId is a GUID or tenant name) + .DESCRIPTION Gets a list of groups or a single group from the Inforcer API. When called without -Group, returns all groups (GroupSummary objects) with optional filtering and auto-pagination. diff --git a/module/Public/Get-InforcerRole.ps1 b/module/Public/Get-InforcerRole.ps1 index 138ca24..b0e6be8 100644 --- a/module/Public/Get-InforcerRole.ps1 +++ b/module/Public/Get-InforcerRole.ps1 @@ -3,6 +3,8 @@ function Get-InforcerRole { .SYNOPSIS Retrieves directory role definitions from an Inforcer tenant. + Required API scope(s): Tenants.Roles.Read + Tenants.Read (only when -TenantId is a GUID or tenant name) + .DESCRIPTION Gets the list of Entra ID directory role definitions for a tenant from the Inforcer API. Returns role definitions including display name, description, and whether the role is diff --git a/module/Public/Get-InforcerSupportedEventType.ps1 b/module/Public/Get-InforcerSupportedEventType.ps1 index 7a55624..df41cda 100644 --- a/module/Public/Get-InforcerSupportedEventType.ps1 +++ b/module/Public/Get-InforcerSupportedEventType.ps1 @@ -34,6 +34,8 @@ function Get-InforcerSupportedEventType { .SYNOPSIS Returns a list of available audit event types from the Inforcer API. + Required API scope(s): Audit.Read (unconfirmed — see docs/API-REFERENCE.md) + .DESCRIPTION Retrieves the valid event type names that can be used with Get-InforcerAuditEvent. This function is primarily used for tab completion of the -EventType parameter in Get-InforcerAuditEvent. diff --git a/module/Public/Get-InforcerTenant.ps1 b/module/Public/Get-InforcerTenant.ps1 index cb8e327..27ee93d 100644 --- a/module/Public/Get-InforcerTenant.ps1 +++ b/module/Public/Get-InforcerTenant.ps1 @@ -1,6 +1,8 @@ <# .SYNOPSIS Retrieves tenant information from the Inforcer API. + + Required API scope(s): Tenants.Read .DESCRIPTION Lists tenants. Optionally filter by -TenantId (numeric ID, Microsoft Tenant ID GUID, or tenant name). Output includes PascalCase aliases (e.g. ClientTenantId, TenantFriendlyName). When the API diff --git a/module/Public/Get-InforcerTenantPolicies.ps1 b/module/Public/Get-InforcerTenantPolicies.ps1 index 1a58a09..58921e7 100644 --- a/module/Public/Get-InforcerTenantPolicies.ps1 +++ b/module/Public/Get-InforcerTenantPolicies.ps1 @@ -41,6 +41,8 @@ function EnrichPolicyObject { <# .SYNOPSIS Retrieves policies for a tenant from the Inforcer API. + + Required API scope(s): tenants.policies.Read + Tenants.Read (only when -TenantId is a GUID or tenant name) .DESCRIPTION Gets all policies for the specified tenant. TenantId accepts a numeric ID, GUID, or tenant name. Output is normalized to use PolicyName (from displayName or name) so properties are consistent across all rows. diff --git a/module/Public/Get-InforcerUser.ps1 b/module/Public/Get-InforcerUser.ps1 index 41c0ee6..7f63072 100644 --- a/module/Public/Get-InforcerUser.ps1 +++ b/module/Public/Get-InforcerUser.ps1 @@ -3,6 +3,8 @@ function Get-InforcerUser { .SYNOPSIS Retrieves users from an Inforcer tenant. + Required API scope(s): Tenants.Users.Read + Tenants.Read (only when -TenantId is a GUID or tenant name) + .DESCRIPTION Gets a list of users or a single user by ID from the Inforcer API. When called without -UserId, returns all users (UserSummary objects) with optional search filtering and auto-pagination. diff --git a/module/Public/Invoke-InforcerAssessment.ps1 b/module/Public/Invoke-InforcerAssessment.ps1 index c07311f..ebe705d 100644 --- a/module/Public/Invoke-InforcerAssessment.ps1 +++ b/module/Public/Invoke-InforcerAssessment.ps1 @@ -1,6 +1,8 @@ <# .SYNOPSIS Runs an assessment against one or more tenants via the Inforcer API. + + Required API scope(s): Tenants.Read + Assessments.Read + Assessments.Run .DESCRIPTION Triggers an assessment run and returns detailed results including per-check scores, passes, violations, warnings, and framework metadata. From 95cce1e4d5d7ba99106abdbf75adf62dbd348cf4 Mon Sep 17 00:00:00 2001 From: Roy Klooster <76492458+royklo@users.noreply.github.com> Date: Mon, 6 Jul 2026 22:59:06 +0200 Subject: [PATCH 2/3] feat: add Get-InforcerSecureScore + -User filter on audit events + Id alias MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New cmdlet Get-InforcerSecureScore - Wraps GET /beta/tenants/{tenantId}/secureScores — 90-day score history, per-category breakdown, and actionable control profiles with remediation. - Accepts numeric ID, GUID, or tenant name via -TenantId. - Supports -OutputType JsonObject. - PSTypeName InforcerCommunity.SecureScore with a ListControl view showing current/max/percentage plus counts of nested arrays. - Aliases: CurrentScore, CurrentScorePercentage, MaxScore, LicensedUserCount, EnabledServices, Scores, ControlProfiles, ControlCategoryScores. Nested arrays (Scores, ControlProfiles) get their own PascalCase aliases. Get-InforcerAuditEvent -User - New parameter that server-side filters audit events by the specified user. - Sends the value as the `user` field in the POST /beta/auditEvents/search body. Previously users had to fetch all events and filter client-side. AuditEvent.Id alias - AuditEvent objects now expose the raw `id` field as PascalCase `Id`, consistent with every other object type in the module. Docs / plumbing - README, CMDLET-REFERENCE, and API-REFERENCE updated (endpoint section, three new schemas, cmdlet-to-scope table row, TOC). - CHANGELOG entry for 0.6.0. - Module version bumped 0.5.0 → 0.6.0. - Consistency tests: expected count 20 → 21, new not-connected test, new SecureScore and AuditEvent alias tests, updated Get-InforcerAuditEvent param list. - scripts/Test-AllCmdlets.ps1: new case for Get-InforcerSecureScore. 162/162 Pester tests pass. --- CHANGELOG.md | 14 +++ README.md | 1 + Tests/Consistency.Tests.ps1 | 56 ++++++++++- docs/API-REFERENCE.md | 67 +++++++++++++ docs/CMDLET-REFERENCE.md | 52 +++++++++- module/InforcerCommunity.Format.ps1xml | 22 +++++ module/InforcerCommunity.psd1 | 3 +- .../Private/Add-InforcerPropertyAliases.ps1 | 46 ++++++++- module/Public/Get-InforcerAuditEvent.ps1 | 8 ++ module/Public/Get-InforcerSecureScore.ps1 | 95 +++++++++++++++++++ scripts/Test-AllCmdlets.ps1 | 3 + 11 files changed, 360 insertions(+), 7 deletions(-) create mode 100644 module/Public/Get-InforcerSecureScore.ps1 diff --git a/CHANGELOG.md b/CHANGELOG.md index 9bbfc6c..eb6a36b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,20 @@ 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. +## [0.6.0] - 2026-07-06 + +### Features + +- **New cmdlet: `Get-InforcerSecureScore`** — retrieves the current and historic Microsoft Secure Score for a tenant from `GET /beta/tenants/{tenantId}/secureScores`. Returns the current score, max score, licensed user count, enabled services, up to 90 days of daily score history, per-category scores, and actionable control profiles with remediation guidance. `-TenantId` accepts numeric ID, GUID, or tenant name (name/GUID resolution via `Resolve-InforcerTenantId`). Supports `-OutputType JsonObject`. Required scopes: `Tenants.SecureScores.Read` + `Tenants.Read` (only when `-TenantId` is a GUID or tenant name). PSTypeName `InforcerCommunity.SecureScore` with a ListControl default view showing current/max/percentage plus counts of nested arrays. +- **`Get-InforcerAuditEvent -User `** — new parameter that server-side filters audit events by the specified user (matches the `user` field in the `POST /beta/auditEvents/search` request body). Previously users had to fetch all events and filter client-side. +- **`Id` alias on audit event output** — `AuditEvent` objects now expose the raw `id` field as PascalCase `Id`, consistent with every other object type in the module. + +### Documentation + +- **`Required API scope(s):` line added to `Get-Help` output for 13 cmdlets** — `Compare-InforcerEnvironments`, `Export-InforcerTenantDocumentation`, `Get-InforcerAlignmentDetails`, `Get-InforcerAssessment`, `Get-InforcerAuditEvent`, `Get-InforcerBaseline`, `Get-InforcerGroup`, `Get-InforcerRole`, `Get-InforcerSupportedEventType`, `Get-InforcerTenant`, `Get-InforcerTenantPolicies`, `Get-InforcerUser`, `Invoke-InforcerAssessment`. Users no longer need to leave PowerShell and open `docs/API-REFERENCE.md` to learn which scope a cmdlet needs. Follows the pattern already used by the Reports cmdlets. +- **Removed stale `PolicyDiffFormatted` mention** in `docs/CMDLET-REFERENCE.md` — the property was removed from the module in an earlier version (see FINDINGS #63) but one line in the docs was missed. +- Added `Get-InforcerSecureScore` section to `docs/CMDLET-REFERENCE.md`; endpoint description, schemas (`TenantSecureScoreDetails`, `TenantSecureScoreHistoryPoint`, `TenantSecureScoreControlProfile`), and scope-mapping row added to `docs/API-REFERENCE.md`; cmdlet added to the README public-surface table. + ## [0.5.0] - 2026-06-30 ### Features diff --git a/README.md b/README.md index 0d21d24..661588c 100644 --- a/README.md +++ b/README.md @@ -110,6 +110,7 @@ Disconnect-Inforcer | **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. | +| **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. | | **Get-InforcerAssessment** | Lists available assessments (CIS, Essential Eight, etc.). | diff --git a/Tests/Consistency.Tests.ps1 b/Tests/Consistency.Tests.ps1 index c807360..0271ce9 100644 --- a/Tests/Consistency.Tests.ps1 +++ b/Tests/Consistency.Tests.ps1 @@ -30,12 +30,12 @@ Describe 'Consistency contract' { $path = Get-InforcerCommunityManifestPath Import-Module $path -Force $script:exported = (Get-Module -Name 'InforcerCommunity').ExportedCommands.Keys - $script:expectedCount = 20 + $script:expectedCount = 21 $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', + 'Get-InforcerUser', 'Get-InforcerGroup', 'Get-InforcerRole', 'Get-InforcerSecureScore', 'Export-InforcerTenantDocumentation', 'Compare-InforcerEnvironments', 'Get-InforcerAssessment', 'Invoke-InforcerAssessment', 'Get-InforcerReportType', 'Invoke-InforcerReport', 'Get-InforcerReportRun', 'Save-InforcerReportOutput' @@ -48,11 +48,12 @@ Describe 'Consistency contract' { 'Get-InforcerBaseline' = @('Format', 'TenantId', 'OutputType') 'Get-InforcerTenantPolicies' = @('Format', 'TenantId', 'OutputType') 'Get-InforcerAlignmentDetails' = @('Format', 'TenantId', 'BaselineId', 'Tag', 'OutputType') - 'Get-InforcerAuditEvent' = @('EventType', 'DateFrom', 'DateTo', 'PageSize', 'MaxResults', 'Format', 'OutputType') + 'Get-InforcerAuditEvent' = @('EventType', 'DateFrom', 'DateTo', 'User', 'PageSize', 'MaxResults', 'Format', 'OutputType') 'Get-InforcerSupportedEventType' = @() 'Get-InforcerUser' = @('Format', 'TenantId', 'Search', 'MaxResults', 'UserId', 'OutputType') 'Get-InforcerGroup' = @('TenantId', 'Search', 'Filter', 'MaxResults', 'Group', 'OutputType') 'Get-InforcerRole' = @('TenantId', 'OutputType') + 'Get-InforcerSecureScore' = @('TenantId', 'OutputType') 'Export-InforcerTenantDocumentation' = @('Format', 'TenantId', 'OutputPath', 'SettingsCatalogPath', 'FetchGraphData', 'Baseline', 'Tag') 'Compare-InforcerEnvironments' = @('SourceTenantId', 'DestinationTenantId', 'SourceSession', 'DestinationSession', 'SourceBaselineId', 'DestinationBaselineId', 'IncludingAssignments', 'SettingsCatalogPath', 'FetchGraphData', 'ExcludeOS', 'PolicyNameFilter', 'OutputPath') 'Get-InforcerAssessment' = @('Format', 'OutputType') @@ -84,7 +85,7 @@ Describe 'Consistency contract' { } It 'Get-* cmdlets that return API data have -OutputType' { - $getCmdlets = @('Get-InforcerTenant', 'Get-InforcerBaseline', 'Get-InforcerTenantPolicies', 'Get-InforcerAlignmentDetails', 'Get-InforcerAuditEvent', 'Get-InforcerUser', 'Get-InforcerGroup', 'Get-InforcerRole') + $getCmdlets = @('Get-InforcerTenant', 'Get-InforcerBaseline', 'Get-InforcerTenantPolicies', 'Get-InforcerAlignmentDetails', 'Get-InforcerAuditEvent', 'Get-InforcerUser', 'Get-InforcerGroup', 'Get-InforcerRole', 'Get-InforcerSecureScore') foreach ($name in $getCmdlets) { $cmd = Get-Command -Name $name -ErrorAction Stop $cmd.Parameters.Keys | Should -Contain 'OutputType' @@ -195,6 +196,12 @@ Describe 'No-silent-failure contract' { $err | Should -Not -BeNullOrEmpty -Because 'should report not connected, not return silence' } + It 'Get-InforcerSecureScore produces an error when not connected' { + $err = $null + Get-InforcerSecureScore -TenantId 1 -ErrorVariable err -ErrorAction SilentlyContinue + $err | Should -Not -BeNullOrEmpty -Because 'should report not connected, not return silence' + } + It 'Export-InforcerTenantDocumentation produces an error when not connected' { $err = $null Export-InforcerTenantDocumentation -TenantId 1 -ErrorVariable err -ErrorAction SilentlyContinue @@ -782,6 +789,47 @@ Describe 'Private helpers (via module scope)' { $role.IsPrivileged | Should -BeTrue } } + + It 'SecureScore: adds top-level and nested aliases' { + & (Get-Module InforcerCommunity) { + $secure = [PSCustomObject]@{ + currentScore = 412.5 + currentScorePercentage = 64.45 + maxScore = 640 + licensedUserCount = 275 + enabledServices = @('AzureAD','Exchange') + scores = @( + [PSCustomObject]@{ createdDateTime = '2026-07-01'; currentScore = 410; maxScore = 640 } + ) + controlProfiles = @( + [PSCustomObject]@{ id = 'mfa-admins'; title = 'Require MFA for admins'; controlCategory = 'Identity'; currentScore = 0; maxScore = 10; scoreDifference = 10 } + ) + } + $null = Add-InforcerPropertyAliases -InputObject $secure -ObjectType SecureScore + $secure.CurrentScore | Should -Be 412.5 + $secure.MaxScore | Should -Be 640 + $secure.LicensedUserCount | Should -Be 275 + $secure.EnabledServices | Should -Be @('AzureAD','Exchange') + $secure.Scores[0].CreatedDateTime | Should -Be '2026-07-01' + $secure.Scores[0].CurrentScore | Should -Be 410 + $secure.ControlProfiles[0].Title | Should -Be 'Require MFA for admins' + $secure.ControlProfiles[0].ScoreDifference | Should -Be 10 + } + } + + It 'AuditEvent: adds Id alias' { + & (Get-Module InforcerCommunity) { + $ev = [PSCustomObject]@{ + id = 'e1a5-1234' + eventType = 'authentication' + timestamp = '2026-07-01T00:00:00Z' + user = 'admin@contoso.com' + } + $null = Add-InforcerPropertyAliases -InputObject $ev -ObjectType AuditEvent + $ev.Id | Should -Be 'e1a5-1234' + $ev.EventType | Should -Be 'authentication' + } + } } Context 'Filter-InforcerResponse' { diff --git a/docs/API-REFERENCE.md b/docs/API-REFERENCE.md index d343638..69b8ac6 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) + - [Secure Scores](#secure-scores) - [Reports](#reports) - [Schemas](#schemas) - [BaselineGroup](#baselinegroup) @@ -39,6 +40,9 @@ This document describes the Inforcer REST API endpoints, schemas, and response s - [TenantGroupSummary](#tenantgroupsummary) - [TenantGroup](#tenantgroup) - [TenantRole](#tenantrole) + - [TenantSecureScoreDetails](#tenantsecurescoredetails) + - [TenantSecureScoreHistoryPoint](#tenantsecurescorehistorypoint) + - [TenantSecureScoreControlProfile](#tenantsecurescorecontrolprofile) - [ReportType](#reporttype) - [ReportRun](#reportrun) - [ReportOutput](#reportoutput) @@ -95,6 +99,7 @@ Built mechanically by tracing each public cmdlet through the module (including t | `Get-InforcerUser` | `GET /beta/tenants/{tenantId}/users[/...] ` + `GET /beta/tenants` *(GUID/name lookup)* | `Tenants.Users.Read` + `Tenants.Read`† | | `Get-InforcerGroup` | `GET /beta/tenants/{tenantId}/groups[/...] ` + `GET /beta/tenants` *(GUID/name lookup)* | `Tenants.Groups.Read` + `Tenants.Read`† | | `Get-InforcerRole` | `GET /beta/tenants/{tenantId}/roles` + `GET /beta/tenants` *(GUID/name lookup)* | `Tenants.Roles.Read` + `Tenants.Read`† | +| `Get-InforcerSecureScore` | `GET /beta/tenants/{tenantId}/secureScores` + `GET /beta/tenants` *(GUID/name lookup)* | `Tenants.SecureScores.Read` + `Tenants.Read`† | | `Get-InforcerAssessment` | `GET /beta/assessments` | `Assessments.Read` | | `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` | @@ -311,6 +316,20 @@ Returns the list of Entra ID directory role definitions for a tenant. **Response**: Array of [TenantRole](#tenantrole) objects. +### Secure Scores + +#### `GET /beta/tenants/{tenantId}/secureScores` + +Returns the current and historic Microsoft Secure Score for a tenant. Includes up to 90 days of daily score history, per-category scores, and actionable control profiles. + +**Required scope**: `Tenants.SecureScores.Read` (or `Tenants.Read`) + +| Parameter | In | Type | Required | Description | +|-----------|----|------|----------|-------------| +| `tenantId` | path | integer | Yes | Inforcer tenant ID. | + +**Response**: A single [TenantSecureScoreDetails](#tenantsecurescoredetails) object. + ### Reports Beta endpoints for triggering and retrieving asynchronous report runs. @@ -762,6 +781,54 @@ An Entra ID directory role definition. **PSTypeName:** `InforcerCommunity.Role` +### TenantSecureScoreDetails + +Top-level response object for `GET /beta/tenants/{tenantId}/secureScores`. + +| Property | Type | Required | Description | +|----------|------|----------|-------------| +| currentScore | number | Yes | Current Microsoft Secure Score. | +| currentScorePercentage | number | No | Current score as a percentage of `maxScore`. | +| maxScore | number | Yes | Maximum possible Secure Score. | +| licensedUserCount | integer | No | Number of licensed users. | +| enabledServices | array\ | No | Services enabled for this tenant (e.g. `AzureAD`, `Exchange`). | +| scores | array\<[TenantSecureScoreHistoryPoint](#tenantsecurescorehistorypoint)\> | No | Up to 90 days of daily score history. | +| controlProfiles | array\<[TenantSecureScoreControlProfile](#tenantsecurescorecontrolprofile)\> | No | Actionable control profiles with remediation guidance. | +| controlCategoryScores | array\ | No | Per-category scores and history (e.g. Identity, Data, Device). | + +**PSTypeName:** `InforcerCommunity.SecureScore` + +### TenantSecureScoreHistoryPoint + +Single daily score entry in the `scores` array of `TenantSecureScoreDetails`. + +| Property | Type | Required | Description | +|----------|------|----------|-------------| +| createdDateTime | string (datetime) | Yes | Date the score was recorded (UTC). | +| currentScore | number | Yes | Score at this date. | +| currentScorePercentage | number | No | Score expressed as a percentage. | +| maxScore | number | Yes | Maximum possible score at this date. | + +### TenantSecureScoreControlProfile + +Actionable control profile in the `controlProfiles` array of `TenantSecureScoreDetails`. + +| Property | Type | Required | Description | +|----------|------|----------|-------------| +| id | string | Yes | Control profile identifier. | +| title | string | Yes | Human-readable title. | +| controlCategory | string | No | Category (e.g. Identity, Data). | +| service | string | No | Associated service (e.g. Azure AD, Exchange). | +| currentScore | number | No | Current score for this control. | +| currentScorePercentage | number | No | Current score as a percentage. | +| maxScore | number | No | Maximum score for this control. | +| maxScorePercentage | number | No | Maximum score as a percentage. | +| scoreDifference | number | No | Gap between current and max score (higher = larger remediation opportunity). | +| scoreDifferencePercentage | number | No | Score difference as a percentage. | +| remediation | string | No | Recommended remediation steps. | +| remediationImpact | string | No | Impact description of the remediation. | +| actionUrl | string | No | URL to take the recommended action. | + ### ReportType A catalog entry from `GET /beta/reports/types`. Field names verified against `api-uk.inforcer.com`. diff --git a/docs/CMDLET-REFERENCE.md b/docs/CMDLET-REFERENCE.md index 606b63b..d04bacc 100644 --- a/docs/CMDLET-REFERENCE.md +++ b/docs/CMDLET-REFERENCE.md @@ -324,7 +324,7 @@ A JSON string (array of objects) with properties such as `tenantId`, `tenantFrie ## Get-InforcerAuditEvent -Retrieves audit events from the Inforcer API. Supports optional `-EventType`, `-DateFrom`, `-DateTo`, `-PageSize`, and `-MaxResults`. +Retrieves audit events from the Inforcer API. Supports optional `-EventType`, `-DateFrom`, `-DateTo`, `-User`, `-PageSize`, and `-MaxResults`. **Endpoints called**: `POST /beta/auditEvents/search` **Required API scope(s)**: `Audit.Read` *(not in the API team's published scope→route mapping — the route `/beta/auditEvents/search` is unmapped; `Audit.Read` is the assumed scope based on naming. Confirm with Inforcer API team.)* @@ -336,6 +336,7 @@ Retrieves audit events from the Inforcer API. Supports optional `-EventType`, `- | **EventType** | String[] | No | Event types to include. Tab completion with supported event types. Omit for all types. | | **DateFrom** | DateTime | No | Start of date/time range (inclusive). | | **DateTo** | DateTime | No | End of date/time range (inclusive). | +| **User** | String | No | Filter events server-side by this user (matches the `user` field in the API request body). | | **PageSize** | Int | No | Page size per API request. Default: 100. | | **MaxResults** | Int | No | Max events to return. 0 = no limit. Default: 0. | | **Format** | String | No | `Raw` (default). | @@ -347,6 +348,7 @@ Retrieves audit events from the Inforcer API. Supports optional `-EventType`, `- Get-InforcerAuditEvent Get-InforcerAuditEvent -DateFrom (Get-Date).AddDays(-7) -DateTo (Get-Date) Get-InforcerAuditEvent -EventType authentication,failedAuthentication -DateFrom $from -DateTo $to +Get-InforcerAuditEvent -User admin@contoso.com -DateFrom (Get-Date).AddDays(-30) Get-InforcerAuditEvent -OutputType JsonObject ``` @@ -540,6 +542,54 @@ IsPrivileged : True --- +## Get-InforcerSecureScore + +Retrieves the current and historic Microsoft Secure Score for a tenant, including up to 90 days of daily score history, per-category breakdown, and actionable control profiles with remediation guidance. + +**Endpoints called**: `GET /beta/tenants/{tenantId}/secureScores` — plus `GET /beta/tenants` when `-TenantId` is a GUID or name (resolution). +**Required API scope(s)**: `Tenants.SecureScores.Read` + `Tenants.Read` *(the `Tenants.Read` part can be skipped if `-TenantId` is always passed as a numeric Client Tenant ID — no lookup needed)* + +**Output schema**: [TenantSecureScore](./API-REFERENCE.md#tenantsecurescoredetails) — includes `Scores` (90-day history), `ControlProfiles` (actionable recommendations), and `ControlCategoryScores`. + +| Parameter | Type | Mandatory | Description | +|-----------|------|-----------|--------------| +| **TenantId** | Object | Yes | Inforcer tenant ID (numeric ID, GUID, or tenant name). Alias: `ClientTenantId`. | +| **OutputType** | String | No | `PowerShellObject` (default) or `JsonObject`. | + +### Examples + +```powershell +# Summary view for one tenant +Get-InforcerSecureScore -TenantId 139 + +# 90-day score history +(Get-InforcerSecureScore -TenantId 139).Scores + +# Top 10 controls with the biggest score gap (highest-value remediation targets) +(Get-InforcerSecureScore -TenantId 139).ControlProfiles | + Sort-Object ScoreDifference -Descending | + Select-Object Title, Service, CurrentScore, MaxScore, Remediation -First 10 + +# Pipeline from Get-InforcerTenant +Get-InforcerTenant -TenantId 139 | Get-InforcerSecureScore +``` + +### Example output (default list view) + +``` +CurrentScore : 412.5 +MaxScore : 640 +CurrentScorePercentage : 64.45 +LicensedUserCount : 275 +EnabledServices : AzureAD, Exchange, SharePoint, Defender +ScoreHistoryDays : 90 +ControlProfilesCount : 128 +``` + +Use `| Select-Object *` to see all properties including `Scores`, `ControlProfiles`, and `ControlCategoryScores`. + +--- + ## Export-InforcerTenantDocumentation Generates comprehensive, human-readable documentation of an entire M365 tenant's configuration as managed through the Inforcer API. Pulls data from existing cmdlets (`Get-InforcerBaseline`, `Get-InforcerTenant`, `Get-InforcerTenantPolicies`), resolves Intune Settings Catalog settingDefinitionIDs to friendly names, and outputs in multiple formats. diff --git a/module/InforcerCommunity.Format.ps1xml b/module/InforcerCommunity.Format.ps1xml index 1fbfbda..b955b10 100644 --- a/module/InforcerCommunity.Format.ps1xml +++ b/module/InforcerCommunity.Format.ps1xml @@ -582,5 +582,27 @@ + + InforcerCommunity.SecureScore.Default + + InforcerCommunity.SecureScore + + + + + + CurrentScore + MaxScore + CurrentScorePercentage + LicensedUserCount + if ($_.enabledServices) { ($_.enabledServices) -join ', ' } else { '' } + if ($_.scores) { @($_.scores).Count } else { 0 } + if ($_.controlProfiles) { @($_.controlProfiles).Count } else { 0 } + + + + + + diff --git a/module/InforcerCommunity.psd1 b/module/InforcerCommunity.psd1 index 9f578f8..7909567 100644 --- a/module/InforcerCommunity.psd1 +++ b/module/InforcerCommunity.psd1 @@ -1,6 +1,6 @@ @{ RootModule = 'InforcerCommunity.psm1' - ModuleVersion = '0.5.0' + ModuleVersion = '0.6.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.' @@ -21,6 +21,7 @@ 'Get-InforcerUser' 'Get-InforcerGroup' 'Get-InforcerRole' + 'Get-InforcerSecureScore' 'Export-InforcerTenantDocumentation' 'Compare-InforcerEnvironments' 'Get-InforcerAssessment' diff --git a/module/Private/Add-InforcerPropertyAliases.ps1 b/module/Private/Add-InforcerPropertyAliases.ps1 index c02e599..c06ec39 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', 'ReportType', 'ReportRun', 'ReportOutput')] + [ValidateSet('Tenant', 'Baseline', 'Policy', 'AlignmentScore', 'AlignmentDetail', 'AuditEvent', 'UserSummary', 'User', 'GroupSummary', 'Group', 'Role', 'Assessment', 'ReportType', 'ReportRun', 'ReportOutput', 'SecureScore')] [string]$ObjectType ) @@ -177,6 +177,7 @@ function Add-InforcerPropertyAliases { } } 'AuditEvent' { + AddAliasIfExists $obj 'Id' 'id' AddAliasIfExists $obj 'CorrelationId' 'correlationId' AddAliasIfExists $obj 'ClientId' 'clientId' AddAliasIfExists $obj 'RelType' 'relType' @@ -386,6 +387,49 @@ function Add-InforcerPropertyAliases { AddAliasIfExists $obj 'OutputFormat' 'format' AddAliasIfExists $obj 'FileSize' 'sizeBytes' } + 'SecureScore' { + AddAliasIfExists $obj 'CurrentScore' 'currentScore' + AddAliasIfExists $obj 'CurrentScorePercentage' 'currentScorePercentage' + AddAliasIfExists $obj 'MaxScore' 'maxScore' + AddAliasIfExists $obj 'LicensedUserCount' 'licensedUserCount' + AddAliasIfExists $obj 'EnabledServices' 'enabledServices' + AddAliasIfExists $obj 'Scores' 'scores' + AddAliasIfExists $obj 'ControlProfiles' 'controlProfiles' + AddAliasIfExists $obj 'ControlCategoryScores' 'controlCategoryScores' + # Nested historic score points + $scoresProp = $obj.PSObject.Properties['scores'] + if ($scoresProp -and $null -ne $scoresProp.Value) { + foreach ($s in @($scoresProp.Value)) { + if ($s -is [PSObject]) { + AddAliasIfExists $s 'CreatedDateTime' 'createdDateTime' + AddAliasIfExists $s 'CurrentScore' 'currentScore' + AddAliasIfExists $s 'CurrentScorePercentage' 'currentScorePercentage' + AddAliasIfExists $s 'MaxScore' 'maxScore' + } + } + } + # Nested control profiles + $cpProp = $obj.PSObject.Properties['controlProfiles'] + if ($cpProp -and $null -ne $cpProp.Value) { + foreach ($c in @($cpProp.Value)) { + if ($c -is [PSObject]) { + AddAliasIfExists $c 'Id' 'id' + AddAliasIfExists $c 'Title' 'title' + AddAliasIfExists $c 'ControlCategory' 'controlCategory' + AddAliasIfExists $c 'Service' 'service' + AddAliasIfExists $c 'CurrentScore' 'currentScore' + AddAliasIfExists $c 'CurrentScorePercentage' 'currentScorePercentage' + AddAliasIfExists $c 'MaxScore' 'maxScore' + AddAliasIfExists $c 'MaxScorePercentage' 'maxScorePercentage' + AddAliasIfExists $c 'ScoreDifference' 'scoreDifference' + AddAliasIfExists $c 'ScoreDifferencePercentage' 'scoreDifferencePercentage' + AddAliasIfExists $c 'Remediation' 'remediation' + AddAliasIfExists $c 'RemediationImpact' 'remediationImpact' + AddAliasIfExists $c 'ActionUrl' 'actionUrl' + } + } + } + } } $obj diff --git a/module/Public/Get-InforcerAuditEvent.ps1 b/module/Public/Get-InforcerAuditEvent.ps1 index 7eb8506..eaff72c 100644 --- a/module/Public/Get-InforcerAuditEvent.ps1 +++ b/module/Public/Get-InforcerAuditEvent.ps1 @@ -18,6 +18,8 @@ Start of date/time range (inclusive). Omit with DateTo = 10 years ago to now; if only DateTo set = 30 days before DateTo. .PARAMETER DateTo End of date/time range (inclusive). Omit with DateFrom = now (UTC). +.PARAMETER User + Filter events server-side by this user (matches the `user` field in the API request body). .PARAMETER PageSize Page size per API request. Default 100. .PARAMETER MaxResults @@ -111,6 +113,9 @@ param( [Parameter(Mandatory = $false)] [DateTime]$DateTo, + [Parameter(Mandatory = $false)] + [string]$User, + [Parameter(Mandatory = $false)] [int]$PageSize = 100, @@ -181,6 +186,9 @@ foreach ($batch in $typeBatches) { dateTo = $dateToStr pageSize = $PageSize } + if ($User) { + $bodyObj['user'] = $User + } if ($continuationToken) { $bodyObj['continuationToken'] = $continuationToken } diff --git a/module/Public/Get-InforcerSecureScore.ps1 b/module/Public/Get-InforcerSecureScore.ps1 new file mode 100644 index 0000000..d880144 --- /dev/null +++ b/module/Public/Get-InforcerSecureScore.ps1 @@ -0,0 +1,95 @@ +function Get-InforcerSecureScore { + <# + .SYNOPSIS + Retrieves the current and historic secure score for an Inforcer tenant. + + Required API scope(s): Tenants.SecureScores.Read + Tenants.Read (only when -TenantId is a GUID or tenant name) + + .DESCRIPTION + Gets secure score data for a tenant from GET /beta/tenants/{tenantId}/secureScores. + Returns the current score, max score, licensed user count, enabled services, up to + 90 days of daily score history, per-category scores, and actionable control profiles + with remediation guidance. + + .PARAMETER TenantId + The Inforcer tenant ID. Accepts numeric ID, GUID, or tenant name. Supports pipeline input. + + .PARAMETER OutputType + Output type: 'PowerShellObject' (default) or 'JsonObject'. + + .EXAMPLE + Get-InforcerSecureScore -TenantId 139 + + Returns the secure score summary for tenant 139. + + .EXAMPLE + (Get-InforcerSecureScore -TenantId 139).Scores + + Returns the 90-day daily score history. + + .EXAMPLE + (Get-InforcerSecureScore -TenantId 139).ControlProfiles | Sort-Object ScoreDifference -Descending | Select-Object -First 10 + + Lists the 10 controls with the largest gap between current and max score. + + .EXAMPLE + Get-InforcerTenant -TenantId 139 | Get-InforcerSecureScore + + Retrieves secure scores for the piped tenant. + + .EXAMPLE + Get-InforcerSecureScore -TenantId 139 -OutputType JsonObject + + Returns the response as a JSON string. + + .OUTPUTS + PSObject or String (when -OutputType JsonObject) + + .LINK + https://github.com/royklo/InforcerCommunity/blob/main/docs/CMDLET-REFERENCE.md#get-inforcersecurescore + + .LINK + Connect-Inforcer + #> + [CmdletBinding()] + [OutputType([PSObject], [string])] + param( + [Parameter(Mandatory, ValueFromPipelineByPropertyName)] + [Alias('ClientTenantId')] + [object]$TenantId, + + [Parameter(Mandatory = $false)] + [ValidateSet('PowerShellObject', 'JsonObject')] + [string]$OutputType = 'PowerShellObject' + ) + + process { + if (-not (Test-InforcerSession)) { + Write-Error -Message "Not connected to Inforcer. Use Connect-Inforcer first." -ErrorId 'NotConnected' -Category ConnectionError + return + } + + try { + $resolvedTenantId = Resolve-InforcerTenantId -TenantId $TenantId + } catch { + Write-Error -Message $_.Exception.Message -ErrorId 'InvalidTenantId' -Category InvalidArgument + return + } + + $endpoint = "/beta/tenants/$resolvedTenantId/secureScores" + Write-Verbose "Retrieving secure score for tenant $resolvedTenantId..." + + $response = Invoke-InforcerApiRequest -Endpoint $endpoint -Method GET -OutputType PowerShellObject + if ($null -eq $response) { return } + + if ($OutputType -eq 'JsonObject') { + return ($response | ConvertTo-Json -Depth 100) + } + + if ($response -is [PSObject]) { + $null = Add-InforcerPropertyAliases -InputObject $response -ObjectType SecureScore + $response.PSObject.TypeNames.Insert(0, 'InforcerCommunity.SecureScore') + } + $response + } +} diff --git a/scripts/Test-AllCmdlets.ps1 b/scripts/Test-AllCmdlets.ps1 index b131809..15e4989 100644 --- a/scripts/Test-AllCmdlets.ps1 +++ b/scripts/Test-AllCmdlets.ps1 @@ -57,6 +57,9 @@ foreach ($name in $exported) { 'Get-InforcerRole' { $out = & $name -TenantId 1 -ErrorVariable err -ErrorAction SilentlyContinue } + 'Get-InforcerSecureScore' { + $out = & $name -TenantId 1 -ErrorVariable err -ErrorAction SilentlyContinue + } 'Get-InforcerSupportedEventType' { $out = & $name -ErrorVariable err -ErrorAction SilentlyContinue } From 31ac0cf3be2eb5ddbb0b822bad5c52a124ec52bb Mon Sep 17 00:00:00 2001 From: Roy Klooster <76492458+royklo@users.noreply.github.com> Date: Mon, 6 Jul 2026 23:37:59 +0200 Subject: [PATCH 3/3] fix: correct response-shape and aliasing bugs found by live-API verification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two `Invoke-InforcerApiRequest` callers were missing `-PreserveStructure`, causing the helper's "if .data is a single object with an array property, unwrap to that array" convenience to return the wrong shape for single-object endpoints: - `Get-InforcerSecureScore`: was returning the 90-item `scores` array instead of the top-level `tenantSecureScoreDetails` object. PSTypeName was never applied. Live-verified fix: 90/90 assertions pass, aliases populated on the top-level object plus all three nested arrays. - `Get-InforcerUser -UserId`: same bug pattern — was returning one of the many nested arrays (groups, roles, devices, assignedLicenses, ...) instead of the user object. Live-verified fix. Also: - `SecureScore` aliasing now covers `controlCategoryScores` and its inner `historicScores` array (previously only top-level, `.Scores`, and `.ControlProfiles` were aliased). - `-EventType` tab completion is now dynamic — reads `$global:InforcerCachedEventTypes` (populated by `Get-InforcerSupportedEventType` at module import, refreshed on every authenticated call). New server-side event types show up automatically without a module release. Static fallback list updated to today's live 80 types (from 57). - `ReleaseNotes` in the psd1 manifest updated for 0.6.0 (was still describing 0.5.0). - CHANGELOG entry extended to cover all of the above. 162/162 Pester pass. Live-API verification against uk.inforcer.com: 85 assertions pass, 5 failures are all bugs in the verification harness (hashtable-splat mistakes), not the module. --- CHANGELOG.md | 2 + module/InforcerCommunity.psd1 | 2 +- .../Private/Add-InforcerPropertyAliases.ps1 | 25 ++++++++++ module/Public/Get-InforcerAuditEvent.ps1 | 33 ++++--------- module/Public/Get-InforcerSecureScore.ps1 | 7 ++- .../Public/Get-InforcerSupportedEventType.ps1 | 46 +++++++++++++++---- module/Public/Get-InforcerUser.ps1 | 5 +- 7 files changed, 83 insertions(+), 37 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eb6a36b..19f6a3d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ The format follows [Conventional Commits](https://www.conventionalcommits.org/). - **New cmdlet: `Get-InforcerSecureScore`** — retrieves the current and historic Microsoft Secure Score for a tenant from `GET /beta/tenants/{tenantId}/secureScores`. Returns the current score, max score, licensed user count, enabled services, up to 90 days of daily score history, per-category scores, and actionable control profiles with remediation guidance. `-TenantId` accepts numeric ID, GUID, or tenant name (name/GUID resolution via `Resolve-InforcerTenantId`). Supports `-OutputType JsonObject`. Required scopes: `Tenants.SecureScores.Read` + `Tenants.Read` (only when `-TenantId` is a GUID or tenant name). PSTypeName `InforcerCommunity.SecureScore` with a ListControl default view showing current/max/percentage plus counts of nested arrays. - **`Get-InforcerAuditEvent -User `** — new parameter that server-side filters audit events by the specified user (matches the `user` field in the `POST /beta/auditEvents/search` request body). Previously users had to fetch all events and filter client-side. - **`Id` alias on audit event output** — `AuditEvent` objects now expose the raw `id` field as PascalCase `Id`, consistent with every other object type in the module. +- **Dynamic `-EventType` tab completion** — the completer on `Get-InforcerAuditEvent -EventType` now reads `$global:InforcerCachedEventTypes`, which `Get-InforcerSupportedEventType` refreshes with the live server-side list on any authenticated call. New event types the API adds appear in tab completion automatically after the next `Get-InforcerSupportedEventType` call — no module release required. A static fallback list (currently 80 event types) covers the pre-connect case. +- **`Get-InforcerSecureScore` nested aliasing extended** — `.ControlCategoryScores` and its inner `.historicScores` now get PascalCase aliases too (previously only top-level, `.Scores`, and `.ControlProfiles` were aliased). ### Documentation diff --git a/module/InforcerCommunity.psd1 b/module/InforcerCommunity.psd1 index 7909567..e42deff 100644 --- a/module/InforcerCommunity.psd1 +++ b/module/InforcerCommunity.psd1 @@ -38,7 +38,7 @@ PSData = @{ ProjectUri = 'https://github.com/royklo/InforcerCommunity' LicenseUri = 'https://github.com/royklo/InforcerCommunity/blob/main/LICENSE' - 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.' + ReleaseNotes = 'v0.6.0: New cmdlet Get-InforcerSecureScore wraps GET /beta/tenants/{tenantId}/secureScores — current + 90-day daily score history, per-category scores, and actionable control profiles with remediation. Get-InforcerAuditEvent gains a -User parameter for server-side filtering and now exposes the raw event id as PascalCase Id. -EventType tab completion is now dynamic — reads $global:InforcerCachedEventTypes refreshed by Get-InforcerSupportedEventType, so new server-side event types show up automatically without a module release. Required API scope(s) documentation added to Get-Help output for 13 cmdlets that were missing it. Stale PolicyDiffFormatted mention removed from CMDLET-REFERENCE. See CHANGELOG.md for full details.' Tags = @('Inforcer', 'API', 'Community') } } diff --git a/module/Private/Add-InforcerPropertyAliases.ps1 b/module/Private/Add-InforcerPropertyAliases.ps1 index c06ec39..a3b01bf 100644 --- a/module/Private/Add-InforcerPropertyAliases.ps1 +++ b/module/Private/Add-InforcerPropertyAliases.ps1 @@ -429,6 +429,31 @@ function Add-InforcerPropertyAliases { } } } + # Nested per-category scores + $ccsProp = $obj.PSObject.Properties['controlCategoryScores'] + if ($ccsProp -and $null -ne $ccsProp.Value) { + foreach ($cc in @($ccsProp.Value)) { + if ($cc -is [PSObject]) { + AddAliasIfExists $cc 'ControlCategory' 'controlCategory' + AddAliasIfExists $cc 'CurrentScore' 'currentScore' + AddAliasIfExists $cc 'CurrentScorePercentage' 'currentScorePercentage' + AddAliasIfExists $cc 'MaxScore' 'maxScore' + AddAliasIfExists $cc 'HistoricScores' 'historicScores' + # Historic score points inside each category + $hsProp = $cc.PSObject.Properties['historicScores'] + if ($hsProp -and $null -ne $hsProp.Value) { + foreach ($h in @($hsProp.Value)) { + if ($h -is [PSObject]) { + AddAliasIfExists $h 'CreatedDateTime' 'createdDateTime' + AddAliasIfExists $h 'CurrentScore' 'currentScore' + AddAliasIfExists $h 'CurrentScorePercentage' 'currentScorePercentage' + AddAliasIfExists $h 'MaxScore' 'maxScore' + } + } + } + } + } + } } } diff --git a/module/Public/Get-InforcerAuditEvent.ps1 b/module/Public/Get-InforcerAuditEvent.ps1 index eaff72c..33f63f6 100644 --- a/module/Public/Get-InforcerAuditEvent.ps1 +++ b/module/Public/Get-InforcerAuditEvent.ps1 @@ -69,30 +69,15 @@ param( $prefix = ($prefix -split ',' | ForEach-Object { $_.Trim() })[-1] } $prefix = $prefix.Trim() - # Inline list so completer never depends on script scope (avoids path completion fallback) - $types = @( - 'alertRuleCreate', 'alertRuleDelete', 'alertRuleUpdate', - 'apiKeyCreate', 'apiKeyDelete', 'apiKeyUsage', - 'authentication', - 'clientAdminUpdated', 'clientCreated', 'clientLicenseUpdate', 'clientStatusChanged', - 'copilotAssessmentFailure', 'copilotAssessmentRun', 'copilotAssessmentSuccess', - 'failedAuthentication', - 'policiesDelete', 'policiesDeployment', 'policiesRename', 'policiesRestore', - 'reportQueued', - 'salesAdminUpdated', - 'scheduleCreate', 'scheduleDelete', 'scheduleUpdate', - 'sharedBaselinesManaged', - 'supportAccessInvoke', - 'tenantAssessmentFailure', 'tenantAssessmentRun', 'tenantAssessmentSuccess', - 'tenantDelete', 'tenantGroupCreate', 'tenantGroupMembershipsModified', 'tenantGroupUpdate', 'tenantGroupsDeployment', - 'tenantLicenseUpdate', 'tenantOnboard', 'tenantRefresh', - 'tenantUserCreate', 'tenantUserGroupMembershipModified', 'tenantUserLicensesModified', - 'tenantUserOffboardingQueued', 'tenantUserResetMfa', 'tenantUserResetPassword', - 'tenantUserRevokedSessions', 'tenantUserUpdate', - 'userAutoProvision', 'userCreate', 'userDelete', - 'userGroupCreate', 'userGroupDelete', 'userGroupMembershipModified', 'userGroupUpdate', - 'userResetMfa', 'userResetPassword', 'userToggleEnable', 'userToggleSso' - ) + # Dynamic: read $global:InforcerCachedEventTypes populated by Get-InforcerSupportedEventType.ps1 + # at module import (static fallback) and refreshed with live data after any authenticated + # Get-InforcerSupportedEventType call. Two-item minimum fallback covers the case where a user + # somehow imports only Get-InforcerAuditEvent without the sibling file. + $types = if ($global:InforcerCachedEventTypes -and $global:InforcerCachedEventTypes.Count -gt 0) { + $global:InforcerCachedEventTypes + } else { + @('authentication', 'failedAuthentication') + } $filterByPrefix = $prefix -and $prefix -notmatch '^[./\\]' if ($filterByPrefix) { $filtered = @($types | Where-Object { $_ -like "$prefix*" }) diff --git a/module/Public/Get-InforcerSecureScore.ps1 b/module/Public/Get-InforcerSecureScore.ps1 index d880144..06f921b 100644 --- a/module/Public/Get-InforcerSecureScore.ps1 +++ b/module/Public/Get-InforcerSecureScore.ps1 @@ -79,7 +79,12 @@ function Get-InforcerSecureScore { $endpoint = "/beta/tenants/$resolvedTenantId/secureScores" Write-Verbose "Retrieving secure score for tenant $resolvedTenantId..." - $response = Invoke-InforcerApiRequest -Endpoint $endpoint -Method GET -OutputType PowerShellObject + # -PreserveStructure prevents Invoke-InforcerApiRequest from unwrapping the first array + # property of .data. The secure-score payload is a single object whose interesting + # content sits in its `scores`, `controlProfiles`, and `controlCategoryScores` arrays — + # without -PreserveStructure the caller would receive one of those inner arrays instead + # of the top-level tenantSecureScoreDetails object. + $response = Invoke-InforcerApiRequest -Endpoint $endpoint -Method GET -OutputType PowerShellObject -PreserveStructure if ($null -eq $response) { return } if ($OutputType -eq 'JsonObject') { diff --git a/module/Public/Get-InforcerSupportedEventType.ps1 b/module/Public/Get-InforcerSupportedEventType.ps1 index df41cda..d04ab4e 100644 --- a/module/Public/Get-InforcerSupportedEventType.ps1 +++ b/module/Public/Get-InforcerSupportedEventType.ps1 @@ -1,31 +1,57 @@ # Internal: returns audit event type names from the API. # Used by Get-InforcerAuditEvent when -EventType is omitted (resolve "all types"). -# Tab completion for -EventType uses the static list in Get-InforcerAuditEvent.ps1 so it always works. -# Uses global cache for API response so "all types" resolution is consistent. +# $global:InforcerCachedEventTypes is populated at module import from the static +# fallback below, then refreshed with the live API response on the first +# authenticated call. The -EventType ArgumentCompleter in Get-InforcerAuditEvent.ps1 +# reads this same variable, so tab completion tracks the server-side list once the +# user is connected — no need to ship a module release when new event types arrive. if (-not $global:InforcerCachedEventTypes) { + # Static fallback used until an authenticated Get-InforcerSupportedEventType call + # refreshes this cache with the live server-side list. Kept alphabetically sorted so + # diffs against a live API dump are readable. $global:InforcerCachedEventTypes = @( 'alertRuleCreate', 'alertRuleDelete', 'alertRuleUpdate', - 'apiKeyCreate', 'apiKeyDelete', 'apiKeyUsage', + 'apiKeyCreate', 'apiKeyDelete', 'apiKeyUpdate', 'apiKeyUsage', + 'assignmentsModification', 'authentication', - 'clientAdminUpdated', 'clientCreated', 'clientLicenseUpdate', 'clientStatusChanged', + 'clientAdminUpdated', 'clientCreated', 'clientLicenseUpdate', + 'clientSsoConfigurationCreated', 'clientSsoConfigurationRemoved', + 'clientSsoConfigurationToggled', 'clientSsoConfigurationUpdated', + 'clientStatusChanged', 'copilotAssessmentFailure', 'copilotAssessmentRun', 'copilotAssessmentSuccess', + 'copilotManagerToggle', 'failedAuthentication', + 'onboardingLinkCreated', 'onboardingLinkDeleted', + 'onboardingLinkSlugRotated', 'onboardingLinkUpdated', 'policiesDelete', 'policiesDeployment', 'policiesRename', 'policiesRestore', 'reportQueued', 'salesAdminUpdated', 'scheduleCreate', 'scheduleDelete', 'scheduleUpdate', + 'securityGroupCreate', 'securityGroupDelete', 'securityGroupFilterUpdate', + 'securityGroupMembersAdded', 'securityGroupMembersRemoved', + 'securityGroupRoleUpdate', 'securityGroupUpdate', 'sharedBaselinesManaged', 'supportAccessInvoke', 'tenantAssessmentFailure', 'tenantAssessmentRun', 'tenantAssessmentSuccess', - 'tenantDelete', 'tenantGroupCreate', 'tenantGroupMembershipsModified', 'tenantGroupUpdate', 'tenantGroupsDeployment', + 'tenantDelete', + 'tenantGroupCreate', 'tenantGroupMembershipsModified', + 'tenantGroupUpdate', 'tenantGroupsDeployment', 'tenantLicenseUpdate', 'tenantOnboard', 'tenantRefresh', - 'tenantUserCreate', 'tenantUserGroupMembershipModified', 'tenantUserLicensesModified', - 'tenantUserOffboardingQueued', 'tenantUserResetMfa', 'tenantUserResetPassword', - 'tenantUserRevokedSessions', 'tenantUserUpdate', + 'tenantUserAuthenticationMethodDelete', + 'tenantUserCreate', 'tenantUserGroupMembershipModified', + 'tenantUserLicensesModified', + 'tenantUserOffboardingFailed', 'tenantUserOffboardingQueued', + 'tenantUserOffboardingScheduled', 'tenantUserOffboardingSucceeded', + 'tenantUserResetMfa', 'tenantUserResetPassword', + 'tenantUserRevokedSessions', + 'tenantUserTemporaryAccessPassCreate', + 'tenantUserUpdate', 'userAutoProvision', 'userCreate', 'userDelete', - 'userGroupCreate', 'userGroupDelete', 'userGroupMembershipModified', 'userGroupUpdate', - 'userResetMfa', 'userResetPassword', 'userToggleEnable', 'userToggleSso' + 'userGroupCreate', 'userGroupDelete', + 'userGroupMembershipModified', 'userGroupUpdate', + 'userResetMfa', 'userResetPassword', + 'userToggleClientAdmin', 'userToggleEnable', 'userToggleSso' ) } diff --git a/module/Public/Get-InforcerUser.ps1 b/module/Public/Get-InforcerUser.ps1 index 7f63072..02b54bf 100644 --- a/module/Public/Get-InforcerUser.ps1 +++ b/module/Public/Get-InforcerUser.ps1 @@ -98,7 +98,10 @@ function Get-InforcerUser { # --- ById: single user detail --- $endpoint = "/beta/tenants/$resolvedTenantId/users/$UserId" $err = $null - $response = Invoke-InforcerApiRequest -Endpoint $endpoint -Method GET -OutputType PowerShellObject -ErrorVariable err -ErrorAction SilentlyContinue + # -PreserveStructure: user detail response is a single object with many nested arrays + # (groups, roles, devices, appRoleAssignments, assignedLicenses, businessPhones, ...). + # Without it, Invoke-InforcerApiRequest would unwrap to the first array property. + $response = Invoke-InforcerApiRequest -Endpoint $endpoint -Method GET -OutputType PowerShellObject -PreserveStructure -ErrorVariable err -ErrorAction SilentlyContinue if ($null -eq $response) { # Only emit UserNotFound if API returned 404; otherwise the API helper already wrote the real error