From b2985a6f07cd60c812747367d8c64e64add34cb6 Mon Sep 17 00:00:00 2001 From: Bobby <31723128+kris6673@users.noreply.github.com> Date: Mon, 13 Jul 2026 18:25:26 +0200 Subject: [PATCH 01/16] fix(assignments): change default mode to append for consistency --- .../Public/Set-CIPPAssignedApplication.ps1 | 2 +- .../Public/Set-CIPPAssignedPolicy.ps1 | 2 +- .../Applications/Invoke-ExecAssignApp.ps1 | 2 +- .../Endpoint/MEM/Invoke-ExecAssignPolicy.ps1 | 2 +- Tests/Endpoint/Invoke-ExecAssignApp.Tests.ps1 | 66 +++++++++++++++++++ .../Invoke-ExecAssignPolicy.Tests.ps1 | 64 ++++++++++++++++++ 6 files changed, 134 insertions(+), 4 deletions(-) create mode 100644 Tests/Endpoint/Invoke-ExecAssignApp.Tests.ps1 create mode 100644 Tests/Endpoint/Invoke-ExecAssignPolicy.Tests.ps1 diff --git a/Modules/CIPPCore/Public/Set-CIPPAssignedApplication.ps1 b/Modules/CIPPCore/Public/Set-CIPPAssignedApplication.ps1 index 18abcd1c7c659..ac9db1621c57e 100644 --- a/Modules/CIPPCore/Public/Set-CIPPAssignedApplication.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPAssignedApplication.ps1 @@ -10,7 +10,7 @@ function Set-CIPPAssignedApplication { $ApplicationId, $TenantFilter, $GroupIds, - $AssignmentMode = 'replace', + $AssignmentMode = 'append', $AssignmentDirection, $APIName = 'Assign Application', $Headers, diff --git a/Modules/CIPPCore/Public/Set-CIPPAssignedPolicy.ps1 b/Modules/CIPPCore/Public/Set-CIPPAssignedPolicy.ps1 index faff217f016f1..568ee56c4afaa 100644 --- a/Modules/CIPPCore/Public/Set-CIPPAssignedPolicy.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPAssignedPolicy.ps1 @@ -15,7 +15,7 @@ function Set-CIPPAssignedPolicy { $AssignmentFilterType = 'include', $GroupIds, $GroupNames, - $AssignmentMode = 'replace', + $AssignmentMode = 'append', $AssignmentDirection ) diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/Applications/Invoke-ExecAssignApp.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/Applications/Invoke-ExecAssignApp.ps1 index 1f6e1fcb6edef..dac3e6bba060c 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/Applications/Invoke-ExecAssignApp.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/Applications/Invoke-ExecAssignApp.ps1 @@ -31,7 +31,7 @@ function Invoke-ExecAssignApp { $Intent = if ([string]::IsNullOrWhiteSpace($Intent)) { 'Required' } else { $Intent } if ([string]::IsNullOrWhiteSpace($AssignmentMode)) { - $AssignmentMode = 'replace' + $AssignmentMode = 'append' } else { $AssignmentMode = $AssignmentMode.ToLower() if ($AssignmentMode -notin @('replace', 'append')) { diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ExecAssignPolicy.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ExecAssignPolicy.ps1 index 722a5f37cdc04..147f670e64fd5 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ExecAssignPolicy.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ExecAssignPolicy.ps1 @@ -49,7 +49,7 @@ function Invoke-ExecAssignPolicy { # Validate and default AssignmentMode if ([string]::IsNullOrWhiteSpace($AssignmentMode)) { - $AssignmentMode = 'replace' + $AssignmentMode = 'append' } $AssignTo = if ($AssignTo -ne 'on') { $AssignTo } diff --git a/Tests/Endpoint/Invoke-ExecAssignApp.Tests.ps1 b/Tests/Endpoint/Invoke-ExecAssignApp.Tests.ps1 new file mode 100644 index 0000000000000..306807a4565ca --- /dev/null +++ b/Tests/Endpoint/Invoke-ExecAssignApp.Tests.ps1 @@ -0,0 +1,66 @@ +# Pester tests for Invoke-ExecAssignApp assignment mode defaults. + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $FunctionPath = Get-ChildItem -Path (Join-Path $RepoRoot 'Modules') -Recurse -Filter 'Invoke-ExecAssignApp.ps1' -File | + Select-Object -First 1 -ExpandProperty FullName + + ([PSObject].Assembly.GetType('System.Management.Automation.TypeAccelerators')).GetMethod('Add').Invoke( + $null, @('HttpStatusCode', [System.Net.HttpStatusCode])) + + class HttpResponseContext { + [int]$StatusCode + [object]$Body + } + + function Set-CIPPAssignedApplication { param($ApplicationId, $TenantFilter, $Intent, $APIName, $Headers, $GroupName, $AssignmentMode) } + + . $FunctionPath +} + +Describe 'Invoke-ExecAssignApp assignment mode' { + BeforeEach { + $script:assignmentMode = $null + Mock -CommandName Set-CIPPAssignedApplication -MockWith { + $script:assignmentMode = $AssignmentMode + } + } + + It 'defaults omitted assignment mode to append' { + $request = [pscustomobject]@{ + Params = @{ CIPPEndpoint = 'ExecAssignApp' } + Headers = @{} + Query = [pscustomobject]@{} + Body = [pscustomobject]@{ + tenantFilter = 'contoso.onmicrosoft.com' + ID = 'app-1' + AppType = 'Win32Lob' + AssignTo = 'AllDevices' + } + } + + $response = Invoke-ExecAssignApp -Request $request -TriggerMetadata $null + + $response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::OK) + $script:assignmentMode | Should -Be 'append' + } + + It 'preserves explicit replace mode' { + $request = [pscustomobject]@{ + Params = @{ CIPPEndpoint = 'ExecAssignApp' } + Headers = @{} + Query = [pscustomobject]@{} + Body = [pscustomobject]@{ + tenantFilter = 'contoso.onmicrosoft.com' + ID = 'app-1' + AppType = 'Win32Lob' + AssignTo = 'AllDevices' + assignmentMode = 'replace' + } + } + + $null = Invoke-ExecAssignApp -Request $request -TriggerMetadata $null + + $script:assignmentMode | Should -Be 'replace' + } +} diff --git a/Tests/Endpoint/Invoke-ExecAssignPolicy.Tests.ps1 b/Tests/Endpoint/Invoke-ExecAssignPolicy.Tests.ps1 new file mode 100644 index 0000000000000..e600ac0dd7825 --- /dev/null +++ b/Tests/Endpoint/Invoke-ExecAssignPolicy.Tests.ps1 @@ -0,0 +1,64 @@ +# Pester tests for Invoke-ExecAssignPolicy assignment mode defaults. + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $FunctionPath = Get-ChildItem -Path (Join-Path $RepoRoot 'Modules') -Recurse -Filter 'Invoke-ExecAssignPolicy.ps1' -File | + Select-Object -First 1 -ExpandProperty FullName + + ([PSObject].Assembly.GetType('System.Management.Automation.TypeAccelerators')).GetMethod('Add').Invoke( + $null, @('HttpStatusCode', [System.Net.HttpStatusCode])) + + class HttpResponseContext { + [int]$StatusCode + [object]$Body + } + + function Set-CIPPAssignedPolicy { param($PolicyId, $TenantFilter, $GroupName, $Type, $Headers, $AssignmentMode) } + + . $FunctionPath +} + +Describe 'Invoke-ExecAssignPolicy assignment mode' { + BeforeEach { + $script:assignmentMode = $null + Mock -CommandName Set-CIPPAssignedPolicy -MockWith { + $script:assignmentMode = $AssignmentMode + } + } + + It 'defaults omitted assignment mode to append' { + $request = [pscustomobject]@{ + Params = @{ CIPPEndpoint = 'ExecAssignPolicy' } + Headers = @{} + Body = [pscustomobject]@{ + tenantFilter = 'contoso.onmicrosoft.com' + ID = 'policy-1' + Type = 'configurationPolicies' + AssignTo = 'AllDevices' + } + } + + $response = Invoke-ExecAssignPolicy -Request $request -TriggerMetadata $null + + $response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::OK) + $script:assignmentMode | Should -Be 'append' + } + + It 'preserves explicit replace mode' { + $request = [pscustomobject]@{ + Params = @{ CIPPEndpoint = 'ExecAssignPolicy' } + Headers = @{} + Body = [pscustomobject]@{ + tenantFilter = 'contoso.onmicrosoft.com' + ID = 'policy-1' + Type = 'configurationPolicies' + AssignTo = 'AllDevices' + assignmentMode = 'replace' + } + } + + $null = Invoke-ExecAssignPolicy -Request $request -TriggerMetadata $null + + $script:assignmentMode | Should -Be 'replace' + } +} From 5b42363cd7f489e0ef4f523f1d568e909f6567dd Mon Sep 17 00:00:00 2001 From: James Tarran Date: Tue, 14 Jul 2026 09:35:52 +0100 Subject: [PATCH 02/16] feat(nudge-mfa): add group targeting and auth method selection Expand the NudgeMFA standard to support: - Multiple authentication methods (Authenticator, Passkey) - Group-level include/exclude targeting with group name resolution - Snooze enforcement limits (max 3 snoozes) Adds Set-CIPPRegistrationCampaign helper function as a single writer for the campaign, shared by the new ExecRegistrationCampaign HTTP endpoint and the enhanced NudgeMFA standard. The standard now supports both portal-configured and template-specified group targeting, with full compliance comparison. --- Config/standards.json | 56 +++++- .../Public/Set-CIPPRegistrationCampaign.ps1 | 93 ++++++++++ .../Invoke-ExecRegistrationCampaign.ps1 | 80 ++++++++ .../Standards/Invoke-CIPPStandardNudgeMFA.ps1 | 171 ++++++++++++++---- 4 files changed, 364 insertions(+), 36 deletions(-) create mode 100644 Modules/CIPPCore/Public/Set-CIPPRegistrationCampaign.ps1 create mode 100644 Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Invoke-ExecRegistrationCampaign.ps1 diff --git a/Config/standards.json b/Config/standards.json index 5a8d0a83eba9f..0feef69292203 100644 --- a/Config/standards.json +++ b/Config/standards.json @@ -1371,21 +1371,38 @@ "cat": "Entra (AAD) Standards", "tag": ["SMB1001 (2.5)"], "appliesToTest": ["SMB1001_2_5", "ZTNA21889"], - "helpText": "Sets the state of the registration campaign for the tenant", - "docsDescription": "Sets the state of the registration campaign for the tenant. If enabled nudges users to set up the Microsoft Authenticator during sign-in.", + "helpText": "Sets the state of the registration campaign for the tenant, including the targeted authentication method, snooze settings and include/exclude groups. Leave include/exclude blank to keep the groups currently configured in the tenant, or use 'AllUsers' to target all users.", + "docsDescription": "Sets the state of the registration campaign for the tenant. If enabled nudges users to set up the targeted authentication method (Microsoft Authenticator or a Passkey) during sign-in. Supports limiting the number of snoozes, and including or excluding specific groups (by display name).", "executiveText": "Prompts employees to set up multi-factor authentication during login, gradually improving the organization's security posture by encouraging adoption of stronger authentication methods. This helps achieve better security compliance without forcing immediate mandatory changes.", "addedComponent": [ { "type": "autoComplete", "multiple": false, "creatable": false, - "label": "Select value", + "label": "Registration campaign state", "name": "standards.NudgeMFA.state", "options": [ { "label": "Enabled", "value": "enabled" }, { "label": "Disabled", "value": "disabled" } ] }, + { + "type": "autoComplete", + "multiple": false, + "creatable": false, + "required": false, + "label": "Authentication method to nudge users to register (default is Microsoft Authenticator)", + "name": "standards.NudgeMFA.targetedAuthenticationMethod", + "options": [ + { "label": "Microsoft Authenticator", "value": "microsoftAuthenticator" }, + { "label": "Passkey (FIDO2)", "value": "fido2" } + ], + "condition": { + "field": "standards.NudgeMFA.state", + "compareType": "valueEq", + "compareValue": "enabled" + } + }, { "type": "number", "name": "standards.NudgeMFA.snoozeDurationInDays", @@ -1395,6 +1412,39 @@ "min": { "value": 0, "message": "Minimum value is 0" }, "max": { "value": 14, "message": "Maximum value is 14" } } + }, + { + "type": "switch", + "name": "standards.NudgeMFA.enforceRegistrationAfterAllowedSnoozes", + "label": "Limited number of snoozes (require registration after 3 snoozes)", + "defaultValue": true, + "condition": { + "field": "standards.NudgeMFA.state", + "compareType": "valueEq", + "compareValue": "enabled" + } + }, + { + "type": "textField", + "name": "standards.NudgeMFA.includeTargets", + "label": "Include groups (comma separated group names, 'AllUsers' for everyone, blank = keep current targets)", + "required": false, + "condition": { + "field": "standards.NudgeMFA.state", + "compareType": "valueEq", + "compareValue": "enabled" + } + }, + { + "type": "textField", + "name": "standards.NudgeMFA.excludeTargets", + "label": "Exclude groups (comma separated group names, blank = keep current exclusions)", + "required": false, + "condition": { + "field": "standards.NudgeMFA.state", + "compareType": "valueEq", + "compareValue": "enabled" + } } ], "label": "Sets the state for the request to setup Authenticator", diff --git a/Modules/CIPPCore/Public/Set-CIPPRegistrationCampaign.ps1 b/Modules/CIPPCore/Public/Set-CIPPRegistrationCampaign.ps1 new file mode 100644 index 0000000000000..52b7e0e29544f --- /dev/null +++ b/Modules/CIPPCore/Public/Set-CIPPRegistrationCampaign.ps1 @@ -0,0 +1,93 @@ +function Set-CIPPRegistrationCampaign { + <# + .SYNOPSIS + Updates the authentication methods registration campaign (nudge) for a tenant. + .DESCRIPTION + Single writer for the registration campaign, shared by the ExecRegistrationCampaign + endpoint and the NudgeMFA standard. Any parameter left as $null keeps the value + currently configured in the tenant, so callers can update settings independently. + .PARAMETER IncludeTargets + Array of @{ id; targetType } targets. $null keeps the current include targets. The + targeted authentication method is applied to every include target, and a campaign + always ends up with at least one include target (falls back to all_users). + .PARAMETER ExcludeTargets + Array of @{ id; targetType } targets. $null keeps the current exclusions, an empty + array clears them. + #> + [CmdletBinding(SupportsShouldProcess = $true)] + param( + [Parameter(Mandatory = $true)]$Tenant, + $State, + $TargetedAuthenticationMethod, + $SnoozeDurationInDays, + $EnforceRegistrationAfterAllowedSnoozes, + $IncludeTargets, + $ExcludeTargets, + $APIName = 'Set Registration Campaign', + $Headers + ) + + try { + $CurrentPolicy = New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/policies/authenticationMethodsPolicy' -tenantid $Tenant + $CurrentCampaign = $CurrentPolicy.registrationEnforcement.authenticationMethodsRegistrationCampaign + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -headers $Headers -API $APIName -tenant $Tenant -message "Could not get the current registration campaign. Error: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage + throw "Could not get the current registration campaign. Error: $($ErrorMessage.NormalizedError)" + } + + $DesiredState = $State ?? $CurrentCampaign.state + $DesiredMethod = $TargetedAuthenticationMethod ?? (@($CurrentCampaign.includeTargets).targetedAuthenticationMethod | Select-Object -First 1) ?? 'microsoftAuthenticator' + $DesiredSnooze = if ($null -ne $SnoozeDurationInDays) { [int]$SnoozeDurationInDays } else { [int]($CurrentCampaign.snoozeDurationInDays ?? 1) } + $DesiredEnforce = if ($null -ne $EnforceRegistrationAfterAllowedSnoozes) { [bool]$EnforceRegistrationAfterAllowedSnoozes } else { [bool]$CurrentCampaign.enforceRegistrationAfterAllowedSnoozes } + + if ($DesiredState -notin @('default', 'enabled', 'disabled')) { + throw "State must be one of 'default', 'enabled' or 'disabled'" + } + if ($DesiredMethod -notin @('microsoftAuthenticator', 'fido2')) { + throw "TargetedAuthenticationMethod must be 'microsoftAuthenticator' or 'fido2'" + } + if ($DesiredSnooze -lt 0 -or $DesiredSnooze -gt 14) { + throw 'SnoozeDurationInDays must be between 0 and 14' + } + + $DesiredIncludeTargets = if ($null -ne $IncludeTargets) { + @($IncludeTargets | ForEach-Object { @{ id = "$($_.id)"; targetType = "$($_.targetType)"; targetedAuthenticationMethod = $DesiredMethod } }) + } else { + @($CurrentCampaign.includeTargets | ForEach-Object { @{ id = $_.id; targetType = $_.targetType; targetedAuthenticationMethod = $DesiredMethod } }) + } + if ($DesiredIncludeTargets.Count -eq 0) { + $DesiredIncludeTargets = @(@{ id = 'all_users'; targetType = 'group'; targetedAuthenticationMethod = $DesiredMethod }) + } + + $DesiredExcludeTargets = if ($null -ne $ExcludeTargets) { + @($ExcludeTargets | ForEach-Object { @{ id = "$($_.id)"; targetType = "$($_.targetType)" } }) + } else { + @($CurrentCampaign.excludeTargets | ForEach-Object { @{ id = $_.id; targetType = $_.targetType } }) + } + + $Body = @{ + registrationEnforcement = @{ + authenticationMethodsRegistrationCampaign = @{ + state = $DesiredState + snoozeDurationInDays = $DesiredSnooze + enforceRegistrationAfterAllowedSnoozes = $DesiredEnforce + includeTargets = @($DesiredIncludeTargets) + excludeTargets = @($DesiredExcludeTargets) + } + } + } | ConvertTo-Json -Depth 10 -Compress + + try { + $Result = "Set the registration campaign state to $DesiredState targeting $DesiredMethod with a snooze duration of $DesiredSnooze day(s), $($DesiredIncludeTargets.Count) include target(s) and $($DesiredExcludeTargets.Count) exclude target(s)" + if ($PSCmdlet.ShouldProcess('Registration campaign', "Set state to $DesiredState")) { + $null = New-GraphPostRequest -tenantid $Tenant -Uri 'https://graph.microsoft.com/beta/policies/authenticationMethodsPolicy' -Type PATCH -Body $Body -ContentType 'application/json' -AsApp $false + Write-LogMessage -headers $Headers -API $APIName -tenant $Tenant -message $Result -sev Info + } + return $Result + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -headers $Headers -API $APIName -tenant $Tenant -message "Failed to update the registration campaign. Error: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage + throw "Failed to update the registration campaign. Error: $($ErrorMessage.NormalizedError)" + } +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Invoke-ExecRegistrationCampaign.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Invoke-ExecRegistrationCampaign.ps1 new file mode 100644 index 0000000000000..ce03c60af94d2 --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Invoke-ExecRegistrationCampaign.ps1 @@ -0,0 +1,80 @@ +function Invoke-ExecRegistrationCampaign { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Tenant.Administration.ReadWrite + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers + $TenantFilter = $Request.Body.tenantFilter.value ?? $Request.Body.tenantFilter + + if (-not $TenantFilter) { + return [HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::BadRequest + Body = @{ Results = 'Error: tenantFilter is required' } + } + } + + if ($null -ne $Request.Body.snoozeDurationInDays -and ([int]$Request.Body.snoozeDurationInDays -lt 0 -or [int]$Request.Body.snoozeDurationInDays -gt 14)) { + return [HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::BadRequest + Body = @{ Results = 'Error: snoozeDurationInDays must be between 0 and 14' } + } + } + + try { + # Build include/exclude target lists; $null means "keep what is currently configured" + $IncludeSpecified = ($null -ne $Request.Body.includeAllUsers) -or ($null -ne $Request.Body.includeGroups) -or ($null -ne $Request.Body.includeUsers) + $IncludeTargets = if ($IncludeSpecified) { + if ([bool]$Request.Body.includeAllUsers) { + @(@{ id = 'all_users'; targetType = 'group' }) + } else { + $Targets = [System.Collections.Generic.List[hashtable]]::new() + foreach ($GroupId in @($Request.Body.includeGroups)) { + if ($GroupId) { $Targets.Add(@{ id = "$GroupId"; targetType = 'group' }) } + } + foreach ($UserId in @($Request.Body.includeUsers)) { + if ($UserId) { $Targets.Add(@{ id = "$UserId"; targetType = 'user' }) } + } + @($Targets) + } + } else { $null } + + $ExcludeSpecified = ($null -ne $Request.Body.excludeGroups) -or ($null -ne $Request.Body.excludeUsers) + $ExcludeTargets = if ($ExcludeSpecified) { + $Targets = [System.Collections.Generic.List[hashtable]]::new() + foreach ($GroupId in @($Request.Body.excludeGroups)) { + if ($GroupId) { $Targets.Add(@{ id = "$GroupId"; targetType = 'group' }) } + } + foreach ($UserId in @($Request.Body.excludeUsers)) { + if ($UserId) { $Targets.Add(@{ id = "$UserId"; targetType = 'user' }) } + } + @($Targets) + } else { $null } + + $CampaignParams = @{ + Tenant = $TenantFilter + State = $Request.Body.state.value ?? $Request.Body.state + TargetedAuthenticationMethod = $Request.Body.targetedAuthenticationMethod.value ?? $Request.Body.targetedAuthenticationMethod + SnoozeDurationInDays = $Request.Body.snoozeDurationInDays + EnforceRegistrationAfterAllowedSnoozes = $Request.Body.enforceRegistrationAfterAllowedSnoozes + IncludeTargets = $IncludeTargets + ExcludeTargets = $ExcludeTargets + APIName = $APIName + Headers = $Headers + } + $Result = Set-CIPPRegistrationCampaign @CampaignParams + } catch { + $Result = $_.Exception.Message + $StatusCode = [HttpStatusCode]::InternalServerError + } + + return [HttpResponseContext]@{ + StatusCode = $StatusCode ?? [HttpStatusCode]::OK + Body = @{ Results = $Result } + } +} diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardNudgeMFA.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardNudgeMFA.ps1 index 17921a7878a31..afac49b65a297 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardNudgeMFA.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardNudgeMFA.ps1 @@ -7,8 +7,8 @@ function Invoke-CIPPStandardNudgeMFA { .SYNOPSIS (Label) Sets the state for the request to setup Authenticator .DESCRIPTION - (Helptext) Sets the state of the registration campaign for the tenant - (DocsDescription) Sets the state of the registration campaign for the tenant. If enabled nudges users to set up the Microsoft Authenticator during sign-in. + (Helptext) Sets the state of the registration campaign for the tenant, including the targeted authentication method, snooze settings and include/exclude groups. Leave include/exclude blank to keep the groups currently configured in the tenant, or use 'AllUsers' to target all users. + (DocsDescription) Sets the state of the registration campaign for the tenant. If enabled nudges users to set up the targeted authentication method (Microsoft Authenticator or a Passkey) during sign-in. Supports limiting the number of snoozes, and including or excluding specific groups (by display name). .NOTES CAT Entra (AAD) Standards @@ -17,8 +17,12 @@ function Invoke-CIPPStandardNudgeMFA { EXECUTIVETEXT Prompts employees to set up multi-factor authentication during login, gradually improving the organization's security posture by encouraging adoption of stronger authentication methods. This helps achieve better security compliance without forcing immediate mandatory changes. ADDEDCOMPONENT - {"type":"autoComplete","multiple":false,"creatable":false,"label":"Select value","name":"standards.NudgeMFA.state","options":[{"label":"Enabled","value":"enabled"},{"label":"Disabled","value":"disabled"}]} + {"type":"autoComplete","multiple":false,"creatable":false,"label":"Registration campaign state","name":"standards.NudgeMFA.state","options":[{"label":"Enabled","value":"enabled"},{"label":"Disabled","value":"disabled"}]} + {"type":"autoComplete","multiple":false,"creatable":false,"required":false,"label":"Authentication method to nudge users to register (default is Microsoft Authenticator)","name":"standards.NudgeMFA.targetedAuthenticationMethod","options":[{"label":"Microsoft Authenticator","value":"microsoftAuthenticator"},{"label":"Passkey (FIDO2)","value":"fido2"}],"condition":{"field":"standards.NudgeMFA.state","compareType":"valueEq","compareValue":"enabled"}} {"type":"number","name":"standards.NudgeMFA.snoozeDurationInDays","label":"Number of days to allow users to skip registering Authenticator (0-14, default is 1)","defaultValue":1,"validators":{"min":{"value":0,"message":"Minimum value is 0"},"max":{"value":14,"message":"Maximum value is 14"}}} + {"type":"switch","name":"standards.NudgeMFA.enforceRegistrationAfterAllowedSnoozes","label":"Limited number of snoozes (require registration after 3 snoozes)","defaultValue":true,"condition":{"field":"standards.NudgeMFA.state","compareType":"valueEq","compareValue":"enabled"}} + {"type":"textField","name":"standards.NudgeMFA.includeTargets","label":"Include groups (comma separated group names, 'AllUsers' for everyone, blank = keep current targets)","required":false,"condition":{"field":"standards.NudgeMFA.state","compareType":"valueEq","compareValue":"enabled"}} + {"type":"textField","name":"standards.NudgeMFA.excludeTargets","label":"Exclude groups (comma separated group names, blank = keep current exclusions)","required":false,"condition":{"field":"standards.NudgeMFA.state","compareType":"valueEq","compareValue":"enabled"}} IMPACT Low Impact ADDEDDATE @@ -34,45 +38,138 @@ function Invoke-CIPPStandardNudgeMFA { param($Tenant, $Settings) + # NOTE: The ADDEDCOMPONENT conditions above use compareType 'valueEq' rather than the usual 'is'. + # The state field is an autoComplete which stores a {label, value} object, so 'is' (deep equality + # against the raw string) never matches; 'valueEq' compares against the object's .value property + # and is supported by CippFormCondition. Changing state to a 'select' field would allow 'is', but + # would break existing saved NudgeMFA templates that already store the object shape. + + # Resolves comma separated group name entries to registration campaign targets + function Resolve-NudgeMFATarget { + param($Entries, $TenantFilter) + $Resolved = [System.Collections.Generic.List[hashtable]]::new() + $Failed = $false + foreach ($Entry in $Entries) { + try { + if ($Entry -match '^(all_users|allusers|all users)$') { + $Resolved.Add(@{ id = 'all_users'; targetType = 'group' }) + } else { + $EscapedName = $Entry -replace "'", "''" + $GroupFilter = [System.Uri]::EscapeDataString("startsWith(displayName,'$EscapedName')") + $MatchedGroups = @(New-GraphGetRequest -uri "https://graph.microsoft.com/beta/groups?`$select=id,displayName&`$filter=$GroupFilter" -tenantid $TenantFilter) + if ($MatchedGroups.Count -gt 0) { + foreach ($Group in $MatchedGroups) { $Resolved.Add(@{ id = $Group.id; targetType = 'group' }) } + if ($MatchedGroups.Count -gt 1) { + Write-LogMessage -API 'Standards' -tenant $TenantFilter -message "NudgeMFA: Multiple groups matched '$Entry': $($MatchedGroups.displayName -join ', ')" -sev Info + } + } else { + Write-LogMessage -API 'Standards' -tenant $TenantFilter -message "NudgeMFA: No group found matching '$Entry'" -sev Warning + $Failed = $true + } + } + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'Standards' -tenant $TenantFilter -message "NudgeMFA: Failed to resolve target '$Entry'. Error: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage + $Failed = $true + } + } + return [PSCustomObject]@{ Targets = $Resolved; Failed = $Failed } + } + # Get state value using null-coalescing operator $State = $Settings.state.value ?? $Settings.state try { $CurrentState = New-GraphGetRequest -Uri 'https://graph.microsoft.com/beta/policies/authenticationMethodsPolicy' -tenantid $Tenant - $StateIsCorrect = ($CurrentState.registrationEnforcement.authenticationMethodsRegistrationCampaign.state -eq $State) -and - ($CurrentState.registrationEnforcement.authenticationMethodsRegistrationCampaign.snoozeDurationInDays -eq $Settings.snoozeDurationInDays) -and - ($CurrentState.registrationEnforcement.authenticationMethodsRegistrationCampaign.enforceRegistrationAfterAllowedSnoozes -eq $true) + $CurrentCampaign = $CurrentState.registrationEnforcement.authenticationMethodsRegistrationCampaign } catch { Write-LogMessage -API 'Standards' -tenant $Tenant -message 'Failed to get Authenticator App Nudge state, check your permissions and try again' -sev Error -LogData (Get-CippException -Exception $_) return } + $SnoozeDuration = [int]($Settings.snoozeDurationInDays ?? 1) + $EnforceAfterSnoozes = if ($null -eq $Settings.enforceRegistrationAfterAllowedSnoozes) { $true } else { [bool]$Settings.enforceRegistrationAfterAllowedSnoozes } + # Fall back to the method already targeted in the tenant so existing templates keep their current campaign type + $TargetedMethod = $Settings.targetedAuthenticationMethod.value ?? $Settings.targetedAuthenticationMethod ?? (@($CurrentCampaign.includeTargets).targetedAuthenticationMethod | Select-Object -First 1) ?? 'microsoftAuthenticator' + + # NOTE: Unlike the AuthenticationMethods standard (where a blank group field means "All Users"), + # blank include/exclude here means "keep the targets currently configured in the tenant". This is + # deliberate: NudgeMFA predates these fields and existing deployments would otherwise have their + # portal-configured targeting overwritten to all_users on the next run. Use the literal 'AllUsers' + # entry to target everyone explicitly. + $IncludeEntries = @(([string]($Settings.includeTargets ?? '')) -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ }) + $ExcludeEntries = @(([string]($Settings.excludeTargets ?? '')) -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ }) + + # $Remediation*Targets are passed to Set-CIPPRegistrationCampaign ($null = keep current targets); + # $Desired*Targets are the fully resolved lists used for the compliance comparison below. + if ($IncludeEntries.Count -gt 0) { + $IncludeResolution = Resolve-NudgeMFATarget -Entries $IncludeEntries -TenantFilter $Tenant + if ($IncludeResolution.Failed -or $IncludeResolution.Targets.Count -eq 0) { + Write-LogMessage -API 'Standards' -tenant $Tenant -message 'NudgeMFA: Could not resolve all include groups, skipping to avoid removing intended targets.' -sev Error + return + } + $RemediationIncludeTargets = @($IncludeResolution.Targets) + $DesiredIncludeTargets = @($RemediationIncludeTargets | ForEach-Object { @{ id = $_.id; targetType = $_.targetType; targetedAuthenticationMethod = $TargetedMethod } }) + } else { + $RemediationIncludeTargets = $null + $DesiredIncludeTargets = @($CurrentCampaign.includeTargets | ForEach-Object { @{ id = $_.id; targetType = $_.targetType; targetedAuthenticationMethod = $TargetedMethod } }) + if ($DesiredIncludeTargets.Count -eq 0) { + $DesiredIncludeTargets = @(@{ id = 'all_users'; targetType = 'group'; targetedAuthenticationMethod = $TargetedMethod }) + } + } + + $ManageExcludeTargets = $ExcludeEntries.Count -gt 0 + if ($ManageExcludeTargets) { + $ExcludeResolution = Resolve-NudgeMFATarget -Entries $ExcludeEntries -TenantFilter $Tenant + if ($ExcludeResolution.Failed) { + Write-LogMessage -API 'Standards' -tenant $Tenant -message 'NudgeMFA: Could not resolve all exclude groups, skipping to avoid excluding the wrong groups.' -sev Error + return + } + $RemediationExcludeTargets = @($ExcludeResolution.Targets) + $DesiredExcludeTargets = @($RemediationExcludeTargets | ForEach-Object { @{ id = $_.id; targetType = $_.targetType } }) + } else { + $RemediationExcludeTargets = $null + $DesiredExcludeTargets = @($CurrentCampaign.excludeTargets | ForEach-Object { @{ id = $_.id; targetType = $_.targetType } }) + } + + $CurrentIncludeIds = @($CurrentCampaign.includeTargets.id) + $DesiredIncludeIds = @($DesiredIncludeTargets.id) + $IncludeIsCorrect = ($CurrentIncludeIds.Count -eq $DesiredIncludeIds.Count) -and + (-not (Compare-Object -ReferenceObject @($DesiredIncludeIds | Sort-Object) -DifferenceObject @($CurrentIncludeIds | Sort-Object) -ErrorAction SilentlyContinue)) + $MethodIsCorrect = @($CurrentCampaign.includeTargets | Where-Object { $_.targetedAuthenticationMethod -ne $TargetedMethod }).Count -eq 0 + + if ($ManageExcludeTargets) { + $CurrentExcludeIds = @($CurrentCampaign.excludeTargets.id) + $DesiredExcludeIds = @($DesiredExcludeTargets.id) + $ExcludeIsCorrect = ($CurrentExcludeIds.Count -eq $DesiredExcludeIds.Count) -and + (-not (Compare-Object -ReferenceObject @($DesiredExcludeIds | Sort-Object) -DifferenceObject @($CurrentExcludeIds | Sort-Object) -ErrorAction SilentlyContinue)) + } else { + $ExcludeIsCorrect = $true + } + + $StateIsCorrect = ($CurrentCampaign.state -eq $State) -and + ([int]$CurrentCampaign.snoozeDurationInDays -eq $SnoozeDuration) -and + ([bool]$CurrentCampaign.enforceRegistrationAfterAllowedSnoozes -eq $EnforceAfterSnoozes) -and + $IncludeIsCorrect -and $MethodIsCorrect -and $ExcludeIsCorrect + if ($Settings.remediate -eq $true) { $StateName = $State.Substring(0, 1).ToUpper() + $State.Substring(1) if ($StateIsCorrect -eq $true) { - Write-LogMessage -API 'Standards' -tenant $Tenant -message "Authenticator App Nudge is already set to $State with a snooze duration of $($Settings.snoozeDurationInDays)." -sev Info + Write-LogMessage -API 'Standards' -tenant $Tenant -message "Authenticator App Nudge is already set to $State targeting $TargetedMethod with a snooze duration of $SnoozeDuration." -sev Info } else { try { - $GraphRequest = @{ - tenantid = $Tenant - uri = 'https://graph.microsoft.com/beta/policies/authenticationMethodsPolicy' - AsApp = $false - Type = 'PATCH' - ContentType = 'application/json' - Body = @{ - registrationEnforcement = @{ - authenticationMethodsRegistrationCampaign = @{ - state = $State - snoozeDurationInDays = $Settings.snoozeDurationInDays - enforceRegistrationAfterAllowedSnoozes = $true - includeTargets = $CurrentState.registrationEnforcement.authenticationMethodsRegistrationCampaign.includeTargets - excludeTargets = $CurrentState.registrationEnforcement.authenticationMethodsRegistrationCampaign.excludeTargets - } - } - } | ConvertTo-Json -Depth 10 -Compress + $CampaignParams = @{ + Tenant = $Tenant + State = $State + TargetedAuthenticationMethod = $TargetedMethod + SnoozeDurationInDays = $SnoozeDuration + EnforceRegistrationAfterAllowedSnoozes = $EnforceAfterSnoozes + IncludeTargets = $RemediationIncludeTargets + ExcludeTargets = $RemediationExcludeTargets + APIName = 'Standards' } - New-GraphPostRequest @GraphRequest - Write-LogMessage -API 'Standards' -tenant $Tenant -message "$StateName Authenticator App Nudge with a snooze duration of $($Settings.snoozeDurationInDays)" -sev Info + $null = Set-CIPPRegistrationCampaign @CampaignParams + Write-LogMessage -API 'Standards' -tenant $Tenant -message "$StateName Authenticator App Nudge targeting $TargetedMethod with a snooze duration of $SnoozeDuration" -sev Info } catch { $ErrorMessage = Get-CippException -Exception $_ Write-LogMessage -API 'Standards' -tenant $Tenant -message "Failed to set Authenticator App Nudge to $State. Error: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage @@ -82,21 +179,29 @@ function Invoke-CIPPStandardNudgeMFA { if ($Settings.alert -eq $true) { if ($StateIsCorrect -eq $true) { - Write-LogMessage -API 'Standards' -tenant $Tenant -message "Authenticator App Nudge is enabled with a snooze duration of $($CurrentState.registrationEnforcement.authenticationMethodsRegistrationCampaign.snoozeDurationInDays)" -sev Info + Write-LogMessage -API 'Standards' -tenant $Tenant -message "Authenticator App Nudge is configured correctly: $($CurrentCampaign.state) targeting $TargetedMethod with a snooze duration of $($CurrentCampaign.snoozeDurationInDays)" -sev Info } else { - Write-StandardsAlert -message "Authenticator App Nudge is not enabled with a snooze duration of $($CurrentState.registrationEnforcement.authenticationMethodsRegistrationCampaign.snoozeDurationInDays)" -object ($CurrentState.registrationEnforcement.authenticationMethodsRegistrationCampaign | Select-Object snoozeDurationInDays, state) -tenant $Tenant -standardName 'NudgeMFA' -standardId $Settings.standardId - Write-LogMessage -API 'Standards' -tenant $Tenant -message "Authenticator App Nudge is not enabled with a snooze duration of $($CurrentState.registrationEnforcement.authenticationMethodsRegistrationCampaign.snoozeDurationInDays)" -sev Info + Write-StandardsAlert -message "Authenticator App Nudge is not configured as expected: state $($CurrentCampaign.state), snooze duration $($CurrentCampaign.snoozeDurationInDays)" -object ($CurrentCampaign | Select-Object state, snoozeDurationInDays, enforceRegistrationAfterAllowedSnoozes, includeTargets, excludeTargets) -tenant $Tenant -standardName 'NudgeMFA' -standardId $Settings.standardId + Write-LogMessage -API 'Standards' -tenant $Tenant -message "Authenticator App Nudge is not configured as expected: state $($CurrentCampaign.state), snooze duration $($CurrentCampaign.snoozeDurationInDays)" -sev Info } } if ($Settings.report -eq $true) { $CurrentValue = @{ - snoozeDurationInDays = $CurrentState.registrationEnforcement.authenticationMethodsRegistrationCampaign.snoozeDurationInDays - state = $CurrentState.registrationEnforcement.authenticationMethodsRegistrationCampaign.state + state = $CurrentCampaign.state + snoozeDurationInDays = $CurrentCampaign.snoozeDurationInDays + enforceRegistrationAfterAllowedSnoozes = [bool]$CurrentCampaign.enforceRegistrationAfterAllowedSnoozes + targetedAuthenticationMethod = (@($CurrentCampaign.includeTargets).targetedAuthenticationMethod | Select-Object -First 1) + includeTargets = @($CurrentCampaign.includeTargets | ForEach-Object { @{ id = $_.id; targetType = $_.targetType } }) + excludeTargets = @($CurrentCampaign.excludeTargets | ForEach-Object { @{ id = $_.id; targetType = $_.targetType } }) } $ExpectedValue = @{ - snoozeDurationInDays = $Settings.snoozeDurationInDays - state = $State + state = $State + snoozeDurationInDays = $SnoozeDuration + enforceRegistrationAfterAllowedSnoozes = $EnforceAfterSnoozes + targetedAuthenticationMethod = $TargetedMethod + includeTargets = @($DesiredIncludeTargets | ForEach-Object { @{ id = $_.id; targetType = $_.targetType } }) + excludeTargets = $DesiredExcludeTargets } Set-CIPPStandardsCompareField -FieldName 'standards.NudgeMFA' -CurrentValue $CurrentValue -ExpectedValue $ExpectedValue -TenantFilter $Tenant Add-CIPPBPAField -FieldName 'NudgeMFA' -FieldValue $StateIsCorrect -StoreAs bool -Tenant $Tenant From 24db2c55c366f55a2727450497cfda5fdcd44017 Mon Sep 17 00:00:00 2001 From: Logan Cook <2997336+MWG-Logan@users.noreply.github.com> Date: Tue, 14 Jul 2026 13:46:58 -0400 Subject: [PATCH 03/16] feat(ninjaone): auto-create CVE vulnerability scan group when missing Resolves KelvinTegelaar/CIPP#6349, "[Feature Request]: Auto-create NinjaOne vulnerability scan group during CVE sync". Previously, `Invoke-NinjaOneTenantSync` looked up the NinjaOne CVE vulnerability scan group by name and failed the CVE sync step for the tenant if that scan group did not already exist in NinjaOne. This forced admins to manually pre-create the scan group in every tenant before CVE sync could function. - Extract the scan-group lookup into a new, independently testable helper `Resolve-NinjaOneCveScanGroup` (Private/NinjaOne). It now: - Looks up the scan group by configured name. - Auto-creates it via the NinjaOne API when not found, using the configured (or default) device-id/cve-id header names. - Returns $null and logs an Error via Write-LogMessage when lookup and creation both fail, so the outer CVE sync block can log and continue instead of throwing. - Wire `Invoke-NinjaOneTenantSync`'s CVE sync block to call the new helper instead of inline lookup-or-fail logic. - Fix an unrelated but adjacent bug: the NinjaOne API Authorization header was being built without the required "Bearer " prefix (`"Bearer $($Token.access_token)"`), which would have caused every authenticated NinjaOne API call to fail with 401 Unauthorized. - Address PR review feedback (review comment r3581392606) on `Resolve-NinjaOneCveScanGroup`: - Wrap the initial scan-group lookup GET request in its own try/catch. Previously an uncaught exception here (e.g. 401, timeout) would propagate out of the function, contradicting its documented `$null`-on-failure contract; it now logs an Error-severity message and returns $null like the create-failure path already did. - Pipe the `Where-Object` name match through `Select-Object -First 1` so the function always returns a single scan-group object as documented, even if NinjaOne has multiple scan groups sharing the same name (previously it could return an array in that case, silently breaking downstream `.id`/`.deviceIdHeader`/`.cveIdHeader` access and upload-URI construction in the caller). Tests: - Add `Tests/NinjaOne/Resolve-NinjaOneCveScanGroup.Tests.ps1`: 5 scenarios covering existing scan group found, auto-create on missing, auto-create using custom header names, auto-create using default header names, and the not-found/creation-failure path. Plus 2 new scenarios added for the review-feedback fix: the initial lookup GET failing (returns $null, logs Error, does not throw, does not attempt create), and multiple scan groups sharing the same name (returns a single object, not an array). - Add `Tests/NinjaOne/Invoke-NinjaOneTenantSync.Tests.ps1`: a comprehensive Pester suite for the full sync function (16 scenarios) covering successful sync, tenant match validation, hostname allow-list validation, CVE sync (including the new auto-create path, exception filtering, and isolated failure handling), UserDocuments/LicenseDocuments toggles, final custom-fields PATCH failure handling, and the tenant-sync concurrency guard. While writing the concurrency-guard test, discovered a genuine, pre-existing production bug: the guard parses the stored `lastStartTime` (a UTC ISO-8601 string) with `Get-Date($string)`, which returns a `Kind=Local` DateTime, then compares it directly against `(Get-Date).ToUniversalTime()` (`Kind=Utc`). .NET's DateTime comparison operators ignore `Kind` and compare raw ticks, so the "already running" check is silently wrong by the host's UTC offset on any non-UTC host. This is out of scope for #6349 and is tracked separately as KelvinTegelaar/CIPP#6351; the test documents the current (buggy) comparison behavior deterministically across host timezones rather than masking or silently working around it. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../NinjaOne/Resolve-NinjaOneCveScanGroup.ps1 | 63 +++ .../NinjaOne/Invoke-NinjaOneTenantSync.ps1 | 7 +- .../Invoke-NinjaOneTenantSync.Tests.ps1 | 480 ++++++++++++++++++ .../Resolve-NinjaOneCveScanGroup.Tests.ps1 | 140 +++++ 4 files changed, 685 insertions(+), 5 deletions(-) create mode 100644 Modules/CippExtensions/Private/NinjaOne/Resolve-NinjaOneCveScanGroup.ps1 create mode 100644 Tests/NinjaOne/Invoke-NinjaOneTenantSync.Tests.ps1 create mode 100644 Tests/NinjaOne/Resolve-NinjaOneCveScanGroup.Tests.ps1 diff --git a/Modules/CippExtensions/Private/NinjaOne/Resolve-NinjaOneCveScanGroup.ps1 b/Modules/CippExtensions/Private/NinjaOne/Resolve-NinjaOneCveScanGroup.ps1 new file mode 100644 index 0000000000000..9578039306621 --- /dev/null +++ b/Modules/CippExtensions/Private/NinjaOne/Resolve-NinjaOneCveScanGroup.ps1 @@ -0,0 +1,63 @@ +function Resolve-NinjaOneCveScanGroup { + <# + .SYNOPSIS + Resolves the NinjaOne vulnerability scan group used for CVE sync, creating it if it does not exist. + .DESCRIPTION + Looks up the CVE sync scan group by name via the NinjaOne API. If the lookup fails, or if no matching + scan group is found and the subsequent create attempt also fails, logs the error and returns $null so + the caller can skip CVE sync for this tenant without failing the overall tenant sync. + .PARAMETER Configuration + The NinjaOne configuration object. Must expose CveSyncDeviceIdHeader / CveSyncCveIdHeader (both optional). + .PARAMETER TenantFilter + The tenant identifier used for logging context. + .PARAMETER ScanGroupName + The resolved scan group name to look up or create. + .PARAMETER NinjaBaseUrl + The base NinjaOne API URL (e.g. https://instance/api/v2). + .PARAMETER Token + The NinjaOne OAuth token object. Must expose access_token. + .OUTPUTS + The resolved (existing or newly-created) scan group object, or $null if it could not be resolved. + #> + [CmdletBinding()] + param ( + $Configuration, + [string]$TenantFilter, + [string]$ScanGroupName, + [string]$NinjaBaseUrl, + $Token + ) + + try { + $CveScanGroups = Invoke-RestMethod -Method Get -Uri "$NinjaBaseUrl/vulnerability/scan-groups" -Headers @{ Authorization = "Bearer $($Token.access_token)" } -TimeoutSec 30 -ErrorAction Stop + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'NinjaOneSync' -tenant $TenantFilter -message "CVE sync skipped — could not look up scan group '$ScanGroupName': $($ErrorMessage.NormalizedError)" -sev 'Error' -LogData $ErrorMessage + return $null + } + + # Where-Object returns an array when multiple scan groups share the same name; take the + # first match so callers always receive a single object as documented, not a collection. + $ResolvedScanGroup = $CveScanGroups | Where-Object { $_.groupName -eq $ScanGroupName } | Select-Object -First 1 + + if (-not $ResolvedScanGroup) { + Write-LogMessage -API 'NinjaOneSync' -tenant $TenantFilter -message "CVE sync — scan group '$ScanGroupName' not found, attempting to create it" -sev 'Info' + + try { + $NewScanGroupBody = @{ + groupName = $ScanGroupName + deviceIdHeader = $Configuration.CveSyncDeviceIdHeader ?? 'deviceName' + cveIdHeader = $Configuration.CveSyncCveIdHeader ?? 'cveId' + } | ConvertTo-Json -Depth 5 + + $ResolvedScanGroup = Invoke-RestMethod -Method Post -Uri "$NinjaBaseUrl/vulnerability/scan-groups" -Headers @{ Authorization = "Bearer $($Token.access_token)"; 'Content-Type' = 'application/json' } -Body $NewScanGroupBody -TimeoutSec 30 -ErrorAction Stop + Write-LogMessage -API 'NinjaOneSync' -tenant $TenantFilter -message "CVE sync — created scan group '$ScanGroupName'" -sev 'Info' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'NinjaOneSync' -tenant $TenantFilter -message "CVE sync skipped — scan group '$ScanGroupName' not found and could not be created: $($ErrorMessage.NormalizedError)" -sev 'Error' -LogData $ErrorMessage + $ResolvedScanGroup = $null + } + } + + return $ResolvedScanGroup +} diff --git a/Modules/CippExtensions/Public/NinjaOne/Invoke-NinjaOneTenantSync.ps1 b/Modules/CippExtensions/Public/NinjaOne/Invoke-NinjaOneTenantSync.ps1 index e415d4cf580c1..d3a99fcc51fa7 100644 --- a/Modules/CippExtensions/Public/NinjaOne/Invoke-NinjaOneTenantSync.ps1 +++ b/Modules/CippExtensions/Public/NinjaOne/Invoke-NinjaOneTenantSync.ps1 @@ -2198,12 +2198,9 @@ function Invoke-NinjaOneTenantSync { $ScanGroupName = "$ScanGroupPrefix$TenantFilter" $NinjaBaseUrl = "https://$($Configuration.Instance)/api/v2" - $CveScanGroups = Invoke-RestMethod -Method Get -Uri "$NinjaBaseUrl/vulnerability/scan-groups" -Headers @{ Authorization = "Bearer $($Token.access_token)" } -TimeoutSec 30 -ErrorAction Stop - $ResolvedScanGroup = $CveScanGroups | Where-Object { $_.groupName -eq $ScanGroupName } + $ResolvedScanGroup = Resolve-NinjaOneCveScanGroup -Configuration $Configuration -TenantFilter $TenantFilter -ScanGroupName $ScanGroupName -NinjaBaseUrl $NinjaBaseUrl -Token $Token - if (-not $ResolvedScanGroup) { - Write-LogMessage -API 'NinjaOneSync' -tenant $TenantFilter -message "CVE sync skipped — scan group '$ScanGroupName' not found" -sev 'Warning' - } else { + if ($ResolvedScanGroup) { $ResolvedScanGroupId = $ResolvedScanGroup.id $DeviceIdHeader = $ResolvedScanGroup.deviceIdHeader $CveIdHeader = $ResolvedScanGroup.cveIdHeader diff --git a/Tests/NinjaOne/Invoke-NinjaOneTenantSync.Tests.ps1 b/Tests/NinjaOne/Invoke-NinjaOneTenantSync.Tests.ps1 new file mode 100644 index 0000000000000..b2197f467cdd9 --- /dev/null +++ b/Tests/NinjaOne/Invoke-NinjaOneTenantSync.Tests.ps1 @@ -0,0 +1,480 @@ +# Pester tests for Invoke-NinjaOneTenantSync +# +# Scope (per "solid coverage" agreement): happy-path end-to-end plus key +# error/edge branches per major section. Deep internals of the optional +# UserDocuments/LicenseDocuments related-items linking flows are exercised +# only at the "does it run without throwing / batch call happens" level — +# not every one of the 17 Invoke-WebRequest call sites is asserted +# individually, since that would require disproportionate fixture effort +# relative to this file's actual bug surface (CVE scan-group auto-create, +# GH issue #6349). +# +# Strategy: Get-CIPPTable is stubbed (not Mocked) to tag its returned +# Context with the requested table name, so downstream +# Get-CIPPAzDataTableEntity / Add-CIPPAzDataTableEntity mocks can +# distinguish table + filter combinations via -ParameterFilter. + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $FunctionPath = Join-Path $RepoRoot 'Modules/CippExtensions/Public/NinjaOne/Invoke-NinjaOneTenantSync.ps1' + + # Minimal stubs so Mock has commands to replace during tests. + function Get-CIPPTable { param($tablename) @{ Context = $tablename } } + function Get-CippTable { param($tablename) @{ Context = $tablename } } + function Get-CIPPAzDataTableEntity { param($Context, $Filter) } + function Add-CIPPAzDataTableEntity { param($Context, $Entity, [switch]$Force) } + function Remove-AzDataTableEntity { param($Context, $Entity, [switch]$Force) } + function Get-Tenants { param([switch]$IncludeErrors) } + function Write-LogMessage { param($tenant, $API, $message, $Sev, $LogData) } + function Get-NinjaOneToken { param($configuration) } + function Invoke-WebRequest { param($Uri, $Method, $Headers, $ContentType, $Body) } + function Invoke-RestMethod { param($Uri, $Method, $Headers, $ContentType, $Body) } + function Get-CippExtensionReportingData { param($TenantFilter, [switch]$IncludeMailboxes) } + function Get-NinjaOneLinks { param($Data, $Title, $SmallCols, $MedCols, $LargeCols, $XLCols) } + function Get-NinjaOneInfoCard { param($Title, $Data, $Icon) } + function Get-NinjaOneCard { param($Title, $Body, $Icon, $TitleLink) } + function Get-NinjaOneWidgetCard { param($Title, $Data, $Icon) } + function Get-NinjaInLineBarGraph { param($Title, $Data, [switch]$KeyInLine) } + function Get-SharePointAdminLink { param($TenantFilter) } + function Get-CIPPStandards { param($TenantFilter) } + function Get-CippException { param($Exception) [pscustomobject]@{ NormalizedError = $Exception.Exception.Message } } + function Get-NormalizedError { param($Message) $Message } + function New-CIPPGraphSubscription { param($TenantFilter, $Type) } + function New-VulnCsvBytes { param($Rows, $Headers) } + function Invoke-NinjaOneDocumentTemplate { param($TenantFilter, $TemplateName) } + function Invoke-NinjaOneVulnCsvUpload { param($Uri, $PollUri, $CsvBytes, $Headers) } + function Get-CIPPDbItem { param($TenantFilter, $Type) } + function Resolve-NinjaOneCveScanGroup { param($Configuration, $TenantFilter, $ScanGroupName, $NinjaBaseUrl, $Token) } + function convert-skuname { param($skuname) $skuname } + + . $FunctionPath + + function New-DefaultConfiguration { + [pscustomobject]@{ + Instance = 'app.ninjarmm.com' + UserDocumentsEnabled = $false + LicenseDocumentsEnabled = $false + LicensedOnly = $false + CveSyncEnabled = $true + CveSyncPrefix = 'CIPP-' + CveSyncDeviceIdHeader = 'deviceName' + CveSyncCveIdHeader = 'cveId' + } + } + + function New-DefaultQueueItem { + [pscustomobject]@{ + MappedTenant = [pscustomobject]@{ + RowKey = 'contoso-tenant-id' + IntegrationId = '918273' + } + } + } +} + +Describe 'Invoke-NinjaOneTenantSync' { + + BeforeEach { + # --- Common baseline mocks used by (almost) every test --- + Mock -CommandName Write-LogMessage -MockWith { } + Mock -CommandName Get-NinjaOneToken -MockWith { [pscustomobject]@{ access_token = 'fake-token' } } + + Mock -CommandName Get-Tenants -MockWith { + @([pscustomobject]@{ + customerId = 'contoso-tenant-id' + defaultDomainName = 'contoso.onmicrosoft.com' + displayName = 'Contoso Ltd' + initialDomainName = 'contoso.onmicrosoft.com' + }) + } + + # CippMapping table: two different partitions are read via the same + # table/context — the sync-lock lookup (NinjaOneMapping) and the + # field-mapping lookup (NinjaOneFieldMapping). Default: no active + # lock, no field mappings (keeps the HTML/summary-card machinery + # entirely skipped for the core happy path). + Mock -CommandName Get-CIPPAzDataTableEntity -ParameterFilter { + $Context -eq 'CippMapping' -and $Filter -eq "PartitionKey eq 'NinjaOneMapping'" + } -MockWith { + @([pscustomobject]@{ + RowKey = 'contoso-tenant-id' + lastStartTime = $null + lastEndTime = $null + }) + } + + Mock -CommandName Get-CIPPAzDataTableEntity -ParameterFilter { + $Context -eq 'CippMapping' -and $Filter -eq "PartitionKey eq 'NinjaOneFieldMapping'" + } -MockWith { @() } + + Mock -CommandName Get-CIPPAzDataTableEntity -ParameterFilter { $Context -eq 'Config' } -MockWith { @() } + + Mock -CommandName Get-CIPPAzDataTableEntity -ParameterFilter { $Context -eq 'Extensionsconfig' } -MockWith { + [pscustomobject]@{ config = (@{ NinjaOne = (New-DefaultConfiguration) } | ConvertTo-Json -Depth 10) } + } + + Mock -CommandName Get-CIPPAzDataTableEntity -ParameterFilter { $Context -eq 'CacheNinjaOneParsedDevices' } -MockWith { @() } + Mock -CommandName Get-CIPPAzDataTableEntity -ParameterFilter { $Context -eq 'NinjaOneDeviceMap' } -MockWith { @() } + Mock -CommandName Get-CIPPAzDataTableEntity -ParameterFilter { $Context -eq 'CacheNinjaOneParsedUsers' } -MockWith { @() } + Mock -CommandName Get-CIPPAzDataTableEntity -ParameterFilter { $Context -eq 'CacheNinjaOneUsersUpdate' } -MockWith { @() } + Mock -CommandName Get-CIPPAzDataTableEntity -ParameterFilter { $Context -eq 'NinjaOneUserMap' } -MockWith { @() } + Mock -CommandName Get-CIPPAzDataTableEntity -ParameterFilter { $Context -eq 'CveExceptions' } -MockWith { @() } + + # $CurrentItem is a single mutable object that the function under test + # mutates in place (via Add-Member -Force) and re-passes to + # Add-CIPPAzDataTableEntity multiple times (Running, then + # Completed/Failed). Pester's Should -Invoke -ParameterFilter + # re-evaluates filters against the *current* state of each recorded + # call's arguments, not a snapshot taken at call time - so once the + # shared object is mutated to 'Completed', every historical call + # appears to match. Capture an immutable clone of $Entity on every + # call so assertions can check actual per-call state. + $script:RecordedEntities = [System.Collections.Generic.List[object]]::new() + Mock -CommandName Add-CIPPAzDataTableEntity -MockWith { + if ($null -ne $Entity) { + $script:RecordedEntities.Add(($Entity | ConvertTo-Json -Depth 10 | ConvertFrom-Json)) + } + } + Mock -CommandName Remove-AzDataTableEntity -MockWith { } + + # Empty extension-cache data by default — this keeps the device/user + # processing loops, tenant-summary cards, and doc-toggle blocks from + # ever executing their bodies (empty collections => no iterations). + Mock -CommandName Get-CippExtensionReportingData -MockWith { + [pscustomobject]@{ + Users = @() + AllRoles = @() + Devices = @() + DeviceCompliancePolicies = @() + OneDriveUsage = @() + CASMailbox = @() + Mailboxes = @() + MailboxUsage = @() + MailboxPermissions = @() + SecureScore = @() + SecureScoreControlProfiles = @() + Organization = [pscustomobject]@{ createdDateTime = '2020-01-01T00:00:00Z' } + Domains = @() + Groups = @() + Licenses = @() + ConditionalAccess = @() + } + } + + # Device fetch — one page, below PageSize, so the pagination loop + # terminates after a single call. + Mock -CommandName Invoke-WebRequest -ParameterFilter { $Uri -like '*devices-detailed*' } -MockWith { + [pscustomobject]@{ content = (@() | ConvertTo-Json -Depth 10) } + } + + # Final org-level custom-fields PATCH — succeeds by default. + Mock -CommandName Invoke-WebRequest -ParameterFilter { $Uri -like '*/organization/*/custom-fields' } -MockWith { + [pscustomobject]@{ content = (@{ success = $true } | ConvertTo-Json) } + } + + # CVE sync — scan group resolves, no vulnerability data by default. + Mock -CommandName Resolve-NinjaOneCveScanGroup -MockWith { + [pscustomobject]@{ id = 'scan-group-1'; deviceIdHeader = 'deviceName'; cveIdHeader = 'cveId' } + } + Mock -CommandName Get-CIPPDbItem -MockWith { @() } + Mock -CommandName New-VulnCsvBytes -MockWith { @() } + } + + Context 'Happy path (docs disabled, CVE sync enabled)' { + It 'completes successfully and records lastStatus Completed' { + $Result = Invoke-NinjaOneTenantSync -QueueItem (New-DefaultQueueItem) + + $Result | Should -Be $true + @($script:RecordedEntities | Where-Object { $_.lastStatus -eq 'Completed' }).Count | Should -Be 1 + @($script:RecordedEntities | Where-Object { $_.lastStatus -eq 'Running' }).Count | Should -Be 1 + } + + It 'fetches devices and posts the final org custom-fields update' { + Invoke-NinjaOneTenantSync -QueueItem (New-DefaultQueueItem) | Out-Null + + Should -Invoke Invoke-WebRequest -ParameterFilter { $Uri -like '*devices-detailed*' } -Times 1 -Exactly + Should -Invoke Invoke-WebRequest -ParameterFilter { $Method -eq 'PATCH' -and $Uri -like '*/organization/*/custom-fields' } -Times 1 -Exactly + } + + It 'does not call UserDocuments or LicenseDocuments endpoints when both toggles are disabled' { + Invoke-NinjaOneTenantSync -QueueItem (New-DefaultQueueItem) | Out-Null + + Should -Invoke Invoke-WebRequest -ParameterFilter { $Uri -like '*organization/documents*' } -Times 0 -Exactly + } + } + + Context 'Concurrency guard' { + It 'throws and does not process when a sync is already running for the tenant' { + # NOTE: the source parses `lastStartTime` with `Get-Date($string)`, + # which returns a Kind=Local DateTime, then compares it directly + # against `(Get-Date).ToUniversalTime()` (Kind=Utc). .NET's + # comparison operators ignore Kind and compare raw ticks, so this + # comparison is off by the host's UTC offset (tracked upstream as + # KelvinTegelaar/CIPP#6351 - not fixed here, out of scope for #6349). + # To keep this test deterministic across hosts in any timezone, + # build the mock timestamp the same way the guard will actually + # read it back: pick a UTC instant that, once shifted by the + # local offset (as the buggy parse does), reads as "now" - i.e. + # comfortably within the "still running" window on this host. + $LocalOffset = [System.TimeZoneInfo]::Local.GetUtcOffset((Get-Date)) + $MockStartTime = ((Get-Date).ToUniversalTime() - $LocalOffset).ToString('yyyy-MM-ddTHH:mm:ss.fffZ') + + Mock -CommandName Get-CIPPAzDataTableEntity -ParameterFilter { + $Context -eq 'CippMapping' -and $Filter -eq "PartitionKey eq 'NinjaOneMapping'" + } -MockWith { + @([pscustomobject]@{ + RowKey = 'contoso-tenant-id' + lastStartTime = $MockStartTime + lastEndTime = $null + }) + } + + $Result = Invoke-NinjaOneTenantSync -QueueItem (New-DefaultQueueItem) + + # The function's try/catch always returns $true — failure is + # observable only via the logged 'Failed' status and the fact + # that no device fetch happened. + $Result | Should -Be $true + Should -Invoke Invoke-WebRequest -ParameterFilter { $Uri -like '*devices-detailed*' } -Times 0 -Exactly + Should -Invoke Write-LogMessage -ParameterFilter { $Sev -eq 'Error' -and $message -like '*still running*' } -Times 1 -Exactly + } + } + + Context 'Tenant match validation' { + It 'fails when the tenant cannot be uniquely matched' { + Mock -CommandName Get-Tenants -MockWith { @() } + + $Result = Invoke-NinjaOneTenantSync -QueueItem (New-DefaultQueueItem) + + $Result | Should -Be $true + Should -Invoke Invoke-WebRequest -ParameterFilter { $Uri -like '*devices-detailed*' } -Times 0 -Exactly + Should -Invoke Write-LogMessage -ParameterFilter { $Sev -eq 'Error' -and $message -like '*Failed NinjaOne Processing*' } -Times 1 -Exactly + } + } + + Context 'Hostname validation' { + It 'fails when the configured NinjaOne instance is not an allow-listed hostname' { + Mock -CommandName Get-CIPPAzDataTableEntity -ParameterFilter { $Context -eq 'Extensionsconfig' } -MockWith { + $BadConfig = New-DefaultConfiguration + $BadConfig.Instance = 'evil.example.com' + [pscustomobject]@{ config = (@{ NinjaOne = $BadConfig } | ConvertTo-Json -Depth 10) } + } + + $Result = Invoke-NinjaOneTenantSync -QueueItem (New-DefaultQueueItem) + + $Result | Should -Be $true + Should -Invoke Invoke-WebRequest -ParameterFilter { $Uri -like '*devices-detailed*' } -Times 0 -Exactly + Should -Invoke Write-LogMessage -ParameterFilter { $Sev -eq 'Error' -and $message -like '*NinjaOne URL is invalid*' } -Times 1 -Exactly + } + } + + Context 'CVE sync' { + It 'uploads CVE rows built from vulnerability data when vulnerabilities exist' { + Mock -CommandName Get-CIPPDbItem -MockWith { + @([pscustomobject]@{ + RowKey = 'CVE-2024-0001' + Data = (@{ cveId = 'CVE-2024-0001'; deviceDetailsJson = (@(@{ deviceName = 'PC01' }) | ConvertTo-Json) } | ConvertTo-Json) + }) + } + Mock -CommandName New-VulnCsvBytes -MockWith { [byte[]](1, 2, 3) } + Mock -CommandName Invoke-NinjaOneVulnCsvUpload -MockWith { + [pscustomobject]@{ status = 'COMPLETE'; recordsProcessed = 1 } + } + + Invoke-NinjaOneTenantSync -QueueItem (New-DefaultQueueItem) | Out-Null + + Should -Invoke Resolve-NinjaOneCveScanGroup -Times 1 -Exactly + Should -Invoke Invoke-NinjaOneVulnCsvUpload -Times 1 -Exactly + Should -Invoke Write-LogMessage -ParameterFilter { $Sev -eq 'Info' -and $message -like '*CVE sync complete*' } -Times 1 -Exactly + } + + It 'logs a warning and uploads a placeholder row when no vulnerability data is returned' { + Mock -CommandName Get-CIPPDbItem -MockWith { @() } + Mock -CommandName New-VulnCsvBytes -MockWith { [byte[]](1) } + Mock -CommandName Invoke-NinjaOneVulnCsvUpload -MockWith { [pscustomobject]@{ status = 'COMPLETE'; recordsProcessed = 0 } } + + Invoke-NinjaOneTenantSync -QueueItem (New-DefaultQueueItem) | Out-Null + + Should -Invoke Write-LogMessage -ParameterFilter { $Sev -eq 'Warning' -and $message -like '*no vulnerability data returned*' } -Times 1 -Exactly + } + + It 'excludes CVEs covered by a tenant or ALL exception before uploading' { + Mock -CommandName Get-CIPPAzDataTableEntity -ParameterFilter { $Context -eq 'CveExceptions' } -MockWith { + @([pscustomobject]@{ RowKey = 'ALL'; cveId = 'CVE-2024-0001' }) + } + Mock -CommandName Get-CIPPDbItem -MockWith { + @([pscustomobject]@{ + RowKey = 'CVE-2024-0001' + Data = (@{ cveId = 'CVE-2024-0001'; deviceDetailsJson = (@(@{ deviceName = 'PC01' }) | ConvertTo-Json) } | ConvertTo-Json) + }) + } + Mock -CommandName New-VulnCsvBytes -MockWith { [byte[]](1) } + Mock -CommandName Invoke-NinjaOneVulnCsvUpload -MockWith { [pscustomobject]@{ status = 'COMPLETE'; recordsProcessed = 0 } } + + Invoke-NinjaOneTenantSync -QueueItem (New-DefaultQueueItem) | Out-Null + + Should -Invoke Write-LogMessage -ParameterFilter { $message -like '*filtered 1 excepted CVEs, 0 remaining*' } -Times 1 -Exactly + } + + It 'isolates a CVE sync failure so the overall tenant sync still completes' { + Mock -CommandName Resolve-NinjaOneCveScanGroup -MockWith { throw 'NinjaOne API unavailable' } + + $Result = Invoke-NinjaOneTenantSync -QueueItem (New-DefaultQueueItem) + + $Result | Should -Be $true + Should -Invoke Write-LogMessage -ParameterFilter { $Sev -eq 'Error' -and $message -like '*CVE sync failed*' } -Times 1 -Exactly + @($script:RecordedEntities | Where-Object { $_.lastStatus -eq 'Completed' }).Count | Should -Be 1 + } + + It 'does not attempt CVE sync when CveSyncEnabled is disabled' { + Mock -CommandName Get-CIPPAzDataTableEntity -ParameterFilter { $Context -eq 'Extensionsconfig' } -MockWith { + $NoConfig = New-DefaultConfiguration + $NoConfig.CveSyncEnabled = $false + [pscustomobject]@{ config = (@{ NinjaOne = $NoConfig } | ConvertTo-Json -Depth 10) } + } + + Invoke-NinjaOneTenantSync -QueueItem (New-DefaultQueueItem) | Out-Null + + Should -Invoke Resolve-NinjaOneCveScanGroup -Times 0 -Exactly + } + } + + Context 'Final org custom-fields PATCH failure' { + It 'propagates the failure to the outer catch and records lastStatus Failed' { + Mock -CommandName Invoke-WebRequest -ParameterFilter { $Uri -like '*/organization/*/custom-fields' } -MockWith { + throw 'NinjaOne API returned 500' + } + + $Result = Invoke-NinjaOneTenantSync -QueueItem (New-DefaultQueueItem) + + $Result | Should -Be $true + @($script:RecordedEntities | Where-Object { $_.lastStatus -eq 'Failed' }).Count | Should -Be 1 + Should -Invoke Write-LogMessage -ParameterFilter { $Sev -eq 'Error' -and $message -like '*Failed NinjaOne Processing*' } -Times 1 -Exactly + # CVE sync runs after the final PATCH in the source, so a PATCH + # failure should prevent CVE sync from ever being attempted. + Should -Invoke Resolve-NinjaOneCveScanGroup -Times 0 -Exactly + } + } + + Context 'UserDocuments enabled' { + BeforeEach { + Mock -CommandName Get-CIPPAzDataTableEntity -ParameterFilter { $Context -eq 'Extensionsconfig' } -MockWith { + $Cfg = New-DefaultConfiguration + $Cfg.UserDocumentsEnabled = $true + [pscustomobject]@{ config = (@{ NinjaOne = $Cfg } | ConvertTo-Json -Depth 10) } + } + Mock -CommandName Get-CippExtensionReportingData -MockWith { + [pscustomobject]@{ + Users = @([pscustomobject]@{ id = 'u1'; userPrincipalName = 'user1@contoso.onmicrosoft.com'; displayName = 'User One'; accountEnabled = $true; assignedLicenses = @() }) + AllRoles = @() + Devices = @() + DeviceCompliancePolicies = @() + OneDriveUsage = @() + CASMailbox = @() + Mailboxes = @() + MailboxUsage = @() + MailboxPermissions = @() + SecureScore = @() + SecureScoreControlProfiles = @() + Organization = [pscustomobject]@{ createdDateTime = '2020-01-01T00:00:00Z' } + Domains = @() + Groups = @() + Licenses = @() + ConditionalAccess = @() + } + } + Mock -CommandName Invoke-NinjaOneDocumentTemplate -MockWith { [pscustomobject]@{ id = '4001' } } + Mock -CommandName Invoke-WebRequest -ParameterFilter { $Uri -like '*organization/documents*' -and $Method -eq 'GET' } -MockWith { + [pscustomobject]@{ content = (@() | ConvertTo-Json) } + } + Mock -CommandName Invoke-WebRequest -ParameterFilter { $Uri -like '*organization/documents*' -and $Method -eq 'POST' } -MockWith { + [pscustomobject]@{ content = (@(@{ id = 'doc-1' }) | ConvertTo-Json) } + } + Mock -CommandName Invoke-WebRequest -ParameterFilter { $Uri -like '*organization/documents*' -and $Method -eq 'PATCH' } -MockWith { + [pscustomobject]@{ content = (@(@{ id = 'doc-1' }) | ConvertTo-Json) } + } + Mock -CommandName Invoke-WebRequest -ParameterFilter { $Uri -like '*related-items*' } -MockWith { + [pscustomobject]@{ content = (@() | ConvertTo-Json) } + } + } + + It 'completes successfully when creating user documents for new users' { + $Result = Invoke-NinjaOneTenantSync -QueueItem (New-DefaultQueueItem) + + $Result | Should -Be $true + @($script:RecordedEntities | Where-Object { $_.lastStatus -eq 'Completed' }).Count | Should -Be 1 + } + + It 'does not rethrow when the user-document batch create call fails' { + Mock -CommandName Invoke-WebRequest -ParameterFilter { $Uri -like '*organization/documents*' -and $Method -eq 'POST' } -MockWith { + throw 'NinjaOne document API error' + } + + $Result = Invoke-NinjaOneTenantSync -QueueItem (New-DefaultQueueItem) + + $Result | Should -Be $true + @($script:RecordedEntities | Where-Object { $_.lastStatus -eq 'Completed' }).Count | Should -Be 1 + } + } + + Context 'LicenseDocuments enabled' { + BeforeEach { + Mock -CommandName Get-CIPPAzDataTableEntity -ParameterFilter { $Context -eq 'Extensionsconfig' } -MockWith { + $Cfg = New-DefaultConfiguration + $Cfg.LicenseDocumentsEnabled = $true + [pscustomobject]@{ config = (@{ NinjaOne = $Cfg } | ConvertTo-Json -Depth 10) } + } + Mock -CommandName Get-CippExtensionReportingData -MockWith { + [pscustomobject]@{ + Users = @() + AllRoles = @() + Devices = @() + DeviceCompliancePolicies = @() + OneDriveUsage = @() + CASMailbox = @() + Mailboxes = @() + MailboxUsage = @() + MailboxPermissions = @() + SecureScore = @() + SecureScoreControlProfiles = @() + Organization = [pscustomobject]@{ createdDateTime = '2020-01-01T00:00:00Z' } + Domains = @() + Groups = @() + Licenses = @([pscustomobject]@{ skuId = 'sku-1'; skuPartNumber = 'ENTERPRISEPACK'; prepaidUnits = @{ enabled = 10 }; consumedUnits = 5 }) + ConditionalAccess = @() + } + } + Mock -CommandName Invoke-NinjaOneDocumentTemplate -MockWith { [pscustomobject]@{ id = '4002' } } + Mock -CommandName Invoke-WebRequest -ParameterFilter { $Uri -like '*organization/documents*' -and $Method -eq 'GET' } -MockWith { + [pscustomobject]@{ content = (@() | ConvertTo-Json) } + } + Mock -CommandName Invoke-WebRequest -ParameterFilter { $Uri -like '*organization/documents*' -and $Method -eq 'POST' } -MockWith { + [pscustomobject]@{ content = (@(@{ id = 'lic-doc-1' }) | ConvertTo-Json) } + } + Mock -CommandName Invoke-WebRequest -ParameterFilter { $Uri -like '*organization/documents*' -and $Method -eq 'PATCH' } -MockWith { + [pscustomobject]@{ content = (@(@{ id = 'lic-doc-1' }) | ConvertTo-Json) } + } + Mock -CommandName Invoke-WebRequest -ParameterFilter { $Uri -like '*related-items*' } -MockWith { + [pscustomobject]@{ content = (@() | ConvertTo-Json) } + } + } + + It 'completes successfully when creating license documents, using convert-skuname for friendly names' { + $Result = Invoke-NinjaOneTenantSync -QueueItem (New-DefaultQueueItem) + + $Result | Should -Be $true + @($script:RecordedEntities | Where-Object { $_.lastStatus -eq 'Completed' }).Count | Should -Be 1 + } + + It 'does not rethrow when the license-document batch create call fails' { + Mock -CommandName Invoke-WebRequest -ParameterFilter { $Uri -like '*organization/documents*' -and $Method -eq 'POST' } -MockWith { + throw 'NinjaOne document API error' + } + + $Result = Invoke-NinjaOneTenantSync -QueueItem (New-DefaultQueueItem) + + $Result | Should -Be $true + @($script:RecordedEntities | Where-Object { $_.lastStatus -eq 'Completed' }).Count | Should -Be 1 + } + } +} diff --git a/Tests/NinjaOne/Resolve-NinjaOneCveScanGroup.Tests.ps1 b/Tests/NinjaOne/Resolve-NinjaOneCveScanGroup.Tests.ps1 new file mode 100644 index 0000000000000..26c58261d2b52 --- /dev/null +++ b/Tests/NinjaOne/Resolve-NinjaOneCveScanGroup.Tests.ps1 @@ -0,0 +1,140 @@ +# Pester tests for Resolve-NinjaOneCveScanGroup +# Verifies the CVE sync scan-group lookup/auto-create behavior (GH issue #6349): +# - reuses an existing scan group when the name matches +# - creates a new scan group via the NinjaOne API when none exists +# - logs and returns $null (without throwing) when creation also fails, so the +# overall tenant sync is not interrupted by CVE sync failures + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $FunctionPath = Join-Path $RepoRoot 'Modules/CippExtensions/Private/NinjaOne/Resolve-NinjaOneCveScanGroup.ps1' + + # Minimal stubs so Mock has commands to replace during tests + function Write-LogMessage { param($API, $tenant, $message, $sev, $LogData) } + function Get-CippException { param($Exception) [pscustomobject]@{ NormalizedError = $Exception.Exception.Message } } + + . $FunctionPath +} + +Describe 'Resolve-NinjaOneCveScanGroup' { + BeforeEach { + $script:Configuration = [pscustomobject]@{ + Instance = 'contoso.rmmservice.com' + CveSyncDeviceIdHeader = 'deviceName' + CveSyncCveIdHeader = 'cveId' + } + $script:Token = [pscustomobject]@{ access_token = 'fake-token-value' } + $script:TenantFilter = 'contoso.onmicrosoft.com' + $script:ScanGroupName = 'CIPP-contoso.onmicrosoft.com' + $script:NinjaBaseUrl = 'https://contoso.rmmservice.com/api/v2' + + Mock -CommandName Write-LogMessage -MockWith { } + } + + It 'returns the existing scan group without attempting to create one' { + Mock -CommandName Invoke-RestMethod -ParameterFilter { $Method -eq 'Get' } -MockWith { + @( + [pscustomobject]@{ id = 'existing-id'; groupName = $script:ScanGroupName; deviceIdHeader = 'deviceName'; cveIdHeader = 'cveId' } + ) + } + Mock -CommandName Invoke-RestMethod -ParameterFilter { $Method -eq 'Post' } -MockWith { throw 'Should not be called' } + + $Result = Resolve-NinjaOneCveScanGroup -Configuration $script:Configuration -TenantFilter $script:TenantFilter -ScanGroupName $script:ScanGroupName -NinjaBaseUrl $script:NinjaBaseUrl -Token $script:Token + + $Result.id | Should -Be 'existing-id' + Should -Invoke Invoke-RestMethod -ParameterFilter { $Method -eq 'Get' } -Times 1 -Exactly + Should -Invoke Invoke-RestMethod -ParameterFilter { $Method -eq 'Post' } -Times 0 -Exactly + } + + It 'creates a new scan group when no matching group exists' { + Mock -CommandName Invoke-RestMethod -ParameterFilter { $Method -eq 'Get' } -MockWith { + @( + [pscustomobject]@{ id = 'other-id'; groupName = 'SomeOtherGroup'; deviceIdHeader = 'deviceName'; cveIdHeader = 'cveId' } + ) + } + Mock -CommandName Invoke-RestMethod -ParameterFilter { $Method -eq 'Post' } -MockWith { + [pscustomobject]@{ id = 'new-id'; groupName = $script:ScanGroupName; deviceIdHeader = 'deviceName'; cveIdHeader = 'cveId' } + } + + $Result = Resolve-NinjaOneCveScanGroup -Configuration $script:Configuration -TenantFilter $script:TenantFilter -ScanGroupName $script:ScanGroupName -NinjaBaseUrl $script:NinjaBaseUrl -Token $script:Token + + $Result.id | Should -Be 'new-id' + $Result.groupName | Should -Be $script:ScanGroupName + Should -Invoke Invoke-RestMethod -ParameterFilter { $Method -eq 'Get' } -Times 1 -Exactly + Should -Invoke Invoke-RestMethod -ParameterFilter { $Method -eq 'Post' } -Times 1 -Exactly + Should -Invoke Write-LogMessage -ParameterFilter { $sev -eq 'Info' -and $message -like '*created scan group*' } -Times 1 -Exactly + } + + It 'sends the configured header names and a bearer token when creating a scan group' { + Mock -CommandName Invoke-RestMethod -ParameterFilter { $Method -eq 'Get' } -MockWith { @() } + Mock -CommandName Invoke-RestMethod -ParameterFilter { $Method -eq 'Post' } -MockWith { + [pscustomobject]@{ id = 'new-id'; groupName = $script:ScanGroupName; deviceIdHeader = 'deviceName'; cveIdHeader = 'cveId' } + } + + Resolve-NinjaOneCveScanGroup -Configuration $script:Configuration -TenantFilter $script:TenantFilter -ScanGroupName $script:ScanGroupName -NinjaBaseUrl $script:NinjaBaseUrl -Token $script:Token | Out-Null + + Should -Invoke Invoke-RestMethod -ParameterFilter { + $Method -eq 'Post' -and + $Uri -eq "$($script:NinjaBaseUrl)/vulnerability/scan-groups" -and + $Headers.Authorization -eq "Bearer $($script:Token.access_token)" -and + $Headers.'Content-Type' -eq 'application/json' -and + ($Body | ConvertFrom-Json).groupName -eq $script:ScanGroupName -and + ($Body | ConvertFrom-Json).deviceIdHeader -eq 'deviceName' -and + ($Body | ConvertFrom-Json).cveIdHeader -eq 'cveId' + } -Times 1 -Exactly + } + + It 'returns $null and logs an error when the scan group cannot be found or created' { + Mock -CommandName Invoke-RestMethod -ParameterFilter { $Method -eq 'Get' } -MockWith { @() } + Mock -CommandName Invoke-RestMethod -ParameterFilter { $Method -eq 'Post' } -MockWith { throw 'NinjaOne API unavailable' } + + $Result = $null + { $Result = Resolve-NinjaOneCveScanGroup -Configuration $script:Configuration -TenantFilter $script:TenantFilter -ScanGroupName $script:ScanGroupName -NinjaBaseUrl $script:NinjaBaseUrl -Token $script:Token } | Should -Not -Throw + + $Result | Should -BeNullOrEmpty + Should -Invoke Write-LogMessage -ParameterFilter { $sev -eq 'Error' -and $message -like '*could not be created*' } -Times 1 -Exactly + } + + It 'returns $null and logs an error without throwing when the initial lookup GET fails' { + Mock -CommandName Invoke-RestMethod -ParameterFilter { $Method -eq 'Get' } -MockWith { throw 'NinjaOne API unreachable' } + Mock -CommandName Invoke-RestMethod -ParameterFilter { $Method -eq 'Post' } -MockWith { throw 'Should not be called' } + + $Result = $null + { $Result = Resolve-NinjaOneCveScanGroup -Configuration $script:Configuration -TenantFilter $script:TenantFilter -ScanGroupName $script:ScanGroupName -NinjaBaseUrl $script:NinjaBaseUrl -Token $script:Token } | Should -Not -Throw + + $Result | Should -BeNullOrEmpty + Should -Invoke Invoke-RestMethod -ParameterFilter { $Method -eq 'Post' } -Times 0 -Exactly + Should -Invoke Write-LogMessage -ParameterFilter { $sev -eq 'Error' -and $message -like '*could not look up scan group*' } -Times 1 -Exactly + } + + It 'returns a single scan group object when multiple groups share the same name' { + Mock -CommandName Invoke-RestMethod -ParameterFilter { $Method -eq 'Get' } -MockWith { + @( + [pscustomobject]@{ id = 'dup-1'; groupName = $script:ScanGroupName; deviceIdHeader = 'deviceName'; cveIdHeader = 'cveId' } + [pscustomobject]@{ id = 'dup-2'; groupName = $script:ScanGroupName; deviceIdHeader = 'deviceName'; cveIdHeader = 'cveId' } + ) + } + Mock -CommandName Invoke-RestMethod -ParameterFilter { $Method -eq 'Post' } -MockWith { throw 'Should not be called' } + + $Result = Resolve-NinjaOneCveScanGroup -Configuration $script:Configuration -TenantFilter $script:TenantFilter -ScanGroupName $script:ScanGroupName -NinjaBaseUrl $script:NinjaBaseUrl -Token $script:Token + + $Result.GetType().IsArray | Should -Be $false + $Result.id | Should -Be 'dup-1' + } + + It 'falls back to default header names when CveSyncDeviceIdHeader/CveSyncCveIdHeader are not configured' { + $script:Configuration = [pscustomobject]@{ Instance = 'contoso.rmmservice.com' } + Mock -CommandName Invoke-RestMethod -ParameterFilter { $Method -eq 'Get' } -MockWith { @() } + Mock -CommandName Invoke-RestMethod -ParameterFilter { $Method -eq 'Post' } -MockWith { + [pscustomobject]@{ id = 'new-id'; groupName = $script:ScanGroupName; deviceIdHeader = 'deviceName'; cveIdHeader = 'cveId' } + } + + Resolve-NinjaOneCveScanGroup -Configuration $script:Configuration -TenantFilter $script:TenantFilter -ScanGroupName $script:ScanGroupName -NinjaBaseUrl $script:NinjaBaseUrl -Token $script:Token | Out-Null + + Should -Invoke Invoke-RestMethod -ParameterFilter { + $Method -eq 'Post' -and + ($Body | ConvertFrom-Json).deviceIdHeader -eq 'deviceName' -and + ($Body | ConvertFrom-Json).cveIdHeader -eq 'cveId' + } -Times 1 -Exactly + } +} From 2bc0c8e2611aa0738bd3b1b4eda2cae4666b2758 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Wed, 15 Jul 2026 16:29:41 +0800 Subject: [PATCH 04/16] fix(standards): cast retention days to integer Convert `AgeLimitForRetention` from `TimeSpan.TotalDays` to an integer before state comparison in `Invoke-CIPPStandardRetentionPolicyTag`. This avoids decimal day values causing false drift detection when validating retention policy tag settings. --- .../Public/Standards/Invoke-CIPPStandardRetentionPolicyTag.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardRetentionPolicyTag.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardRetentionPolicyTag.ps1 index aa9ede0c285b8..d38c25f56144a 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardRetentionPolicyTag.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardRetentionPolicyTag.ps1 @@ -59,7 +59,7 @@ function Invoke-CIPPStandardRetentionPolicyTag { } $CurrentAgeLimitForRetention = if ($CurrentState.AgeLimitForRetention) { - ([timespan]$CurrentState.AgeLimitForRetention).TotalDays + [int]([timespan]$CurrentState.AgeLimitForRetention).TotalDays } $StateIsCorrect = ($CurrentState.Name -eq $PolicyName) -and From 986de0254baa64d4f7e9357083e24888abb65b2f Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Wed, 15 Jul 2026 19:34:20 +0800 Subject: [PATCH 05/16] chore: align frontend file with api file --- Config/standards.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Config/standards.json b/Config/standards.json index 0feef69292203..6fd4e3d3b64ec 100644 --- a/Config/standards.json +++ b/Config/standards.json @@ -5120,6 +5120,7 @@ }, { "name": "standards.SPDirectSharing", + "deprecated": true, "cat": "SharePoint Standards", "tag": [], "helpText": "This standard has been deprecated in favor of the Default Sharing Link standard. ", @@ -6580,7 +6581,7 @@ "label": "Conditional Access Template", "cat": "Templates", "multiple": true, - "disabledFeatures": { "report": true, "warn": true, "remediate": false }, + "disabledFeatures": { "report": false, "warn": false, "remediate": false }, "impact": "High Impact", "addedDate": "2023-12-30", "tag": [ From 8f1dd767ab7da97e20066760954935dc9f9eb1ca Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Wed, 15 Jul 2026 19:37:47 +0800 Subject: [PATCH 06/16] fix(standards): normalize smart lockout mode Update Smart Lockout metadata and docs to use the correct on-premises mode value (`Enforce` instead of `Enforced`). In `Invoke-CIPPStandardSmartLockout`, add compatibility mapping for legacy templates that still store `Enforced`, converting it to `Enforce` before compliance checks and remediation calls so tenants are not incorrectly flagged as non-compliant. --- Config/standards.json | 4 ++-- .../Standards/Invoke-CIPPStandardSmartLockout.ps1 | 12 +++++++++--- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/Config/standards.json b/Config/standards.json index 6fd4e3d3b64ec..52630842cdce3 100644 --- a/Config/standards.json +++ b/Config/standards.json @@ -8094,7 +8094,7 @@ "tag": ["CIS M365 7.0.0 (5.2.3.8)", "CIS M365 7.0.0 (5.2.3.9)"], "appliesToTest": ["CIS_5_2_3_8", "CIS_5_2_3_9"], "helpText": "**Requires Entra ID P1.** Configures the Entra ID Smart Lockout settings including lockout duration, lockout threshold, and on-premises integration mode.", - "docsDescription": "Configures the Entra ID Smart Lockout policy which protects against brute-force password attacks. Smart Lockout locks out bad actors who try to guess user passwords or use brute-force methods. It recognizes sign-ins from valid users and treats them differently from attackers. Settings include lockout duration (seconds), lockout threshold (failed attempts before lockout), and on-premises password protection mode (Audit or Enforced).", + "docsDescription": "Configures the Entra ID Smart Lockout policy which protects against brute-force password attacks. Smart Lockout locks out bad actors who try to guess user passwords or use brute-force methods. It recognizes sign-ins from valid users and treats them differently from attackers. Settings include lockout duration (seconds), lockout threshold (failed attempts before lockout), and on-premises password protection mode (Audit or Enforce).", "addedComponent": [ { "type": "number", @@ -8121,7 +8121,7 @@ "label": "On-Premises Mode", "options": [ { "label": "Audit", "value": "Audit" }, - { "label": "Enforced", "value": "Enforced" } + { "label": "Enforce", "value": "Enforce" } ] } ], diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardSmartLockout.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardSmartLockout.ps1 index 8c6237bcc290c..ba687cf031ea5 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardSmartLockout.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardSmartLockout.ps1 @@ -8,7 +8,7 @@ function Invoke-CIPPStandardSmartLockout { (Label) Configure Entra ID Smart Lockout .DESCRIPTION (Helptext) **Requires Entra ID P1.** Configures the Entra ID Smart Lockout settings including lockout duration, lockout threshold, and on-premises integration mode. - (DocsDescription) Configures the Entra ID Smart Lockout policy which protects against brute-force password attacks. Smart Lockout locks out bad actors who try to guess user passwords or use brute-force methods. It recognizes sign-ins from valid users and treats them differently from attackers. Settings include lockout duration (seconds), lockout threshold (failed attempts before lockout), and on-premises password protection mode (Audit or Enforced). + (DocsDescription) Configures the Entra ID Smart Lockout policy which protects against brute-force password attacks. Smart Lockout locks out bad actors who try to guess user passwords or use brute-force methods. It recognizes sign-ins from valid users and treats them differently from attackers. Settings include lockout duration (seconds), lockout threshold (failed attempts before lockout), and on-premises password protection mode (Audit or Enforce). .NOTES CAT Entra (AAD) Standards @@ -19,7 +19,7 @@ function Invoke-CIPPStandardSmartLockout { {"type":"number","name":"standards.SmartLockout.LockoutDurationInSeconds","label":"Lockout Duration (seconds)","default":60,"required":true} {"type":"number","name":"standards.SmartLockout.LockoutThreshold","label":"Lockout Threshold (failed attempts)","default":10,"required":true} {"type":"switch","name":"standards.SmartLockout.EnableBannedPasswordCheckOnPremises","label":"Enable On-Premises Password Protection"} - {"type":"radio","name":"standards.SmartLockout.BannedPasswordCheckOnPremisesMode","label":"On-Premises Mode","options":[{"label":"Audit","value":"Audit"},{"label":"Enforced","value":"Enforced"}]} + {"type":"radio","name":"standards.SmartLockout.BannedPasswordCheckOnPremisesMode","label":"On-Premises Mode","options":[{"label":"Audit","value":"Audit"},{"label":"Enforce","value":"Enforce"}]} IMPACT Medium Impact ADDEDDATE @@ -51,7 +51,13 @@ function Invoke-CIPPStandardSmartLockout { $DesiredLockoutDuration = [string]($Settings.LockoutDurationInSeconds.value ?? $Settings.LockoutDurationInSeconds ?? '60') $DesiredLockoutThreshold = [string]($Settings.LockoutThreshold.value ?? $Settings.LockoutThreshold ?? '10') $DesiredEnableOnPrem = [string]($Settings.EnableBannedPasswordCheckOnPremises.value ?? $Settings.EnableBannedPasswordCheckOnPremises ?? 'False') - $DesiredOnPremMode = $Settings.BannedPasswordCheckOnPremisesMode.value ?? $Settings.BannedPasswordCheckOnPremisesMode ?? 'Audit' + $DesiredOnPremMode = [string]($Settings.BannedPasswordCheckOnPremisesMode.value ?? $Settings.BannedPasswordCheckOnPremisesMode ?? 'Audit') + + # Graph only accepts 'Audit' or 'Enforce'. Templates saved before the option value was + # corrected hold 'Enforced', which never matches the tenant and reports as non-compliant. + if ($DesiredOnPremMode -eq 'Enforced') { + $DesiredOnPremMode = 'Enforce' + } # Normalize boolean switch to string if ($DesiredEnableOnPrem -eq $true -or $DesiredEnableOnPrem -eq 'true' -or $DesiredEnableOnPrem -eq 'True') { From 25f38c58f6f9ee41843c5127fa54110e91382db5 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Wed, 15 Jul 2026 19:53:25 +0800 Subject: [PATCH 07/16] fix(cisa): correct EXO 1.4.1 failure output Use the actual policy properties returned by the test when building the failed policy table, and hardcode the expected action as `Quarantine` so the report matches the check logic. --- .../Public/Tests/CISA/Identity/Invoke-CippTestCISAMSEXO141.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Modules/CIPPTests/Public/Tests/CISA/Identity/Invoke-CippTestCISAMSEXO141.ps1 b/Modules/CIPPTests/Public/Tests/CISA/Identity/Invoke-CippTestCISAMSEXO141.ps1 index b4968c25a793a..e8117ad8db535 100644 --- a/Modules/CIPPTests/Public/Tests/CISA/Identity/Invoke-CippTestCISAMSEXO141.ps1 +++ b/Modules/CIPPTests/Public/Tests/CISA/Identity/Invoke-CippTestCISAMSEXO141.ps1 @@ -33,7 +33,7 @@ function Invoke-CippTestCISAMSEXO141 { $null = $Result.Append("| Policy Name | Current Action | Expected |`n") $null = $Result.Append("| :---------- | :------------- | :------- |`n") foreach ($Policy in $FailedPolicies) { - $null = $Result.Append("| $($Policy.'Policy Name') | $($Policy.'Current Action') | $($Policy.Expected) |`n") + $null = $Result.Append("| $($Policy.Name) | $($Policy.HighConfidenceSpamAction) | Quarantine |`n") } $Status = 'Failed' } From 285685273e1e915f9f493429431c906260c2edb6 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Wed, 15 Jul 2026 19:54:56 +0800 Subject: [PATCH 08/16] fix(orca): validate ZAP-supported spam actions Update ORCA121 to inspect hosted content filter policies instead of quarantine policies and flag SpamAction or PhishSpamAction values that Zero Hour Autopurge cannot remediate. This adds clearer pass/fail output with per-setting details and documents the test intent. --- .../ORCA/Identity/Invoke-CippTestORCA121.ps1 | 50 +++++++++++++++++-- 1 file changed, 46 insertions(+), 4 deletions(-) diff --git a/Modules/CIPPTests/Public/Tests/ORCA/Identity/Invoke-CippTestORCA121.ps1 b/Modules/CIPPTests/Public/Tests/ORCA/Identity/Invoke-CippTestORCA121.ps1 index d18f29b16a7c0..e95e5c5f9a378 100644 --- a/Modules/CIPPTests/Public/Tests/ORCA/Identity/Invoke-CippTestORCA121.ps1 +++ b/Modules/CIPPTests/Public/Tests/ORCA/Identity/Invoke-CippTestORCA121.ps1 @@ -2,20 +2,62 @@ function Invoke-CippTestORCA121 { <# .SYNOPSIS Supported filter policy action used + + .DESCRIPTION + ORCA121 (area: Zero Hour Autopurge). ZAP can only act on a message that was delivered to the + Junk Email folder or quarantined, so it is inert when a policy's spam or phish action is one + that leaves the message in place (e.g. AddXHeader, ModifySubject, NoAction). This checks that + SpamAction and PhishSpamAction on each anti-spam policy are actions ZAP supports. + + .FUNCTIONALITY + Internal #> param($Tenant) try { - $Policies = Get-CIPPTestData -TenantFilter $Tenant -Type 'ExoQuarantinePolicy' + $Policies = Get-CIPPTestData -TenantFilter $Tenant -Type 'ExoHostedContentFilterPolicy' if (-not $Policies) { Add-CippTestResult -TenantFilter $Tenant -TestId 'ORCA121' -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'No data found in database. This may be due to missing required licenses or data collection not yet completed.' -Risk 'Low' -Name 'Supported filter policy action used' -UserImpact 'Medium' -ImplementationEffort 'Low' -Category 'Quarantine' return } - $Status = 'Passed' - $Result = [System.Text.StringBuilder]::new("Quarantine policies are configured to support Zero Hour Auto Purge.`n`n") - $null = $Result.Append("**Total Policies:** $($Policies.Count)") + # Actions ZAP can act on, per ORCA121. Anything else leaves the message in the inbox, + # where ZAP has nothing to purge. + $SupportedActions = @('MoveToJmf', 'Redirect', 'Delete', 'Quarantine') + + $Failures = [System.Collections.Generic.List[object]]::new() + $PolicyCount = 0 + + foreach ($Policy in $Policies) { + $PolicyCount++ + # ORCA evaluates the two actions as separate config items, so a policy can fail on + # one and pass on the other; report them independently rather than per-policy. + foreach ($Setting in @('SpamAction', 'PhishSpamAction')) { + $Value = $Policy.$Setting + if ($Value -notin $SupportedActions) { + $Failures.Add([PSCustomObject]@{ + Policy = $Policy.Identity ?? $Policy.Name + Setting = $Setting + Value = if ($null -eq $Value -or $Value -eq '') { 'Not set' } else { $Value } + }) | Out-Null + } + } + } + + if ($Failures.Count -eq 0) { + $Status = 'Passed' + $Result = [System.Text.StringBuilder]::new("✅ **Pass**: All $PolicyCount anti-spam policy/policies use a filter action that Zero Hour Auto Purge supports.`n`n") + $null = $Result.Append("Supported actions: $($SupportedActions -join ', ').") + } else { + $Status = 'Failed' + $Result = [System.Text.StringBuilder]::new("❌ **Fail**: $($Failures.Count) setting(s) across $PolicyCount anti-spam policy/policies use an action that Zero Hour Auto Purge cannot act on:`n`n") + $null = $Result.Append("| Policy | Setting | Current Action | Supported |`n") + $null = $Result.Append("| :----- | :------ | :------------- | :-------- |`n") + foreach ($Failure in $Failures) { + $null = $Result.Append("| $($Failure.Policy) | $($Failure.Setting) | $($Failure.Value) | $($SupportedActions -join ', ') |`n") + } + } Add-CippTestResult -TenantFilter $Tenant -TestId 'ORCA121' -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'Low' -Name 'Supported filter policy action used' -UserImpact 'Medium' -ImplementationEffort 'Low' -Category 'Quarantine' From 45bd6c9963f17f3e0431592c994de121aeaa5a57 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Thu, 16 Jul 2026 00:13:47 +0800 Subject: [PATCH 09/16] feat(cache): implement field projection for test data Add field-level projection to Get-CIPPTestData and New-CIPPDbRequest to reduce memory usage by only materializing queried fields. Introduce Get-CippTestDataFieldManifest to manage per-type field whitelists, and replace ConvertFrom-Json with CIPP.CippJson (System.Text.Json-based) for faster parsing. Update Get-CippDbRoleMembers with missing fields (appId, EndDateTime, IsPermanent) required by role member callers. Refactor CIPPTestDataCache to use direct PSObject APIs instead of reflection for heap-size estimation. --- Modules/CIPPCore/Public/Get-CIPPTestData.ps1 | 91 +++++++++- .../CIPPCore/Public/Get-CippDbRoleMembers.ps1 | 55 ++++++ .../CIPPCore/Public/Get-CippSandboxData.ps1 | 2 +- .../Public/Get-CippTestDataFieldManifest.ps1 | 165 ++++++++++++++++++ Modules/CIPPCore/Public/New-CIPPDbRequest.ps1 | 56 +++++- Shared/CIPPSharp/CIPPSharp.csproj | 3 + Shared/CIPPSharp/CIPPTestDataCache.cs | 58 ++---- Shared/CIPPSharp/CippJsonConverter.cs | 113 ++++++++++++ Shared/CIPPSharp/bin/CIPPSharp.dll | Bin 48640 -> 49152 bytes 9 files changed, 485 insertions(+), 58 deletions(-) create mode 100644 Modules/CIPPCore/Public/Get-CippTestDataFieldManifest.ps1 create mode 100644 Shared/CIPPSharp/CippJsonConverter.cs diff --git a/Modules/CIPPCore/Public/Get-CIPPTestData.ps1 b/Modules/CIPPCore/Public/Get-CIPPTestData.ps1 index f3b6d1b8f06cb..ed6568c6d364b 100644 --- a/Modules/CIPPCore/Public/Get-CIPPTestData.ps1 +++ b/Modules/CIPPCore/Public/Get-CIPPTestData.ps1 @@ -4,15 +4,66 @@ function Get-CIPPTestData { Cached wrapper around New-CIPPDbRequest for test functions .DESCRIPTION - Returns cached tenant data during test suite execution. The cache is - backed by CIPP.TestDataCache (static ConcurrentDictionary in C#) so - it is shared across all PowerShell runspaces within the worker process. + Returns cached tenant data during test suite execution. The cache is backed by + CIPP.TestDataCache (static ConcurrentDictionary in C#) so it is shared across all + PowerShell runspaces within the worker process. + + RECORDS ARE FIELD-PROJECTED. Only the fields listed for $Type in + Get-CippTestDataFieldManifest are materialized. Reading an unlisted field returns $null + with no error, so the test emits a wrong verdict silently. If you add a field read to a + test, add it to the manifest first. A type absent from the manifest is fetched whole, + which is the safe default. + + Projection is record-level: a kept field keeps its entire subtree, so + `$policy.conditions.users.includeRoles` only requires 'conditions'. + + Records are PSCustomObject and must stay so. Hashtable output changes scalar semantics + ($x[0] becomes a key lookup returning $null; $x.Count returns the record's field count + instead of 1) and only when a pipeline yields exactly one record — data-dependent, and + easily missed in testing. .PARAMETER TenantFilter The tenant domain or GUID to filter by .PARAMETER Type The data type to retrieve (e.g., Users, Groups, ConditionalAccessPolicies) + + .PARAMETER Fields + Optional. Override the manifest for this call: keep only these top-level fields. Prefer + the manifest; this is for one-off and diagnostic callers, not tests. + + The field set is part of the cache key — it must be, or callers asking for the same + tenant+type with different lists would receive each other's projection. Distinct lists + therefore fragment the cache: the same rows parsed once per list, all alive together for + the TTL. Per-call-site lists measured several times worse than one shared per-type entry. + Fields belong to the type — see Get-CippTestDataFieldManifest. + + .PARAMETER NoProjection + Return every field, ignoring the manifest. + + Required for the custom-script sandbox (Get-CippSandboxData): those scripts are + customer-authored, so the fields they read are unknowable and a manifest built from our + own tests would silently hand them incomplete records. Any caller fetching data on behalf + of code we did not write must pass this. + + .EXAMPLE + Get-CIPPTestData -TenantFilter $Tenant -Type 'Users' + Normal use: the manifest decides the fields. This is what tests should do. + + .EXAMPLE + Get-CIPPTestData -TenantFilter $Tenant -Type 'Users' -NoProjection + Every field. For callers serving code we did not author. + + .NOTES + Verifying a change: "0 errored" proves nothing, because the failure modes here are silent + — a broken test still reports success. Diff test verdicts against a baseline, across more + than one tenant: a bug that needs exactly one matching record will not show on a tenant + that has two. + + Useful endpoints: ExecTestRun then ListTests for verdicts; ListDBCache to confirm a field + exists and its real casing before adding it to the manifest (type=_availableTypes lists + the types); ListWorkerHealth?Action=CacheDiag for entries, hit rate and a per-type + breakdown whose keys carry the projected field list. #> [CmdletBinding()] param( @@ -20,7 +71,13 @@ function Get-CIPPTestData { [string]$TenantFilter, [Parameter(Mandatory = $false)] - [string]$Type + [string]$Type, + + [Parameter(Mandatory = $false)] + [string[]]$Fields, + + [Parameter(Mandatory = $false)] + [switch]$NoProjection ) # Enforce tenant lock when running inside custom script execution @@ -32,14 +89,36 @@ function Get-CIPPTestData { throw 'TenantFilter is required.' } - $CacheKey = '{0}|{1}' -f $TenantFilter, $Type + # Resolve the field set: an explicit -Fields wins, otherwise consult the per-type manifest. + # -NoProjection suppresses both (the sandbox path must never receive projected records). + $EffectiveFields = if ($NoProjection) { + $null + } elseif ($Fields) { + $Fields + } else { + Get-CippTestDataFieldManifest -Type $Type + } + + # Normalize the field set (sorted + de-duplicated) so that callers asking for the same + # fields in a different order share one cache entry instead of parsing twice. + $NormalizedFields = if ($EffectiveFields) { @($EffectiveFields | Sort-Object -Unique) } else { $null } + + $CacheKey = if ($NormalizedFields) { + '{0}|{1}|{2}' -f $TenantFilter, $Type, ($NormalizedFields -join ',') + } else { + '{0}|{1}' -f $TenantFilter, $Type + } $CachedValue = $null if ([CIPP.TestDataCache]::TryGet($CacheKey, [ref]$CachedValue)) { return $CachedValue } - $Data = New-CIPPDbRequest -TenantFilter $TenantFilter -Type $Type + $Data = if ($NormalizedFields) { + New-CIPPDbRequest -TenantFilter $TenantFilter -Type $Type -Fields $NormalizedFields + } else { + New-CIPPDbRequest -TenantFilter $TenantFilter -Type $Type + } [CIPP.TestDataCache]::Set($CacheKey, $Data) diff --git a/Modules/CIPPCore/Public/Get-CippDbRoleMembers.ps1 b/Modules/CIPPCore/Public/Get-CippDbRoleMembers.ps1 index 0b5b026df61aa..78b144efa856c 100644 --- a/Modules/CIPPCore/Public/Get-CippDbRoleMembers.ps1 +++ b/Modules/CIPPCore/Public/Get-CippDbRoleMembers.ps1 @@ -1,4 +1,47 @@ function Get-CippDbRoleMembers { + <# + .SYNOPSIS + Resolve the members of a directory role from cached PIM + directoryRole data. + + .DESCRIPTION + Merges three sources into one member list, de-duplicated by principal id: + Active - PIM roleAssignmentScheduleInstances with assignmentType 'Assigned' + Eligible - PIM roleEligibilitySchedules (can activate, not currently active) + Direct - directoryRole membership assigned outside PIM + + A principal may be a user, a servicePrincipal, or a group — use '@odata.type' to tell + them apart. userPrincipalName is null for anything that isn't a user, and appId is + populated only for servicePrincipals. + + .PARAMETER TenantFilter + The tenant to resolve members for. + + .PARAMETER RoleTemplateId + The role's TEMPLATE id (e.g. Global Administrator = 62e90394-69f5-4237-9190-012177145e10). + + NOT the directoryRole instance id. PIM's roleDefinitionId carries template ids, so passing + an instance id (Roles.id) matches nothing and silently returns an empty list. When starting + from a Get-CippDbRole record, pass $Role.roleTemplateId — never $Role.id. + + .OUTPUTS + PSCustomObject per member: + id - principal object id + displayName - principal display name + userPrincipalName - users only; null for servicePrincipals and groups + appId - servicePrincipals only; null otherwise + '@odata.type' - '#microsoft.graph.user' | '...servicePrincipal' | '...group' + AssignmentType - 'Active' | 'Eligible' | 'Direct' + EndDateTime - when the assignment expires; null when it does not + IsPermanent - $true when the assignment has no expiry + + .NOTES + Depends on the cached records carrying an expanded 'principal' object — the Graph APIs + return only principalId by default, so the collectors request $expand=principal. Without + it every displayName/userPrincipalName here is null. + + .FUNCTIONALITY + Internal + #> [CmdletBinding()] param ( [Parameter(Mandatory = $true)] @@ -27,8 +70,12 @@ function Get-CippDbRoleMembers { id = $member.principalId displayName = $member.principal.displayName userPrincipalName = $member.principal.userPrincipalName + appId = $member.principal.appId '@odata.type' = $member.principal.'@odata.type' AssignmentType = 'Active' + EndDateTime = $member.endDateTime + # A PIM assignment with no endDateTime never expires. + IsPermanent = ($null -eq $member.endDateTime) } $AllMembers.Add($memberObj) } @@ -39,8 +86,12 @@ function Get-CippDbRoleMembers { id = $member.principalId displayName = $member.principal.displayName userPrincipalName = $member.principal.userPrincipalName + appId = $member.principal.appId '@odata.type' = $member.principal.'@odata.type' AssignmentType = 'Eligible' + # Eligibilities carry their expiry under scheduleInfo, not endDateTime. + EndDateTime = $member.scheduleInfo.expiration.endDateTime + IsPermanent = ($member.scheduleInfo.expiration.type -eq 'noExpiration') } $AllMembers.Add($memberObj) } @@ -52,8 +103,12 @@ function Get-CippDbRoleMembers { id = $member.id displayName = $member.displayName userPrincipalName = $member.userPrincipalName + appId = $member.appId '@odata.type' = $member.'@odata.type' AssignmentType = 'Direct' + # directoryRole membership assigned outside PIM has no expiry. + EndDateTime = $null + IsPermanent = $true } $AllMembers.Add($memberObj) } diff --git a/Modules/CIPPCore/Public/Get-CippSandboxData.ps1 b/Modules/CIPPCore/Public/Get-CippSandboxData.ps1 index daa7b4a32e143..1d5112b92963f 100644 --- a/Modules/CIPPCore/Public/Get-CippSandboxData.ps1 +++ b/Modules/CIPPCore/Public/Get-CippSandboxData.ps1 @@ -70,7 +70,7 @@ function Get-CippSandboxData { $Key = if ($Type) { $Type } else { '' } if (-not $Data.ContainsKey($Key)) { - $Data[$Key] = @(Get-CIPPTestData -TenantFilter $TenantFilter -Type $Type) + $Data[$Key] = @(Get-CIPPTestData -TenantFilter $TenantFilter -Type $Type -NoProjection) } } diff --git a/Modules/CIPPCore/Public/Get-CippTestDataFieldManifest.ps1 b/Modules/CIPPCore/Public/Get-CippTestDataFieldManifest.ps1 new file mode 100644 index 0000000000000..41905a55f7a71 --- /dev/null +++ b/Modules/CIPPCore/Public/Get-CippTestDataFieldManifest.ps1 @@ -0,0 +1,165 @@ +function Get-CippTestDataFieldManifest { + <# + .SYNOPSIS + Per-type field manifest for Get-CIPPTestData projection. + + .DESCRIPTION + Returns the union of top-level fields that every consumer of a given CippReportingDB type + reads, or $null for types that must be fetched whole. + + ADDING OR CHANGING A FIELD READ IN A TEST? ADD IT HERE FIRST. Get-CIPPTestData only + materializes what is listed here; reading an unlisted field returns $null with no error, + and the test emits a wrong compliance verdict silently. + + Rules for editing this table: + + * Top-level names only. Projection is record-level: a kept field keeps its ENTIRE subtree. + For `$policy.conditions.users.includeRoles` you add 'conditions' — never 'users'. + * When in doubt, add it. Over-inclusion costs a little memory; omission corrupts results. + * Matching is case-insensitive, and tests rely on it — some read a field using different + casing than the stored JSON. Use the real JSON casing here; do not "fix" the test. + * A type absent from this table is fetched whole — the safe default. A new type, or a + field nobody listed, degrades to pre-projection behaviour rather than corrupting a + verdict. Deliberately absent are types whose tests discover column names at runtime by + reflecting over the record, and types small enough that projecting buys nothing. + * Verify with a verdict diff across more than one tenant — see Get-CIPPTestData .NOTES. + "0 errored" proves nothing; the failure mode is silent. + + CONSUMERS THAT ARE NOT TEST FILES — easy to miss, since no test names these fields: + + Get-CippDbRole ......... Roles + Get-CippDbRoleMembers .. RoleAssignmentScheduleInstances, RoleEligibilitySchedules, + Roles — including the 'principal' subtree, which no test + mentions; omitting it blanks every role member across the + privileged-access tests + Test-E8AsrRule ......... IntuneConfigurationPolicies — the field contract for the E8 ASR + tests, which read nothing themselves + + Any new helper calling Get-CIPPTestData belongs on that list. + + WHY PER-TYPE, NOT PER-CALL-SITE: the field set is part of the Get-CIPPTestData cache key, + so per-caller lists fragment the cache — the same rows parsed once per distinct list, all + alive together for the TTL. Many call sites share few types, and they mostly want the same + large subtrees, so fragmenting measured several times worse than one shared entry. + + .PARAMETER Type + The CippReportingDB data type. + + .OUTPUTS + [string[]] of field names, or $null meaning "no projection — return every field". + + .EXAMPLE + Get-CippTestDataFieldManifest -Type 'Users' + Callers do not normally invoke this — Get-CIPPTestData consults it automatically by type. + + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + [OutputType([string[]])] + param( + [Parameter(Mandatory = $false, Position = 0)] + [string]$Type + ) + + if ([string]::IsNullOrWhiteSpace($Type)) { return $null } + + if (-not $script:CippTestDataFieldManifest) { + # Deliberately ABSENT (=> fetched whole), do not "helpfully" add them: + # CopilotUsageUserDetail, CopilotUserCountSummary, CopilotUserCountTrend — their tests + # discover column names at runtime by reflecting over the record, so no field list + # can be known ahead of time (CopilotReady015/016/017). + # ExoGlobalQuarantinePolicy — ORCA107 annotates the record via `Select-Object *`, and + # the type is tiny, so projecting buys nothing. + $script:CippTestDataFieldManifest = @{ + 'AdminConsentRequestPolicy' = @('isEnabled', 'reviewers', 'notifyReviewers', 'remindersEnabled', 'requestDurationInDays') + 'Apps' = @('id', 'appId', 'displayName', 'keyCredentials', 'passwordCredentials', 'signInAudience', 'servicePrincipalLockConfiguration', 'owners', 'web', 'spa', 'publicClient') + 'AppsAndServices' = @('id', 'isOfficeStoreEnabled', 'isAppAndServicesTrialEnabled') + 'AuthenticationFlowsPolicy' = @('selfServiceSignUp') + 'AuthenticationMethodsPolicy' = @('authenticationMethodConfigurations', 'policyMigrationState', 'reportSuspiciousActivitySettings', 'systemCredentialPreferences') + 'AuthenticationStrengths' = @('id', 'displayName', 'policyType', 'allowedCombinations') + 'AuthorizationPolicy' = @('defaultUserRolePermissions', 'guestUserRoleId', 'allowInvitesFrom', 'allowedToUseSSPR', 'allowedToSignUpEmailBasedSubscriptions', 'allowEmailVerifiedUsersToJoinOrganization', 'permissionGrantPolicyIdsAssignedToDefaultUserRole', 'allowUserConsentForRiskyApps', 'allowedToCreateTenants') + 'B2BManagementPolicy' = @('allowInvitesFrom', 'invitationsAllowedAndBlockedDomainsPolicy', 'definition') + 'CASMailbox' = @('Identity', 'DisplayName', 'SmtpClientAuthenticationDisabled') + 'ConditionalAccessPolicies' = @('id', 'displayName', 'state', 'conditions', 'grantControls', 'sessionControls', 'createdDateTime', 'modifiedDateTime') + 'CopilotReadinessActivity' = @('userPrincipalName', 'usesOutlookEmail', 'usesTeamsMeetings', 'usesTeamsChat', 'usesOfficeDocs', 'onQualifiedUpdateChannel', 'hasCopilotLicenseAssigned') + 'CrossTenantAccessPolicy' = @('id', 'b2bCollaborationOutbound', 'b2bDirectConnectOutbound', 'tenantRestrictions') + 'CsExternalAccessPolicy' = @('EnableFederationAccess', 'EnableTeamsConsumerAccess') + 'CsTeamsAppPermissionPolicy' = @('Identity', 'GlobalCatalogAppsType', 'DefaultCatalogAppsType') + 'CsTeamsClientConfiguration' = @('AllowDropbox', 'AllowBox', 'AllowGoogleDrive', 'AllowShareFile', 'AllowEgnyte', 'AllowEmailIntoChannel') + 'CsTeamsMeetingPolicy' = @('AllowAnonymousUsersToJoinMeeting', 'AllowAnonymousUsersToStartMeeting', 'AutoAdmittedUsers', 'AllowPSTNUsersToBypassLobby', 'MeetingChatEnabledType', 'DesignatedPresenterRoleMode', 'AllowExternalParticipantGiveRequestControl', 'AllowExternalNonTrustedMeetingChat', 'AllowCloudRecording') + 'CsTeamsMessagingPolicy' = @('UseB2BInvitesToAddExternalUsers', 'AllowSecurityEndUserReporting') + 'CsTenantFederationConfiguration' = @('AllowFederatedUsers', 'AllowedDomains', 'AllowTeamsConsumer') + 'DefaultAppManagementPolicy' = @('isEnabled', 'applicationRestrictions', 'servicePrincipalRestrictions') + 'DeviceRegistrationPolicy' = @('azureADJoin', 'userDeviceQuota', 'localAdminPassword', 'multiFactorAuthConfiguration') + 'DeviceSettings' = @('secureByDefault') + 'DirectoryRecommendations' = @('status', 'priority', 'displayName', 'impactType', 'lastModifiedDateTime', 'insights', 'recommendationType', 'applicationDisplayName', 'applicationId') + 'DlpCompliancePolicies' = @('Name', 'DisplayName', 'Mode', 'Enabled', 'TeamsLocation', 'TeamsLocationException', 'Workload', 'EnforcementPlanes') + 'Domains' = @('id', 'passwordValidityPeriodInDays', 'authenticationType') + 'ExoAcceptedDomains' = @('DomainName', 'DomainType', 'SendingFromDomainDisabled') + 'ExoAdminAuditLogConfig' = @('UnifiedAuditLogIngestionEnabled', 'AdminAuditLogEnabled') + 'ExoAntiPhishPolicies' = @('Name', 'Identity', 'Enabled', 'PhishThresholdLevel', 'EnableMailboxIntelligence', 'EnableMailboxIntelligenceProtection', 'EnableSpoofIntelligence', 'TargetedUserProtectionAction', 'TargetedDomainProtectionAction', 'MailboxIntelligenceProtectionAction', 'AuthenticationFailAction', 'EnableFirstContactSafetyTips', 'EnableSimilarUsersSafetyTips', 'EnableSimilarDomainsSafetyTips', 'EnableUnusualCharactersSafetyTips', 'EnableUnauthenticatedSender', 'ExcludedSenders', 'ExcludedDomains', 'RecipientDomainIs', 'HonorDmarcPolicy') + 'ExoAtpPolicyForO365' = @('EnableATPForSPOTeamsODB', 'EnableSafeDocs', 'AllowSafeDocsOpen') + 'ExoDkimSigningConfig' = @('Domain', 'Enabled', 'Selector1CNAME', 'Selector2CNAME') + 'ExoHostedContentFilterPolicy' = @('Name', 'Identity', 'AllowedSenders', 'AllowedSenderDomains', 'EnableSafeList', 'SpamAction', 'HighConfidenceSpamAction', 'BulkSpamAction', 'PhishSpamAction', 'HighConfidencePhishAction', 'RecipientDomainIs', 'BulkThreshold', 'MarkAsSpamBulkMail', 'QuarantineRetentionPeriod', 'InlineSafetyTipsEnabled', 'IPAllowList', 'PhishZapEnabled', 'SpamZapEnabled', + 'IncreaseScoreWithImageLinks', 'IncreaseScoreWithNumericIps', 'IncreaseScoreWithRedirectToOtherPort', 'IncreaseScoreWithBizOrInfoUrls', 'MarkAsSpamEmptyMessages', 'MarkAsSpamJavaScriptInHtml', 'MarkAsSpamFramesInHtml', 'MarkAsSpamObjectTagsInHtml', 'MarkAsSpamEmbedTagsInHtml', 'MarkAsSpamFormTagsInHtml', 'MarkAsSpamWebBugsInHtml', 'MarkAsSpamSensitiveWordList', 'MarkAsSpamFromAddressAuthFail', 'MarkAsSpamNdrBackscatter', 'MarkAsSpamSpfRecordHardFail') + 'ExoHostedOutboundSpamFilterPolicy' = @('Identity', 'IsDefault', 'RecipientLimitExternalPerHour', 'RecipientLimitInternalPerHour', 'RecipientLimitPerDay', 'ActionWhenThresholdReached', 'NotifyOutboundSpam', 'NotifyOutboundSpamRecipients', 'BccSuspiciousOutboundMail', 'BccSuspiciousOutboundAdditionalRecipients', 'AutoForwardingMode') + 'ExoInboundConnector' = @('Identity', 'Enabled', 'SenderDomains', 'EFSkipLastIP', 'EFSkipIPs', 'EFTestMode', 'EFUsers') + 'ExoMalwareFilterPolicies' = @('Name', 'Identity', 'IsDefault', 'EnableFileFilter', 'FileTypes', 'EnableInternalSenderAdminNotifications', 'InternalSenderAdminAddress', 'ZapEnabled', 'Action', 'RecipientDomainIs') + 'ExoOrganizationConfig' = @('CustomerLockBoxEnabled', 'BookingsEnabled', 'AuditDisabled', 'ExternalInOutlookEnabled', 'ExternalInOutlook', 'OAuth2ClientProfileEnabled', 'MailTipsAllTipsEnabled', 'MailTipsExternalRecipientsTipsEnabled', 'MailTipsGroupMetricsEnabled', 'MailTipsLargeAudienceThreshold', 'RejectDirectSend') + 'ExoPresetSecurityPolicy' = @('Identity', 'State', 'ImpersonationProtectionState', 'EnableMailboxIntelligence', 'EnableMailboxIntelligenceProtection', 'EnableSimilarUsersSafetyTips', 'EnableSimilarDomainsSafetyTips', 'EnableUnusualCharactersSafetyTips') + 'ExoProtectionAlert' = @('Name', 'Disabled') + 'ExoRemoteDomain' = @('Name', 'DomainName', 'AutoForwardEnabled') + 'ExoSafeAttachmentPolicies' = @('Name', 'Identity', 'Enable', 'Action', 'RecipientDomainIs') + 'ExoSafeLinksPolicies' = @('Name', 'Identity', 'EnableSafeLinksForEmail', 'EnableSafeLinksForTeams', 'EnableSafeLinksForOffice', 'TrackClicks', 'TrackUserClicks', 'AllowClickThrough', 'ScanUrls', 'EnableForInternalSenders', 'DeliverMessageAfterScan', 'DisableUrlRewrite', 'RecipientDomainIs', 'IsBuiltInProtection') + 'ExoSharingPolicy' = @('Name', 'Enabled', 'Domains') + 'ExoTenantAllowBlockList' = @('Action', 'ListType', 'Value') + 'ExoTransportConfig' = @('SmtpClientAuthenticationDisabled') + 'ExoTransportRules' = @('Name', 'State', 'Priority', 'SetSCL', 'SetSpamConfidenceLevel', 'SetHeaderName', 'SetHeaderValue', 'SenderDomainIs') + 'FormsSettings' = @('isInOrgFormsPhishingScanEnabled') + 'Groups' = @('id', 'displayName', 'mail', 'visibility', 'groupTypes', 'members', 'isAssignableToRole') + 'Guests' = @('id', 'displayName', 'userPrincipalName', 'accountEnabled', 'signInActivity', 'createdDateTime', 'sponsors') + # URLName is added by the collector (Add-Member), not returned by Graph. It carries the + # Graph resource each policy came from (iosManagedAppProtection / + # androidManagedAppProtection / targetedManagedAppConfiguration) and is the only + # platform discriminator on these records — @odata.type is not preserved in the cache. + 'IntuneAppProtectionManagedAppPolicies' = @('URLName', 'displayName', 'assignments') + 'IntuneConfigurationPolicies' = @('name', 'platforms', 'technologies', 'templateReference', 'settings', 'assignments') + 'IntuneDeviceCompliancePolicies' = @('@odata.type', 'displayName', 'assignments', 'osMinimumVersion', 'bitLockerEnabled', 'storageRequireEncryption') + 'IntuneDeviceConfigurations' = @('@odata.type', 'displayName', 'assignments', 'qualityUpdatesDeferralPeriodInDays', 'fileVaultEnabled', 'wiFiSecurityType') + 'IntuneDeviceEnrollmentConfigurations' = @('@odata.type', 'displayName', 'priority', 'deviceEnrollmentConfigurationType', 'assignments', 'androidForWorkRestriction', 'androidRestriction', 'iosRestriction', 'macOSRestriction', 'windowsRestriction') + 'LicenseOverview' = @('License', 'TotalLicenses', 'CountUsed', 'ServicePlans', 'AssignedUsers', 'TermInfo') + 'Mailboxes' = @('UPN', 'UserPrincipalName', 'displayName', 'recipientTypeDetails', 'ExternalDirectoryObjectId', 'AuditEnabled', 'AuditOwner', 'AuditBypassEnabled', 'WhenSoftDeleted', 'LitigationHoldEnabled', 'LicensedForLitigationHold', 'ComplianceTagHoldApplied', 'RetentionPolicy', 'InPlaceHolds') + 'ManagedDevices' = @('deviceName', 'lastSyncDateTime', 'operatingSystem', 'osVersion') + 'MDEOnboarding' = @('partnerState') + 'MFAState' = @('UPN', 'userPrincipalName', 'DisplayName', 'AccountEnabled', 'UserType', 'IsAdmin', 'isLicensed', 'PerUser', 'PerUserMFAState', 'CoveredByCA', 'CoveredBySD', 'MFARegistration', 'MFACapable', 'MFAMethods') + 'NamedLocations' = @('@odata.type', 'displayName', 'isTrusted') + 'OfficeActivations' = @('userPrincipalName', 'userActivationCounts') + 'Organization' = @('onPremisesSyncEnabled', 'onPremisesLastSyncDateTime') + 'OwaMailboxPolicy' = @('Identity', 'IsDefault', 'PersonalAccountsEnabled', 'PersonalAccountCalendarsEnabled', 'AdditionalStorageProvidersAvailable') + 'ReportSubmissionPolicy' = @('ReportJunkToCustomizedAddress', 'ReportPhishToCustomizedAddress', 'ReportChatMessageEnabled', 'ReportChatMessageToCustomizedAddressEnabled') + 'RiskDetections' = @('riskState', 'riskLevel', 'riskEventType', 'riskDetail', 'userPrincipalName', 'userDisplayName', 'detectedDateTime', 'activityDateTime') + 'RiskyServicePrincipals' = @('id', 'appId', 'displayName', 'servicePrincipalType', 'riskState', 'riskLevel', 'riskLastUpdatedDateTime') + 'RiskyUsers' = @('id', 'userPrincipalName', 'riskState', 'riskLevel', 'riskDetail', 'riskLastUpdatedDateTime') + # 'principal' is NOT read by any test file — Get-CippDbRoleMembers reads + # $member.principal.displayName/.userPrincipalName. Omitting it would silently blank + # every role member across the CIS/E8/ZTNA privileged-access tests. + 'RoleAssignmentScheduleInstances' = @('roleDefinitionId', 'assignmentType', 'memberType', 'endDateTime', 'principalId', 'principal') + 'RoleEligibilitySchedules' = @('roleDefinitionId', 'principalId', 'principal', 'scheduleInfo') + # policyId, not id: this type is sourced from roleManagementPolicyAssignments (only the + # assignment carries roleDefinitionId) and the policy is flattened up one level. + 'RoleManagementPolicies' = @('policyId', 'scopeId', 'scopeType', 'roleDefinitionId', 'rules', 'effectiveRules') + 'Roles' = @('id', 'displayName', 'roleTemplateId', 'members') + 'SecureScore' = @('currentScore', 'maxScore', 'createdDateTime', 'controlScores') + 'SensitivityLabels' = @('name', 'PolicyName', 'IsValid', 'isActive', 'sensitivity', 'parent', 'hasProtection') + 'ServicePrincipalRiskDetections' = @('servicePrincipalId', 'servicePrincipalDisplayName', 'appId', 'activity', 'riskState', 'riskLevel', 'riskEventType', 'detectedDateTime', 'lastUpdatedDateTime') + 'ServicePrincipals' = @('id', 'appId', 'displayName', 'accountEnabled', 'keyCredentials', 'passwordCredentials', 'appOwnerOrganizationId', 'servicePrincipalType', 'replyUrls', 'owners', 'appRoleAssignmentRequired', 'preferredSingleSignOnMode') + 'Settings' = @('id', 'templateId', 'displayName', 'values', 'isOfficeStoreEnabled', 'isAppAndServicesTrialEnabled', 'isInOrgFormsPhishingScanEnabled') + 'SPOTenant' = @('LegacyAuthProtocolsEnabled', 'EnableAzureADB2BIntegration', 'SharingCapability', 'OneDriveSharingCapability', 'PreventExternalUsersFromResharing', 'SharingDomainRestrictionMode', 'SharingAllowedDomainList', 'SharingBlockedDomainList', 'DefaultSharingLinkType', 'DefaultLinkPermission', 'ExternalUserExpirationRequired', 'ExternalUserExpireInDays', 'EmailAttestationRequired', 'EmailAttestationReAuthDays', 'DisallowInfectedFileDownload') + 'UserRegistrationDetails' = @('id', 'userPrincipalName', 'userDisplayName', 'isMfaCapable', 'isMfaRegistered', 'methodsRegistered') + 'Users' = @('id', 'userPrincipalName', 'displayName', 'accountEnabled', 'userType', 'onPremisesSyncEnabled', 'assignedLicenses', 'assignedPlans', 'signInActivity', 'passwordPolicies') + } + } + + return $script:CippTestDataFieldManifest[$Type] +} diff --git a/Modules/CIPPCore/Public/New-CIPPDbRequest.ps1 b/Modules/CIPPCore/Public/New-CIPPDbRequest.ps1 index 0a3345e5cbacb..77b17558ce962 100644 --- a/Modules/CIPPCore/Public/New-CIPPDbRequest.ps1 +++ b/Modules/CIPPCore/Public/New-CIPPDbRequest.ps1 @@ -4,7 +4,11 @@ function New-CIPPDbRequest { Query the CIPP Reporting database by partition key .DESCRIPTION - Retrieves data from the CippReportingDB table filtered by partition key (tenant) + Retrieves data from the CippReportingDB table filtered by partition key (tenant). + + Rows are parsed by CIPP.CippJson (System.Text.Json), not ConvertFrom-Json — see .NOTES. + Most callers should use Get-CIPPTestData instead, which adds the shared cache and applies + the per-type field manifest automatically. Call this directly only to bypass both. .PARAMETER TenantFilter The tenant domain or GUID to filter by (used as partition key) @@ -12,11 +16,41 @@ function New-CIPPDbRequest { .PARAMETER Type Optional. The data type to filter by (e.g., Users, Groups, Devices) + .PARAMETER Fields + Optional. Keep only these top-level fields on each returned record; everything else is + skipped without being materialized. A retained field keeps its ENTIRE subtree — projection + never reaches inside a kept value, so `$p.conditions.users.includeRoles` only requires + 'conditions'. Omit to return every field. Matching is case-insensitive. + + This is the only memory lever, but its value depends entirely on which fields you keep: it + pays where records are large and the read-set is small, and buys almost nothing where the + retained subtrees are most of the payload. Measure before assuming a win. + + .NOTES + Backed by CIPP.CippJson (System.Text.Json). Output is PSCustomObject with + ConvertFrom-Json's [DateTime] coercion and Int64 number semantics preserved deliberately, + so this is a drop-in replacement for every existing caller. Matching those semantics costs + nothing measurable — do not reintroduce divergence to save a branch. + + Unparseable rows are skipped rather than thrown, matching the -ErrorAction SilentlyContinue + on the ConvertFrom-Json this replaced. A row whose Data is a JSON array returns object[], + which PowerShell unrolls into the output stream — the same shape the old pipeline produced. + + Without -Fields the parser alone is modestly faster and allocates less, but retains the + same live bytes — the parser was never the memory cost. Only -Fields reduces footprint. + + Benchmark on production's runtime (PowerShell 7.4 / .NET 8, Linux container). + System.Text.Json timings differ enough on newer runtimes to invert conclusions; + allocation numbers are runtime-insensitive. + .EXAMPLE New-CIPPDbRequest -TenantFilter 'contoso.onmicrosoft.com' .EXAMPLE New-CIPPDbRequest -TenantFilter 'contoso.onmicrosoft.com' -Type 'Users' + + .EXAMPLE + New-CIPPDbRequest -TenantFilter 'contoso.onmicrosoft.com' -Type 'Users' -Fields 'id','displayName' #> [CmdletBinding()] param( @@ -24,7 +58,10 @@ function New-CIPPDbRequest { [string]$TenantFilter, [Parameter(Mandatory = $false)] - [string]$Type + [string]$Type, + + [Parameter(Mandatory = $false)] + [string[]]$Fields ) try { @@ -69,7 +106,20 @@ function New-CIPPDbRequest { $Results = Get-CIPPAzDataTableEntity @Table -Filter $Filter - return ($Results.Data | ConvertFrom-Json -ErrorAction SilentlyContinue) + # CippJson replaces `$Results.Data | ConvertFrom-Json`. A row whose Data is a JSON array + # returns object[], which PowerShell unrolls into the output stream — the same shape the + # pipeline produced before. Bad rows are skipped rather than thrown, matching the + # -ErrorAction SilentlyContinue this replaced. + $Projection = if ($Fields) { [string[]]$Fields } else { $null } + $Output = foreach ($Row in $Results.Data) { + if ([string]::IsNullOrWhiteSpace($Row)) { continue } + try { + [CIPP.CippJson]::ConvertFromJson($Row, $Projection) + } catch { + Write-Information "Skipping unparseable CippReportingDB row for '$Tenant'/'$Type': $($_.Exception.Message)" + } + } + return $Output } catch { Write-LogMessage -API 'CIPPDbRequest' -tenant $TenantFilter -message "Failed to query database: $($_.Exception.Message)" -sev Error throw diff --git a/Shared/CIPPSharp/CIPPSharp.csproj b/Shared/CIPPSharp/CIPPSharp.csproj index 9bd6e19ccacde..6c7a3f192bdb0 100644 --- a/Shared/CIPPSharp/CIPPSharp.csproj +++ b/Shared/CIPPSharp/CIPPSharp.csproj @@ -14,4 +14,7 @@ bin\ false + + + diff --git a/Shared/CIPPSharp/CIPPTestDataCache.cs b/Shared/CIPPSharp/CIPPTestDataCache.cs index da2538ec5ba2d..2b15d21c07da8 100644 --- a/Shared/CIPPSharp/CIPPTestDataCache.cs +++ b/Shared/CIPPSharp/CIPPTestDataCache.cs @@ -3,7 +3,7 @@ using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; -using System.Reflection; +using System.Management.Automation; using System.Threading; using System.Threading.Tasks; @@ -266,13 +266,6 @@ private static void TryFireBackgroundSweep() public static double HitRate => (_hits + _misses) > 0 ? Math.Round(_hits * 100.0 / (_hits + _misses), 1) : 0; - // ── PSObject reflection (cached, resolved once at first use) ── - private static readonly object s_reflectLock = new(); - private static bool s_psResolved; - private static Type? s_psObjectType; - private static PropertyInfo? s_psPropsProp; // PSObject.Properties - private static PropertyInfo? s_psPropName; // PSPropertyInfo.Name - private static PropertyInfo? s_psPropValue; // PSPropertyInfo.Value // ── Managed-heap size model (x64 CoreCLR) ── // The cache stores *parsed PSObject graphs*, whose live heap footprint is // many times their JSON text size (measured ≈8× on real Graph data). Sizing @@ -293,40 +286,12 @@ private static void TryFireBackgroundSweep() // Bound the walk on pathological payloads: full-walk small collections for // accuracy, but stride-sample very large ones so a 100k-item array can't - // turn a single Set() into a multi-second reflection storm. + // turn a single Set() into a multi-second graph walk. private const int LargeCollectionThreshold = 512; private const int LargeCollectionSamples = 256; private const int MaxDepth = 32; private const long NodeBudget = 2_000_000; // hard ceiling on nodes visited per Set() - private static void EnsurePSResolved() - { - if (s_psResolved) return; - lock (s_reflectLock) - { - if (s_psResolved) return; - try - { - foreach (var asm in AppDomain.CurrentDomain.GetAssemblies()) - { - var t = asm.GetType("System.Management.Automation.PSObject"); - if (t == null) continue; - s_psObjectType = t; - s_psPropsProp = t.GetProperty("Properties"); - var piType = asm.GetType("System.Management.Automation.PSPropertyInfo"); - if (piType != null) - { - s_psPropName = piType.GetProperty("Name"); - s_psPropValue = piType.GetProperty("Value"); - } - break; - } - } - catch { /* SMA not loaded */ } - s_psResolved = true; - } - } - private static long Align8(long bytes) => (bytes + 7) & ~7L; /// @@ -340,7 +305,6 @@ private static void EnsurePSResolved() private static long EstimateValueSize(object? value, int itemCount) { if (value == null) return 0; - EnsurePSResolved(); try { long budget = NodeBudget; @@ -375,8 +339,8 @@ private static long SizeOf(object? value, int depth, ref long budget) } // PSObject → wrapper cost + each NoteProperty (name string + value + overhead) - if (s_psObjectType != null && s_psObjectType.IsInstanceOfType(value)) - return SizeOfPSObject(value, depth, ref budget); + if (value is PSObject pso) + return SizeOfPSObject(pso, depth, ref budget); // Dictionary / Hashtable → base + per-entry overhead + keys + values if (value is IDictionary dict) @@ -428,22 +392,20 @@ private static long SizeOf(object? value, int depth, ref long budget) return ObjectHeader + 32; } - private static long SizeOfPSObject(object psObj, int depth, ref long budget) + private static long SizeOfPSObject(PSObject psObj, int depth, ref long budget) { long total = PSObjectBase; - if (s_psPropsProp == null || s_psPropName == null || s_psPropValue == null) - return total; - if (s_psPropsProp.GetValue(psObj) is not IEnumerable props) return total; - foreach (var prop in props) + foreach (var prop in psObj.Properties) { if (--budget <= 0) break; total += PSNotePropertyOverhead; try { - if (s_psPropName.GetValue(prop) is string name) - total += Align8(StringBaseOverhead + 2L * name.Length); - total += SizeOf(s_psPropValue.GetValue(prop), depth + 1, ref budget); + var name = prop.Name; + if (name != null) total += Align8(StringBaseOverhead + 2L * name.Length); + // .Value can throw on script/code properties that evaluate on access. + total += SizeOf(prop.Value, depth + 1, ref budget); } catch { /* skip properties that throw on access */ } } diff --git a/Shared/CIPPSharp/CippJsonConverter.cs b/Shared/CIPPSharp/CippJsonConverter.cs new file mode 100644 index 0000000000000..a94da1da4ab9e --- /dev/null +++ b/Shared/CIPPSharp/CippJsonConverter.cs @@ -0,0 +1,113 @@ +using System; +using System.Collections.Generic; +using System.Management.Automation; +using System.Text.Json; + +namespace CIPP +{ + /// + /// Fast JSON -> PowerShell converter for the CippReportingDB read path, replacing + /// `ConvertFrom-Json` in New-CIPPDbRequest. + /// + /// COMPATIBILITY: reproduces ConvertFrom-Json's observable semantics, because every caller + /// depends on them and any divergence fails *silently*: + /// - PSCustomObject records, so `$x[0]`, `$x.Count` and `$x.PSObject.Properties` all keep + /// the scalar semantics callers rely on. Hashtable output is cheaper but changes those, + /// and only when a pipeline yields exactly one record — do not switch. + /// - ISO-8601-looking strings become [DateTime] (tests compare these against [datetime]). + /// - Every integer becomes Int64 regardless of magnitude (never Int32). + /// Matching these costs nothing measurable, so there is no reason to ship divergence. + /// + /// PERFORMANCE: without a field list this is modestly faster and allocates less than + /// ConvertFrom-Json, but retains the SAME live bytes — the parser was never the memory cost. + /// The memory win comes from : not materializing unread fields. + /// + public static class CippJson + { + private static readonly JsonDocumentOptions Opts = new JsonDocumentOptions { MaxDepth = 1024 }; + + /// Convert a JSON document, materializing every field. + public static object? ConvertFromJson(string json) => ConvertFromJson(json, null); + + /// + /// Convert a JSON document, keeping only on each RECORD. + /// + /// Projection applies at the record level only: for an object root, the root's own fields; + /// for an array root, each element's own fields. A field that is kept keeps its ENTIRE + /// subtree — projection never reaches inside a retained value. Null/empty keeps everything. + /// + /// The saving therefore scales with how much of the record is dropped: large for + /// scalar-only field sets, small when a kept subtree is most of the payload. + /// + public static object? ConvertFromJson(string json, string[]? fields) + { + if (string.IsNullOrEmpty(json)) return null; + + HashSet? keep = (fields != null && fields.Length > 0) + ? new HashSet(fields, StringComparer.OrdinalIgnoreCase) + : null; + + using var doc = JsonDocument.Parse(json, Opts); + var root = doc.RootElement; + + // Records live at the root, or one level down if the root is an array. + if (root.ValueKind == JsonValueKind.Array) + { + var rows = new List(); + foreach (var item in root.EnumerateArray()) rows.Add(ReadRecord(item, keep)); + return rows.ToArray(); + } + + return ReadRecord(root, keep); + } + + /// A record: the one level at which projection applies. + private static object? ReadRecord(JsonElement el, HashSet? keep) + { + if (el.ValueKind != JsonValueKind.Object) return ReadValue(el); + + var pso = new PSObject(); + foreach (var p in el.EnumerateObject()) + { + // Skipped fields are never materialized — this is the whole point of projection. + if (keep != null && !keep.Contains(p.Name)) continue; + pso.Properties.Add(new PSNoteProperty(p.Name, ReadValue(p.Value))); // kept => whole subtree + } + return pso; + } + + /// Everything below the record level: materialized in full. + private static object? ReadValue(JsonElement el) + { + switch (el.ValueKind) + { + case JsonValueKind.Object: + var pso = new PSObject(); + foreach (var p in el.EnumerateObject()) + pso.Properties.Add(new PSNoteProperty(p.Name, ReadValue(p.Value))); + return pso; + + case JsonValueKind.Array: + var list = new List(); + foreach (var item in el.EnumerateArray()) list.Add(ReadValue(item)); + return list.ToArray(); + + case JsonValueKind.String: + // ConvertFrom-Json coerces ISO-8601 strings to DateTime; matching that keeps + // date comparisons in tests behaving as they do today. + return el.TryGetDateTime(out var dt) ? dt : (object?)el.GetString(); + + case JsonValueKind.True: return true; + case JsonValueKind.False: return false; + case JsonValueKind.Null: return null; + + case JsonValueKind.Number: + // ConvertFrom-Json yields Int64 for all integers — never narrow to Int32. + if (el.TryGetInt64(out long l)) return l; + return el.GetDouble(); + + default: return null; + } + } + } +} diff --git a/Shared/CIPPSharp/bin/CIPPSharp.dll b/Shared/CIPPSharp/bin/CIPPSharp.dll index c242eca31f4571e538cfb150b451b87035b8867d..b2cd18ede86a7d510a57512e247a06235d5d0dec 100644 GIT binary patch literal 49152 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 literal 48640 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 Hhk?HV`9~DX From ba8232e2f832d6e957b40d48212b1713110dd181 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Thu, 16 Jul 2026 00:14:23 +0800 Subject: [PATCH 10/16] fix(cippdb): cache PIM role data with app token Add the required RoleManagement permissions to SAMManifest and update PIM cache jobs to use app-only Graph calls (`-AsApp`) so role APIs stop failing with unauthorized delegated access. Role assignment and eligibility schedule caching now expands `principal` to preserve member identity fields, and role management policy caching now reads `roleManagementPolicyAssignments` (with required filter/expands) and flattens policy rules/effectiveRules alongside `roleDefinitionId` for role-to-policy lookups. --- Config/SAMManifest.json | 30 ++++++++++++++++- ...DBCacheRoleAssignmentScheduleInstances.ps1 | 12 ++++++- ...et-CIPPDBCacheRoleEligibilitySchedules.ps1 | 7 +++- .../Set-CIPPDBCacheRoleManagementPolicies.ps1 | 32 +++++++++++++++++-- 4 files changed, 76 insertions(+), 5 deletions(-) diff --git a/Config/SAMManifest.json b/Config/SAMManifest.json index 3b304150e7cf8..1f7dd716f19c2 100644 --- a/Config/SAMManifest.json +++ b/Config/SAMManifest.json @@ -582,6 +582,34 @@ { "id": "b7887744-6746-4312-813d-72daeaee7e2d", "type": "Scope" + }, + { + "id": "dd199f4a-f148-40a4-a2ec-f0069cc799ec", + "type": "Role" + }, + { + "id": "8c026be3-8e26-4774-9372-8d5d6f21daff", + "type": "Scope" + }, + { + "id": "fee28b28-e1f3-4841-818e-2704dc62245f", + "type": "Role" + }, + { + "id": "62ade113-f8e0-4bf9-a6ba-5acb31db32fd", + "type": "Scope" + }, + { + "id": "9e3f62cf-ca93-4989-b6ce-bf83c28f9fe8", + "type": "Role" + }, + { + "id": "31e08e0a-d3f7-4ca2-ac39-7343fb83e8ad", + "type": "Role" + }, + { + "id": "1ff1be21-34eb-448c-9ac9-ce1f506b2a68", + "type": "Scope" } ] }, @@ -689,4 +717,4 @@ "isEnabled": true, "allProperties": true } -} \ No newline at end of file +} diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheRoleAssignmentScheduleInstances.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheRoleAssignmentScheduleInstances.ps1 index ca5aab39a4bf0..06387748b9dcb 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheRoleAssignmentScheduleInstances.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheRoleAssignmentScheduleInstances.ps1 @@ -18,7 +18,17 @@ function Set-CIPPDBCacheRoleAssignmentScheduleInstances { try { Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching role assignment schedule instances' -sev Debug - New-GraphGetRequest -Uri 'https://graph.microsoft.com/v1.0/roleManagement/directory/roleAssignmentScheduleInstances' -tenantid $TenantFilter -Stream | + # -AsApp is required: the RoleManagement.*.Directory grant is an APPLICATION permission, + # so it only appears in an app-only token. The default delegated path (service account + + # GDAP) fails with "Attempted to perform an unauthorized operation" because PIM reads via + # delegated access additionally need the signed-in user to hold a directory role such as + # Privileged Role Administrator, which GDAP does not grant. + # $expand=principal is required by Get-CippDbRoleMembers, which reads + # $member.principal.displayName/.userPrincipalName/.'@odata.type'. The API returns only + # principalId (a GUID) by default, so without this those all resolve to $null and every + # role member surfaces with a blank name. + $Uri = 'https://graph.microsoft.com/v1.0/roleManagement/directory/roleAssignmentScheduleInstances?$expand=principal' + New-GraphGetRequest -Uri $Uri -tenantid $TenantFilter -AsApp $true -Stream | Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'RoleAssignmentScheduleInstances' -AddCount Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached role assignment schedule instances successfully' -sev Debug diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheRoleEligibilitySchedules.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheRoleEligibilitySchedules.ps1 index e730bab436fc5..cdef0a857ee3e 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheRoleEligibilitySchedules.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheRoleEligibilitySchedules.ps1 @@ -18,7 +18,12 @@ function Set-CIPPDBCacheRoleEligibilitySchedules { try { Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching role eligibility schedules' -sev Debug - New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/roleManagement/directory/roleEligibilitySchedules' -tenantid $TenantFilter -Stream | + # -AsApp is required: RoleManagement.*.Directory is an APPLICATION permission and only + # lands in an app-only token. The default delegated path returns "unauthorized". + # $expand=principal — see the note in Set-CIPPDBCacheRoleAssignmentScheduleInstances; + # Get-CippDbRoleMembers reads principal.displayName off these records too. + $Uri = 'https://graph.microsoft.com/beta/roleManagement/directory/roleEligibilitySchedules?$expand=principal' + New-GraphGetRequest -uri $Uri -tenantid $TenantFilter -AsApp $true -Stream | Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'RoleEligibilitySchedules' -AddCount Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached role eligibility schedules successfully' -sev Debug diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheRoleManagementPolicies.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheRoleManagementPolicies.ps1 index 168e8aeedbf3b..3e68308d27a52 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheRoleManagementPolicies.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheRoleManagementPolicies.ps1 @@ -1,13 +1,36 @@ function Set-CIPPDBCacheRoleManagementPolicies { <# .SYNOPSIS - Caches role management policies for a tenant + Caches PIM role management policies for a tenant, keyed by the role they apply to .PARAMETER TenantFilter The tenant to cache role management policies for .PARAMETER QueueId The queue ID to update with total tasks (optional) + + .NOTES + Reads roleManagementPolicyAssignments, NOT roleManagementPolicies, because only the + assignment carries roleDefinitionId — the policy record does not identify the role it + applies to, and the role id is not derivable from it (its id embeds a policy GUID that + matches no roleTemplateId). Consumers need to find "the policy for role X", so the + assignment is the correct entity. + https://learn.microsoft.com/graph/api/policyroot-list-rolemanagementpolicyassignments + + $filter is REQUIRED by this API — it must be scoped to a scopeId and scopeType, and the + request errors without it. rules/effectiveRules are navigation properties on the policy, + so they need a nested $expand; they are absent from the default response and consumers + read both. + + The policy is flattened up one level so cached records expose roleDefinitionId alongside + rules/effectiveRules, which is the shape the tests consume. This costs -Stream (the + Select-Object has to materialize), but the result set is small (~144 records/tenant). + + -AsApp is required: RoleManagement.*.Directory is an APPLICATION permission and only + lands in an app-only token. The default delegated path returns "unauthorized". + + A tenant that has never onboarded PIM returns "MissingProvider: The provider is missing" + regardless of query or permissions. That is tenant state, not a bug in this call. #> [CmdletBinding()] param( @@ -18,7 +41,12 @@ function Set-CIPPDBCacheRoleManagementPolicies { try { Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching role management policies' -sev Debug - New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/policies/roleManagementPolicies' -tenantid $TenantFilter -Stream | + + $Uri = "https://graph.microsoft.com/beta/policies/roleManagementPolicyAssignments?`$filter=scopeId eq '/' and scopeType eq 'DirectoryRole'&`$expand=policy(`$expand=rules,effectiveRules)" + New-GraphGetRequest -uri $Uri -tenantid $TenantFilter -AsApp $true | + Select-Object -Property policyId, roleDefinitionId, scopeId, scopeType, + @{ Name = 'rules'; Expression = { $_.policy.rules } }, + @{ Name = 'effectiveRules'; Expression = { $_.policy.effectiveRules } } | Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'RoleManagementPolicies' -AddCount Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached role management policies successfully' -sev Debug From 98f0778d88a0f16428c4bc4bc9eb362750f580cf Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Thu, 16 Jul 2026 00:15:13 +0800 Subject: [PATCH 11/16] fix: correct field references in identity and device tests Fix multiple bugs in CIPP test functions where incorrect field names or IDs were used when querying role data and policies: - Replace `$GA.id` with `$GA.roleTemplateId` when matching PIM role assignments (CIS_1_1_2, CIS_1_1_3) - Replace `$Role.templateId` with `$Role.roleTemplateId` when filtering roles and policies (ZTNA21813, 21816, 21818, 21820) - Fix app protection policy tests to read from correct data type and distinguish between missing data vs. missing platform-specific policy (ZTNA24548, ZTNA24549) - Replace `IsPermanent` for permanent GA detection instead of `AssignmentType -eq 'Permanent'` (ZTNA21835) - Use correct field names from Get-CippDbRoleMembers: `id` instead of `principalId`, `displayName` instead of `principalDisplayName` (ZTNA21835, ZTNA21836) - Fix role management policy lookup to use `roleDefinitionId` instead of nonexistent `effectiveRules.target.targetObjects.id` (ZTNA21819, ZTNA21820) - Clean up Entra URL construction (ZTNA21836) These changes fix tests that were silently returning false negatives due to incorrect field references. --- .../CIS/Identity/Invoke-CippTestCIS_1_1_2.ps1 | 5 ++++- .../CIS/Identity/Invoke-CippTestCIS_1_1_3.ps1 | 5 ++++- .../ZTNA/Devices/Invoke-CippTestZTNA24548.ps1 | 19 ++++++++++++++++--- .../ZTNA/Devices/Invoke-CippTestZTNA24549.ps1 | 19 ++++++++++++++++--- .../Identity/Invoke-CippTestZTNA21813.ps1 | 11 ++++++++--- .../Identity/Invoke-CippTestZTNA21816.ps1 | 4 +++- .../Identity/Invoke-CippTestZTNA21818.ps1 | 4 +++- .../Identity/Invoke-CippTestZTNA21819.ps1 | 10 +++++++--- .../Identity/Invoke-CippTestZTNA21820.ps1 | 17 +++++++++-------- .../Identity/Invoke-CippTestZTNA21835.ps1 | 14 ++++++++++---- .../Identity/Invoke-CippTestZTNA21836.ps1 | 15 ++++++++++----- .../Identity/Invoke-CippTestZTNA21899.ps1 | 4 +++- 12 files changed, 93 insertions(+), 34 deletions(-) diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_1_2.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_1_2.ps1 index 4684f72618f06..6ba82dcf1b788 100644 --- a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_1_2.ps1 +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_1_2.ps1 @@ -33,8 +33,11 @@ function Invoke-CippTestCIS_1_1_2 { } } + # roleDefinitionId carries the role's TEMPLATE id, not the directoryRole instance id, so + # comparing it to $GA.id never matched and no PIM-assigned admin was ever counted here — + # only the direct members above. foreach ($Assignment in @($RoleAssignmentScheduleInstances)) { - if ($Assignment.roleDefinitionId -eq $GA.id -and $Assignment.assignmentType -eq 'Assigned' -and $null -eq $Assignment.endDateTime -and $Assignment.principalId) { + if ($Assignment.roleDefinitionId -eq $GA.roleTemplateId -and $Assignment.assignmentType -eq 'Assigned' -and $null -eq $Assignment.endDateTime -and $Assignment.principalId) { [void]$GAUserIds.Add([string]$Assignment.principalId) } } diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_1_3.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_1_3.ps1 index fe93d5b980b06..441f577a97d25 100644 --- a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_1_3.ps1 +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_1_3.ps1 @@ -28,8 +28,11 @@ function Invoke-CippTestCIS_1_1_3 { } } + # roleDefinitionId carries the role's TEMPLATE id, not the directoryRole instance id, so + # comparing it to $GA.id never matched and no PIM-assigned admin was ever counted here — + # only the direct members above. foreach ($Assignment in @($RoleAssignmentScheduleInstances)) { - if ($Assignment.roleDefinitionId -eq $GA.id -and $Assignment.assignmentType -eq 'Assigned' -and $null -eq $Assignment.endDateTime -and $Assignment.principalId) { + if ($Assignment.roleDefinitionId -eq $GA.roleTemplateId -and $Assignment.assignmentType -eq 'Assigned' -and $null -eq $Assignment.endDateTime -and $Assignment.principalId) { [void]$GAMembers.Add([string]$Assignment.principalId) } } diff --git a/Modules/CIPPTests/Public/Tests/ZTNA/Devices/Invoke-CippTestZTNA24548.ps1 b/Modules/CIPPTests/Public/Tests/ZTNA/Devices/Invoke-CippTestZTNA24548.ps1 index 0809843b69697..8196f6053aadd 100644 --- a/Modules/CIPPTests/Public/Tests/ZTNA/Devices/Invoke-CippTestZTNA24548.ps1 +++ b/Modules/CIPPTests/Public/Tests/ZTNA/Devices/Invoke-CippTestZTNA24548.ps1 @@ -9,20 +9,33 @@ function Invoke-CippTestZTNA24548 { #Tested - Device try { - $IosPolicies = Get-CIPPTestData -TenantFilter $Tenant -Type 'IntuneIosAppProtectionPolicies' + # App protection policies for every platform live under one type; URLName carries the + # Graph resource they came from and is the platform discriminator. This previously read + # 'IntuneIosAppProtectionPolicies', a type no collector writes, so the test always skipped. + $AllPolicies = @(Get-CIPPTestData -TenantFilter $Tenant -Type 'IntuneAppProtectionManagedAppPolicies') - if (-not $IosPolicies) { + # Only skip when the type itself is absent (no Intune licence, or collection has not run). + if ($AllPolicies.Count -eq 0) { Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Devices' -Status 'Skipped' -ResultMarkdown 'No data found in database. This may be due to missing required licenses or data collection not yet completed.' -Risk 'High' -Name 'Data on iOS/iPadOS is protected by app protection policies' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Tenant' return } + $IosPolicies = @($AllPolicies | Where-Object { $_.URLName -eq 'iosManagedAppProtection' }) + + # Data exists but no iOS policy at all — that is a genuine failure of this control, not a + # missing-data skip. + if ($IosPolicies.Count -eq 0) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Devices' -Status 'Failed' -ResultMarkdown "❌ No iOS/iPadOS app protection policy exists in this tenant.`n`nApp protection policies were found for other platforms, so Intune data is being collected — there is simply no iOS policy." -Risk 'High' -Name 'Data on iOS/iPadOS is protected by app protection policies' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Tenant' + return + } + $AssignedPolicies = @($IosPolicies | Where-Object { $_.assignments -and $_.assignments.Count -gt 0 }) $Passed = $AssignedPolicies.Count -gt 0 if ($Passed) { $ResultMarkdown = [System.Text.StringBuilder]::new("✅ At least one iOS app protection policy exists and is assigned.`n`n") } else { - $ResultMarkdown = [System.Text.StringBuilder]::new("❌ No iOS app protection policy exists or none are assigned.`n`n") + $ResultMarkdown = [System.Text.StringBuilder]::new("❌ iOS app protection policies exist but none are assigned.`n`n") } $null = $ResultMarkdown.Append("## iOS App Protection Policies`n`n") diff --git a/Modules/CIPPTests/Public/Tests/ZTNA/Devices/Invoke-CippTestZTNA24549.ps1 b/Modules/CIPPTests/Public/Tests/ZTNA/Devices/Invoke-CippTestZTNA24549.ps1 index 2837b609f04d7..b2ef4a25e3367 100644 --- a/Modules/CIPPTests/Public/Tests/ZTNA/Devices/Invoke-CippTestZTNA24549.ps1 +++ b/Modules/CIPPTests/Public/Tests/ZTNA/Devices/Invoke-CippTestZTNA24549.ps1 @@ -9,20 +9,33 @@ function Invoke-CippTestZTNA24549 { #Tested - Device try { - $AndroidPolicies = Get-CIPPTestData -TenantFilter $Tenant -Type 'IntuneAndroidAppProtectionPolicies' + # App protection policies for every platform live under one type; URLName carries the + # Graph resource they came from and is the platform discriminator. This previously read + # 'IntuneAndroidAppProtectionPolicies', a type no collector writes, so the test always skipped. + $AllPolicies = @(Get-CIPPTestData -TenantFilter $Tenant -Type 'IntuneAppProtectionManagedAppPolicies') - if (-not $AndroidPolicies) { + # Only skip when the type itself is absent (no Intune licence, or collection has not run). + if ($AllPolicies.Count -eq 0) { Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Devices' -Status 'Skipped' -ResultMarkdown 'No data found in database. This may be due to missing required licenses or data collection not yet completed.' -Risk 'High' -Name 'Data on Android is protected by app protection policies' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Tenant' return } + $AndroidPolicies = @($AllPolicies | Where-Object { $_.URLName -eq 'androidManagedAppProtection' }) + + # Data exists but no Android policy at all — that is a genuine failure of this control, not + # a missing-data skip. + if ($AndroidPolicies.Count -eq 0) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Devices' -Status 'Failed' -ResultMarkdown "❌ No Android app protection policy exists in this tenant.`n`nApp protection policies were found for other platforms, so Intune data is being collected — there is simply no Android policy." -Risk 'High' -Name 'Data on Android is protected by app protection policies' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Tenant' + return + } + $AssignedPolicies = @($AndroidPolicies | Where-Object { $_.assignments -and $_.assignments.Count -gt 0 }) $Passed = $AssignedPolicies.Count -gt 0 if ($Passed) { $ResultMarkdown = [System.Text.StringBuilder]::new("✅ At least one Android app protection policy exists and is assigned.`n`n") } else { - $ResultMarkdown = [System.Text.StringBuilder]::new("❌ No Android app protection policy exists or none are assigned.`n`n") + $ResultMarkdown = [System.Text.StringBuilder]::new("❌ Android app protection policies exist but none are assigned.`n`n") } $null = $ResultMarkdown.Append("## Android App Protection Policies`n`n") diff --git a/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21813.ps1 b/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21813.ps1 index 9f3f82f2e05ae..c43db9e83435a 100644 --- a/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21813.ps1 +++ b/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21813.ps1 @@ -24,11 +24,14 @@ function Invoke-CippTestZTNA21813 { $UserRoleMap = @{} foreach ($Role in $PrivilegedRoles) { + # 'roleTemplateId', not 'templateId' — the Roles cache has no templateId field at all + # (description, displayName, id, memberCount, members, roleTemplateId), so both filters + # below compared against $null and matched nothing. $ActiveAssignments = $RoleAssignmentScheduleInstances | Where-Object { - $_.roleDefinitionId -eq $Role.templateId -and $_.assignmentType -eq 'Assigned' + $_.roleDefinitionId -eq $Role.roleTemplateId -and $_.assignmentType -eq 'Assigned' } $EligibleAssignments = $RoleEligibilitySchedules | Where-Object { - $_.roleDefinitionId -eq $Role.templateId + $_.roleDefinitionId -eq $Role.roleTemplateId } $AllAssignments = @($ActiveAssignments) + @($EligibleAssignments) @@ -38,7 +41,9 @@ function Invoke-CippTestZTNA21813 { if (-not $User) { continue } $UserId = $User.id - $IsGARole = $Role.templateId -eq $GlobalAdminRoleId + # roleTemplateId — $Role.templateId does not exist, so this was always false and + # no user was ever classed as a Global Administrator. + $IsGARole = $Role.roleTemplateId -eq $GlobalAdminRoleId if ($IsGARole) { $AllGAUsers[$UserId] = $User diff --git a/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21816.ps1 b/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21816.ps1 index 3705a2cb776e5..23ac636141a5a 100644 --- a/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21816.ps1 +++ b/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21816.ps1 @@ -55,7 +55,9 @@ function Invoke-CippTestZTNA21816 { } foreach ($Role in $PrivilegedRoles) { - if ($Role.templateId -eq $GlobalAdminRoleId) { continue } + # roleTemplateId — $Role.templateId does not exist, so this guard never fired and the + # Global Administrator role was processed here despite being handled separately below. + if ($Role.roleTemplateId -eq $GlobalAdminRoleId) { continue } $RoleMembers = Get-CippDbRoleMembers -TenantFilter $Tenant -RoleTemplateId $Role.RoletemplateId diff --git a/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21818.ps1 b/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21818.ps1 index 1ebe2fdddbc7c..1830d7443462c 100644 --- a/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21818.ps1 +++ b/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21818.ps1 @@ -68,8 +68,10 @@ function Invoke-CippTestZTNA21818 { $ExitLoop = $false foreach ($Role in $PrivilegedRoles) { + # roleDefinitionId carries the role's TEMPLATE id — matching it against $Role.id (the + # directoryRole instance id) never succeeded. $Policy = $RoleManagementPolicies | Where-Object { - $_.scopeId -eq '/' -and $_.scopeType -eq 'DirectoryRole' -and $_.roleDefinitionId -eq $Role.id + $_.scopeId -eq '/' -and $_.scopeType -eq 'DirectoryRole' -and $_.roleDefinitionId -eq $Role.roleTemplateId } | Select-Object -First 1 if (-not $Policy) { continue } diff --git a/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21819.ps1 b/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21819.ps1 index af4ca97404ecc..30e4ff7adcfb9 100644 --- a/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21819.ps1 +++ b/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21819.ps1 @@ -17,11 +17,15 @@ function Invoke-CippTestZTNA21819 { return } - # Get role management policy for Global Admin + # Get role management policy for Global Admin. + # This previously matched on effectiveRules.target.targetObjects.id — a property that does + # not exist on this response (target is {caller, operations, level, inheritableSettings, + # enforcedSettings}), so the policy was never found. roleDefinitionId carries the role's + # TEMPLATE id, which is the correct join. $RoleManagementPolicies = Get-CIPPTestData -TenantFilter $Tenant -Type 'RoleManagementPolicies' $GlobalAdminPolicy = $RoleManagementPolicies | Where-Object { - $_.scopeId -eq '/' -and $_.scopeType -eq 'DirectoryRole' -and $_.effectiveRules.target.targetObjects.id -contains $GlobalAdminRole.id - } + $_.scopeId -eq '/' -and $_.scopeType -eq 'DirectoryRole' -and $_.roleDefinitionId -eq $GlobalAdminRole.roleTemplateId + } | Select-Object -First 1 $Passed = 'Failed' $IsDefaultRecipientsEnabled = 'N/A' diff --git a/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21820.ps1 b/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21820.ps1 index e4afd33d43926..ecb621ade66ca 100644 --- a/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21820.ps1 +++ b/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21820.ps1 @@ -19,15 +19,15 @@ function Invoke-CippTestZTNA21820 { # Get all role management policies $RoleManagementPolicies = Get-CIPPTestData -TenantFilter $Tenant -Type 'RoleManagementPolicies' - # Build hashtable for quick policy lookup by role ID + # Build hashtable for quick policy lookup by role template ID. + # This previously keyed on effectiveRules.target.targetObjects.id — a property that does + # not exist on this response (target is {caller, operations, level, inheritableSettings, + # enforcedSettings}), so the table was always empty and no policy was ever found. + # roleDefinitionId carries the role's TEMPLATE id, which is the correct key. $PolicyByRoleId = @{} foreach ($Policy in $RoleManagementPolicies) { - if ($Policy.scopeId -eq '/' -and $Policy.scopeType -eq 'DirectoryRole') { - foreach ($RoleId in $Policy.effectiveRules.target.targetObjects.id) { - if ($RoleId) { - $PolicyByRoleId[$RoleId] = $Policy - } - } + if ($Policy.scopeId -eq '/' -and $Policy.scopeType -eq 'DirectoryRole' -and $Policy.roleDefinitionId) { + $PolicyByRoleId[$Policy.roleDefinitionId] = $Policy } } @@ -35,7 +35,8 @@ function Invoke-CippTestZTNA21820 { $Passed = 'Passed' foreach ($Role in $PrivilegedRoles) { - $Policy = $PolicyByRoleId[$Role.id] + # Template id, not the directoryRole instance id — see the note above. + $Policy = $PolicyByRoleId[$Role.roleTemplateId] if (-not $Policy) { $RolesWithIssues.Add(@{ diff --git a/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21835.ps1 b/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21835.ps1 index 0fa5d363f3335..c5f53095d4fe4 100644 --- a/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21835.ps1 +++ b/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21835.ps1 @@ -17,9 +17,12 @@ function Invoke-CippTestZTNA21835 { return } - # Get permanent Global Administrator members + # Get permanent Global Administrator members. + # This previously filtered on AssignmentType -eq 'Permanent', a value Get-CippDbRoleMembers + # never emits (it returns Active/Eligible/Direct), so the set was always empty and the test + # silently reported no permanent GAs on every tenant. Permanence is IsPermanent. $PermanentGAMembers = Get-CippDbRoleMembers -TenantFilter $Tenant -RoleTemplateId '62e90394-69f5-4237-9190-012177145e10' | Where-Object { - $_.AssignmentType -eq 'Permanent' -and $_.'@odata.type' -eq '#microsoft.graph.user' + $_.IsPermanent -and $_.'@odata.type' -eq '#microsoft.graph.user' } # Get Users data to check sync status @@ -28,7 +31,8 @@ function Invoke-CippTestZTNA21835 { $EmergencyAccountCandidates = [System.Collections.Generic.List[object]]::new() foreach ($Member in $PermanentGAMembers) { - $User = $Users | Where-Object { $_.id -eq $Member.principalId } + # Get-CippDbRoleMembers returns the principal object id as 'id', not 'principalId'. + $User = $Users | Where-Object { $_.id -eq $Member.id } # Only process cloud-only accounts if ($User -and $User.onPremisesSyncEnabled -ne $true) { @@ -165,7 +169,9 @@ function Invoke-CippTestZTNA21835 { $UserSummary = [System.Collections.Generic.List[object]]::new() foreach ($Member in $PermanentGAMembers) { - $User = $Users | Where-Object { $_.id -eq $Member.principalId } + # 'id', not 'principalId' — see the note above; this lookup silently matched + # nothing and every row was skipped by the -not $User guard below. + $User = $Users | Where-Object { $_.id -eq $Member.id } if (-not $User) { continue } $PortalLink = "https://entra.microsoft.com/#view/Microsoft_AAD_UsersAndTenants/UserProfileMenuBlade/~/overview/userId/$($User.id)" diff --git a/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21836.ps1 b/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21836.ps1 index f7c37115662a8..aef4e53a6ce40 100644 --- a/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21836.ps1 +++ b/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21836.ps1 @@ -20,16 +20,21 @@ function Invoke-CippTestZTNA21836 { $WorkloadIdentitiesWithPrivilegedRoles = [System.Collections.Generic.List[object]]::new() foreach ($Role in $PrivilegedRoles) { - $RoleMembers = Get-CippDbRoleMembers -TenantFilter $Tenant -RoleTemplateId $Role.id + # Must be the role's TEMPLATE id: PIM's roleDefinitionId carries template ids, so + # passing $Role.id (the directoryRole instance id) matched nothing and this test found + # no workload identities on any tenant. + $RoleMembers = Get-CippDbRoleMembers -TenantFilter $Tenant -RoleTemplateId $Role.roleTemplateId foreach ($Member in $RoleMembers) { if ($Member.'@odata.type' -eq '#microsoft.graph.servicePrincipal') { + # Get-CippDbRoleMembers returns id/displayName/appId — not principalId or + # principalDisplayName, which rendered blank here. $WorkloadIdentitiesWithPrivilegedRoles.Add([PSCustomObject]@{ - PrincipalId = $Member.principalId - PrincipalDisplayName = $Member.principalDisplayName + PrincipalId = $Member.id + PrincipalDisplayName = $Member.displayName AppId = $Member.appId RoleDisplayName = $Role.displayName - RoleDefinitionId = $Role.id + RoleDefinitionId = $Role.roleTemplateId AssignmentType = $Member.AssignmentType }) } @@ -48,7 +53,7 @@ function Invoke-CippTestZTNA21836 { $SortedAssignments = $WorkloadIdentitiesWithPrivilegedRoles | Sort-Object -Property PrincipalDisplayName foreach ($Assignment in $SortedAssignments) { - $SPLink = "https://entra.microsoft.com/#view/Microsoft_AAD_IAM/ManagedAppMenuBlade/~/Overview/objectId/$($Assignment.PrincipalId)/appId/$($Assignment.AppId)/preferredSingleSignOnMode~/null/servicePrincipalType/Application/fromNav/" + $SPLink = "https://entra.microsoft.com/#view/Microsoft_AAD_IAM/ManagedAppMenuBlade/~/Overview/objectId/$($Assignment.PrincipalId)/appId/$($Assignment.AppId)" $null = $ResultMarkdown.Append("| [$($Assignment.PrincipalDisplayName)]($SPLink) | $($Assignment.RoleDisplayName) | $($Assignment.AssignmentType) |`n") } $null = $ResultMarkdown.Append("`n") diff --git a/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21899.ps1 b/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21899.ps1 index 0d94a2fb06c43..0a7b4c3c6a755 100644 --- a/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21899.ps1 +++ b/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21899.ps1 @@ -25,7 +25,9 @@ function Invoke-CippTestZTNA21899 { foreach ($R in $NotifRules) { if (-not $R.notificationRecipients -or $R.notificationRecipients.Count -eq 0) { $MissingRecipients.Add([PSCustomObject]@{ - PolicyId = $Policy.id + # 'policyId' — this type is sourced from roleManagementPolicyAssignments + # and carries the policy's id as policyId, not id. + PolicyId = $Policy.policyId ScopeId = $Policy.scopeId ScopeType = $Policy.scopeType RuleId = $R.id From 003e39f6b1c47e88ee7ec3fbf6b3c06e993cbfc0 Mon Sep 17 00:00:00 2001 From: Logan Cook <2997336+MWG-Logan@users.noreply.github.com> Date: Wed, 15 Jul 2026 12:03:02 -0400 Subject: [PATCH 12/16] fix: restore all name/displayName pairings in drift report Intune matching (#6347) The drift report failed to detect Intune configuration deviations because Get-CIPPDrift.ps1 compared template and tenant policy names using `.displayName -eq .displayName` alone. When both sides had no `.displayName` property (common for Graph policy types that only expose `.name`), this evaluated `$null -eq $null` as true, causing every such tenant policy to be incorrectly matched against the first Intune template in the loop and suppressing real drift. The fix restores all four original name/displayName pairings (displayName<->displayName, name<->name, displayName<->name, name<->displayName), with each pairing individually null-guarded so a match only counts when both sides have a real, non-null value. This is a strict superset of the prior matching capability - it only excludes the null-eq-null case that caused the false positive - so Settings Catalog and other name-only policy types deployed from a template continue to match correctly on name<->name. Adds Tests/Reports/Get-CIPPDrift.Tests.ps1, a new Pester suite (16 tests) covering: - the original null-eq-null false-match bug - each of the four name/displayName pairing combinations - the Settings Catalog name<->name regression path - multi-template alignment loops (matching a later template when earlier ones don't match) - Conditional Access template matching - standards deviation display-name/description resolution - stale drift-entity pruning Fixes KelvinTegelaar/CIPP#6347 --- Modules/CIPPCore/Public/Get-CIPPDrift.ps1 | 23 +- Tests/Reports/Get-CIPPDrift.Tests.ps1 | 568 ++++++++++++++++++++++ 2 files changed, 585 insertions(+), 6 deletions(-) create mode 100644 Tests/Reports/Get-CIPPDrift.Tests.ps1 diff --git a/Modules/CIPPCore/Public/Get-CIPPDrift.ps1 b/Modules/CIPPCore/Public/Get-CIPPDrift.ps1 index 0a5fc8b318b1a..f6bd718c2b00a 100644 --- a/Modules/CIPPCore/Public/Get-CIPPDrift.ps1 +++ b/Modules/CIPPCore/Public/Get-CIPPDrift.ps1 @@ -377,12 +377,23 @@ function Get-CIPPDrift { $tenantPolicy.policy | Add-Member -MemberType NoteProperty -Name 'URLName' -Value $TenantPolicy.Type -Force $TenantPolicyName = if ($TenantPolicy.Policy.displayName) { $TenantPolicy.Policy.displayName } else { $TenantPolicy.Policy.name } foreach ($TemplatePolicy in $TemplateIntuneTemplates) { - $TemplatePolicyName = if ($TemplatePolicy.displayName) { $TemplatePolicy.displayName } else { $TemplatePolicy.name } - - if ($TemplatePolicy.displayName -eq $TenantPolicy.Policy.displayName -or - $TemplatePolicy.name -eq $TenantPolicy.Policy.name -or - $TemplatePolicy.displayName -eq $TenantPolicy.Policy.name -or - $TemplatePolicy.name -eq $TenantPolicy.Policy.displayName) { + # Compare displayName-to-displayName and name-to-name (plus the cross pairings, + # since some policy types are captured under one property but deployed under the + # other) but require BOTH sides of each pairing to be non-empty before treating it + # as a match. Most Intune policy types (compliance policies, device configurations, + # group policy configs, etc.) only expose displayName and have no .name property at + # all, so comparing raw .name values directly (as before) compared $null -eq $null, + # which is $true in PowerShell - causing every tenant policy to falsely "match" the + # first template as soon as any template lacked a .name property, suppressing all + # tenant-only deviations. Note: templates always get a .displayName forced onto them + # (see Add-Member above) even for name-only policy types like Settings Catalog + # (deviceManagement/configurationPolicies), so the name-to-name pairing must still be + # compared directly from the raw properties - collapsing to a single "effective name + # preferring displayName" per side would silently break matching for those policies. + if (($TemplatePolicy.displayName -and $TenantPolicy.Policy.displayName -and $TemplatePolicy.displayName -eq $TenantPolicy.Policy.displayName) -or + ($TemplatePolicy.name -and $TenantPolicy.Policy.name -and $TemplatePolicy.name -eq $TenantPolicy.Policy.name) -or + ($TemplatePolicy.displayName -and $TenantPolicy.Policy.name -and $TemplatePolicy.displayName -eq $TenantPolicy.Policy.name) -or + ($TemplatePolicy.name -and $TenantPolicy.Policy.displayName -and $TemplatePolicy.name -eq $TenantPolicy.Policy.displayName)) { $PolicyFound = $true break } diff --git a/Tests/Reports/Get-CIPPDrift.Tests.ps1 b/Tests/Reports/Get-CIPPDrift.Tests.ps1 new file mode 100644 index 0000000000000..b69b241a59f33 --- /dev/null +++ b/Tests/Reports/Get-CIPPDrift.Tests.ps1 @@ -0,0 +1,568 @@ +# Pester tests for Get-CIPPDrift +# +# Covers: +# - The #6347 bug: raw .name comparison used to match $null -eq $null for policy types that have +# no .name property at all, silently suppressing tenant-only Intune drift detection. +# - A second regression discovered while validating the #6347 fix: collapsing template/tenant +# names to a single "effective name" (preferring displayName) breaks matching for Settings +# Catalog-style policies, whose templates always get a CIPP-forced .displayName but whose real +# Graph identity (and the only property tenant policies of that type actually have) is .name. +# - Conditional Access extra-policy matching (unaffected by either bug, used as a control). +# - Standards-deviation display name/description resolution (Intune/CA/ReusableSettings/Quarantine). +# - Stale tenantDrift row pruning, gated on whether the relevant Graph collection succeeded. +# +# Get-CIPPDrift talks to several CIPPCore/Az helpers; all are stubbed here and mocked per-scenario +# so the function under test can run standalone, following the convention in +# Tests/Alerts/Get-CIPPAlertIntunePolicyConflicts.Tests.ps1. + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $FunctionPath = Join-Path $RepoRoot 'Modules/CIPPCore/Public/Get-CIPPDrift.ps1' + + function Test-CIPPStandardLicense { param($StandardName, $TenantFilter, $Preset) } + function Get-CippTable { param($tablename) } + function Get-CIPPAzDataTableEntity { param($Filter, $TableName) } + function Get-CIPPTenantAlignment { param($TenantFilter, $TemplateId) } + function New-GraphBulkRequest { param($Requests, $tenantid, $asapp) } + function Add-CIPPAzDataTableEntity { param($Entity, [switch]$Force, $TableName) } + function Remove-AzDataTableEntity { param($Entity, $TableName) } + + # Builds an IntuneTemplate row the way the templates table stores it: JSON is a wrapper object + # (Displayname/Description/Type/RAWJson) where RAWJson is itself a JSON string of the captured + # policy body. RawPolicy is a hashtable/PSCustomObject for the *actual* Graph-shaped body (the + # thing that may or may not have .name / .displayName depending on policy type). + function New-IntuneTemplateRow { + param($RowKey, $DisplayName, $RawPolicy, $Description = 'desc', $Type = 'deviceCompliancePolicy', $Package) + $Wrapper = @{ + Displayname = $DisplayName + Description = $Description + Type = $Type + RAWJson = ($RawPolicy | ConvertTo-Json -Compress -Depth 10) + } + [pscustomobject]@{ + PartitionKey = 'IntuneTemplate' + RowKey = $RowKey + Package = $Package + JSON = ($Wrapper | ConvertTo-Json -Compress -Depth 10) + } + } + + function New-CATemplateRow { + param($RowKey, $Policy, $Package) + [pscustomobject]@{ + PartitionKey = 'CATemplate' + RowKey = $RowKey + Package = $Package + JSON = ($Policy | ConvertTo-Json -Compress -Depth 10) + } + } + + function New-DriftEntity { + param($StandardName, $Status = 'New', $Reason = $null, $User = $null) + [pscustomobject]@{ + StandardName = $StandardName + Status = $Status + Reason = $Reason + User = $User + } + } + + # NOTE: These two builders must live in this top-level BeforeAll (not directly inside a + # Describe block) because Pester v5 only re-executes BeforeAll/It bodies during the Run + # phase - plain `function` statements written directly in a Describe body only exist during + # the Discovery phase and are gone by the time Mock -MockWith scriptblocks actually invoke them. + function New-BaseAlignment { + param($TemplateGuids) + [pscustomobject]@{ + standardType = 'drift' + StandardName = 'Test Standard' + StandardId = 'sid-1' + AlignmentScore = 100 + CompliantStandards = 0 + ComparisonDetails = @() + LatestDataCollection = (Get-Date) + standardSettings = @{ + # Get-CIPPDrift iterates standardSettings.IntuneTemplate as an array and reads a + # single TemplateList.value per entry - it is one entry per selected template, not + # one entry with a comma-joined value. + IntuneTemplate = @($TemplateGuids | ForEach-Object { @{ TemplateList = @{ value = $_ } } }) + } + } + } + + function New-CABaseAlignment { + param($TemplateGuids) + [pscustomobject]@{ + standardType = 'drift' + StandardName = 'CA Standard' + StandardId = 'sid-2' + AlignmentScore = 100 + CompliantStandards = 0 + ComparisonDetails = @() + LatestDataCollection = (Get-Date) + standardSettings = @{ + ConditionalAccessTemplate = @($TemplateGuids | ForEach-Object { @{ TemplateList = @{ value = $_ } } }) + } + } + } + + . $FunctionPath +} + +Describe 'Get-CIPPDrift - Intune extra-policy matching (#6347 and Settings Catalog regression)' { + BeforeEach { + $script:IntuneCapable = $true + $script:ConditionalAccessCapable = $false + $script:IntuneTemplateRows = @() + $script:CATemplateRows = @() + $script:ReusableTemplateRows = @() + $script:DriftEntityRows = @() + $script:GraphResponses = @{} + $script:AddedDriftEntities = [System.Collections.Generic.List[object]]::new() + $script:RemovedDriftEntities = [System.Collections.Generic.List[object]]::new() + + Mock -CommandName Test-CIPPStandardLicense -MockWith { + param($StandardName, $TenantFilter, $Preset) + if ($Preset -eq 'Intune') { $script:IntuneCapable } else { $script:ConditionalAccessCapable } + } + Mock -CommandName Get-CippTable -MockWith { param($tablename) @{ TableName = $tablename } } + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { + param($Filter, $TableName) + switch -Wildcard ($Filter) { + "*PartitionKey eq 'IntuneTemplate'*" { return @($script:IntuneTemplateRows) } + "*PartitionKey eq 'CATemplate'*" { return @($script:CATemplateRows) } + "*PartitionKey eq 'IntuneReusableSettingTemplate'*" { return @($script:ReusableTemplateRows) } + default { return @($script:DriftEntityRows) } + } + } + Mock -CommandName New-GraphBulkRequest -MockWith { + param($Requests, $tenantid, $asapp) + foreach ($r in $Requests) { + [pscustomobject]@{ + id = $r.id + body = @{ value = @($script:GraphResponses[$r.id]) } + } + } + } + Mock -CommandName Add-CIPPAzDataTableEntity -MockWith { + param($Entity, [switch]$Force, $TableName) + foreach ($e in @($Entity)) { $script:AddedDriftEntities.Add($e) } + } + Mock -CommandName Remove-AzDataTableEntity -MockWith { + param($Entity, $TableName) + $script:RemovedDriftEntities.Add($Entity) + } + } + + It 'does NOT match on the original null-eq-null bug (template and tenant both lack .name)' { + # Both sides are of a displayName-only Graph type (e.g. compliance policy): the raw captured + # body has no .name property at all, so it is $null on both sides. Names genuinely differ. + $script:IntuneTemplateRows = @( + (New-IntuneTemplateRow -RowKey 'guid-1' -DisplayName 'Template A' -RawPolicy @{ id = 'tpl-1' }) + ) + $script:GraphResponses = @{ + 'deviceManagement/deviceCompliancePolicies' = @( + @{ id = 'tenant-1'; displayName = 'Totally Different Policy' } + ) + } + Mock -CommandName Get-CIPPTenantAlignment -MockWith { @(New-BaseAlignment -TemplateGuids @('guid-1')) } + + $Result = Get-CIPPDrift -TenantFilter 'contoso.onmicrosoft.com' + + $Result[0].currentDeviationsCount | Should -Be 1 + $Result[0].currentDeviations[0].standardName | Should -Be 'IntuneTemplates.tenant-1' + $Result[0].currentDeviations[0].standardDisplayName | Should -Be 'Intune - Totally Different Policy' + } + + It 'matches on displayName-to-displayName when both sides have equal, non-null displayName' { + $script:IntuneTemplateRows = @( + (New-IntuneTemplateRow -RowKey 'guid-1' -DisplayName 'Same Policy' -RawPolicy @{ id = 'tpl-1'; displayName = 'Same Policy' }) + ) + $script:GraphResponses = @{ + 'deviceManagement/deviceCompliancePolicies' = @( + @{ id = 'tenant-1'; displayName = 'Same Policy' } + ) + } + Mock -CommandName Get-CIPPTenantAlignment -MockWith { @(New-BaseAlignment -TemplateGuids @('guid-1')) } + + $Result = Get-CIPPDrift -TenantFilter 'contoso.onmicrosoft.com' + + $Result[0].currentDeviationsCount | Should -Be 0 + } + + It 'matches Settings Catalog policies on raw name-to-name even though the template also has a forced displayName' { + # Settings Catalog (deviceManagement/configurationPolicies) templates get a CIPP-friendly + # displayName forced onto them (Add-Member -Force) even though the underlying Graph object + # only ever has .name. The tenant-side policy, captured straight from Graph, has ONLY .name. + # A "collapsed effective name" comparison (prefer displayName if present) would compare the + # CIPP-friendly template name against the raw tenant .name and never match - that was the + # regression introduced by the first fix attempt for #6347. + $script:IntuneTemplateRows = @( + (New-IntuneTemplateRow -RowKey 'guid-1' -DisplayName 'My Settings Catalog Template' -Type 'configurationPolicy' -RawPolicy @{ id = 'tpl-1'; name = 'RawSettingsCatalogName123' }) + ) + $script:GraphResponses = @{ + 'deviceManagement/configurationPolicies' = @( + @{ id = 'tenant-1'; name = 'RawSettingsCatalogName123' } + ) + } + Mock -CommandName Get-CIPPTenantAlignment -MockWith { @(New-BaseAlignment -TemplateGuids @('guid-1')) } + + $Result = Get-CIPPDrift -TenantFilter 'contoso.onmicrosoft.com' + + $Result[0].currentDeviationsCount | Should -Be 0 + } + + It 'matches on the displayName-to-name cross pairing' { + $script:IntuneTemplateRows = @( + (New-IntuneTemplateRow -RowKey 'guid-1' -DisplayName 'Cross Match' -RawPolicy @{ id = 'tpl-1' }) + ) + $script:GraphResponses = @{ + 'deviceManagement/configurationPolicies' = @( + @{ id = 'tenant-1'; name = 'Cross Match' } + ) + } + Mock -CommandName Get-CIPPTenantAlignment -MockWith { @(New-BaseAlignment -TemplateGuids @('guid-1')) } + + $Result = Get-CIPPDrift -TenantFilter 'contoso.onmicrosoft.com' + + $Result[0].currentDeviationsCount | Should -Be 0 + } + + It 'matches on the name-to-displayName cross pairing' { + $script:IntuneTemplateRows = @( + (New-IntuneTemplateRow -RowKey 'guid-1' -DisplayName 'Some Friendly Name' -Type 'configurationPolicy' -RawPolicy @{ id = 'tpl-1'; name = 'Cross Match 2' }) + ) + $script:GraphResponses = @{ + 'deviceManagement/deviceCompliancePolicies' = @( + @{ id = 'tenant-1'; displayName = 'Cross Match 2' } + ) + } + Mock -CommandName Get-CIPPTenantAlignment -MockWith { @(New-BaseAlignment -TemplateGuids @('guid-1')) } + + $Result = Get-CIPPDrift -TenantFilter 'contoso.onmicrosoft.com' + + $Result[0].currentDeviationsCount | Should -Be 0 + } + + It 'reports a deviation when no pairing matches any template' { + $script:IntuneTemplateRows = @( + (New-IntuneTemplateRow -RowKey 'guid-1' -DisplayName 'Template One' -RawPolicy @{ id = 'tpl-1'; name = 'template-one-raw' }) + ) + $script:GraphResponses = @{ + 'deviceManagement/deviceCompliancePolicies' = @( + @{ id = 'tenant-1'; displayName = 'Unrelated Tenant Policy' } + ) + } + Mock -CommandName Get-CIPPTenantAlignment -MockWith { @(New-BaseAlignment -TemplateGuids @('guid-1')) } + + $Result = Get-CIPPDrift -TenantFilter 'contoso.onmicrosoft.com' + + $Result[0].currentDeviationsCount | Should -Be 1 + $Result[0].currentDeviations[0].expectedValue | Should -Match 'only exists in the tenant' + } + + It 'matches a later template when earlier templates in the loop do not match' { + $script:IntuneTemplateRows = @( + (New-IntuneTemplateRow -RowKey 'guid-1' -DisplayName 'Not It' -RawPolicy @{ id = 'tpl-1' }) + (New-IntuneTemplateRow -RowKey 'guid-2' -DisplayName 'This One' -RawPolicy @{ id = 'tpl-2' }) + ) + $script:GraphResponses = @{ + 'deviceManagement/deviceCompliancePolicies' = @( + @{ id = 'tenant-1'; displayName = 'This One' } + ) + } + Mock -CommandName Get-CIPPTenantAlignment -MockWith { @(New-BaseAlignment -TemplateGuids @('guid-1', 'guid-2')) } + + $Result = Get-CIPPDrift -TenantFilter 'contoso.onmicrosoft.com' + + $Result[0].currentDeviationsCount | Should -Be 0 + } + + It 'preserves an Accepted status across re-detection of the same extra policy' { + $script:IntuneTemplateRows = @( + (New-IntuneTemplateRow -RowKey 'guid-1' -DisplayName 'Template A' -RawPolicy @{ id = 'tpl-1' }) + ) + $script:GraphResponses = @{ + 'deviceManagement/deviceCompliancePolicies' = @( + @{ id = 'tenant-1'; displayName = 'Unrelated Tenant Policy' } + ) + } + $script:DriftEntityRows = @(New-DriftEntity -StandardName 'IntuneTemplates.tenant-1' -Status 'Accepted') + Mock -CommandName Get-CIPPTenantAlignment -MockWith { @(New-BaseAlignment -TemplateGuids @('guid-1')) } + + $Result = Get-CIPPDrift -TenantFilter 'contoso.onmicrosoft.com' + + $Result[0].currentDeviationsCount | Should -Be 0 + $Result[0].acceptedDeviationsCount | Should -Be 1 + $Result[0].acceptedDeviations[0].Status | Should -Be 'Accepted' + } +} + +Describe 'Get-CIPPDrift - Conditional Access extra-policy matching' { + BeforeEach { + $script:IntuneCapable = $false + $script:ConditionalAccessCapable = $true + $script:IntuneTemplateRows = @() + $script:CATemplateRows = @() + $script:ReusableTemplateRows = @() + $script:DriftEntityRows = @() + $script:GraphResponses = @{} + + Mock -CommandName Test-CIPPStandardLicense -MockWith { + param($StandardName, $TenantFilter, $Preset) + if ($Preset -eq 'Intune') { $script:IntuneCapable } else { $script:ConditionalAccessCapable } + } + Mock -CommandName Get-CippTable -MockWith { param($tablename) @{ TableName = $tablename } } + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { + param($Filter, $TableName) + switch -Wildcard ($Filter) { + "*PartitionKey eq 'IntuneTemplate'*" { return @($script:IntuneTemplateRows) } + "*PartitionKey eq 'CATemplate'*" { return @($script:CATemplateRows) } + "*PartitionKey eq 'IntuneReusableSettingTemplate'*" { return @($script:ReusableTemplateRows) } + default { return @($script:DriftEntityRows) } + } + } + Mock -CommandName New-GraphBulkRequest -MockWith { + param($Requests, $tenantid, $asapp) + foreach ($r in $Requests) { + [pscustomobject]@{ id = $r.id; body = @{ value = @($script:GraphResponses[$r.id]) } } + } + } + Mock -CommandName Add-CIPPAzDataTableEntity -MockWith { param($Entity, [switch]$Force, $TableName) } + Mock -CommandName Remove-AzDataTableEntity -MockWith { param($Entity, $TableName) } + } + + It 'does not report a deviation when displayName matches exactly' { + $script:CATemplateRows = @(New-CATemplateRow -RowKey 'ca-guid-1' -Policy @{ id = 'ca-tpl-1'; displayName = 'Block Legacy Auth' }) + $script:GraphResponses = @{ 'policies' = @(@{ id = 'ca-tenant-1'; displayName = 'Block Legacy Auth' }) } + Mock -CommandName Get-CIPPTenantAlignment -MockWith { @(New-CABaseAlignment -TemplateGuids @('ca-guid-1')) } + + $Result = Get-CIPPDrift -TenantFilter 'contoso.onmicrosoft.com' + + $Result[0].currentDeviationsCount | Should -Be 0 + } + + It 'reports a deviation with a Conditional Access label when displayName does not match any template' { + $script:CATemplateRows = @(New-CATemplateRow -RowKey 'ca-guid-1' -Policy @{ id = 'ca-tpl-1'; displayName = 'Block Legacy Auth' }) + $script:GraphResponses = @{ 'policies' = @(@{ id = 'ca-tenant-1'; displayName = 'Some Other CA Policy' }) } + Mock -CommandName Get-CIPPTenantAlignment -MockWith { @(New-CABaseAlignment -TemplateGuids @('ca-guid-1')) } + + $Result = Get-CIPPDrift -TenantFilter 'contoso.onmicrosoft.com' + + $Result[0].currentDeviationsCount | Should -Be 1 + $Result[0].currentDeviations[0].standardName | Should -Be 'ConditionalAccessTemplates.ca-tenant-1' + $Result[0].currentDeviations[0].standardDisplayName | Should -Be 'Conditional Access - Some Other CA Policy' + } +} + +Describe 'Get-CIPPDrift - standards deviation display name resolution' { + BeforeEach { + $script:IntuneCapable = $false + $script:ConditionalAccessCapable = $false + $script:IntuneTemplateRows = @() + $script:CATemplateRows = @() + $script:ReusableTemplateRows = @() + $script:DriftEntityRows = @() + $script:GraphResponses = @{} + + Mock -CommandName Test-CIPPStandardLicense -MockWith { $false } + Mock -CommandName Get-CippTable -MockWith { param($tablename) @{ TableName = $tablename } } + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { + param($Filter, $TableName) + switch -Wildcard ($Filter) { + "*PartitionKey eq 'IntuneTemplate'*" { return @($script:IntuneTemplateRows) } + "*PartitionKey eq 'CATemplate'*" { return @($script:CATemplateRows) } + "*PartitionKey eq 'IntuneReusableSettingTemplate'*" { return @($script:ReusableTemplateRows) } + default { return @($script:DriftEntityRows) } + } + } + Mock -CommandName New-GraphBulkRequest -MockWith { @() } + Mock -CommandName Add-CIPPAzDataTableEntity -MockWith { param($Entity, [switch]$Force, $TableName) } + Mock -CommandName Remove-AzDataTableEntity -MockWith { param($Entity, $TableName) } + } + + It 'resolves the Intune template display name and description for standards.IntuneTemplate deviations' { + $script:IntuneTemplateRows = @(New-IntuneTemplateRow -RowKey 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' -DisplayName 'Compliance Baseline' -Description 'Baseline description' -RawPolicy @{}) + Mock -CommandName Get-CIPPTenantAlignment -MockWith { + @([pscustomobject]@{ + standardType = 'drift' + StandardName = 'Standard' + StandardId = 'sid-3' + AlignmentScore = 90 + CompliantStandards = 1 + LatestDataCollection = (Get-Date) + ComparisonDetails = @( + [pscustomobject]@{ + StandardName = 'standards.IntuneTemplate.aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee.somesetting' + Compliant = $false + StandardValue = $true + ComplianceStatus = 'Non-Compliant' + } + ) + }) + } + + $Result = Get-CIPPDrift -TenantFilter 'contoso.onmicrosoft.com' + + $Deviation = $Result[0].currentDeviations[0] + $Deviation.standardDisplayName | Should -Be 'Compliance Baseline' + $Deviation.standardDescription | Should -Be 'Baseline description' + } + + It 'decodes the hex-encoded name for standards.QuarantineTemplate deviations' { + $HexName = -join ([byte[]][System.Text.Encoding]::UTF8.GetBytes('Bad Policy') | ForEach-Object { $_.ToString('x2') }) + Mock -CommandName Get-CIPPTenantAlignment -MockWith { + @([pscustomobject]@{ + standardType = 'drift' + StandardName = 'Standard' + StandardId = 'sid-4' + AlignmentScore = 90 + CompliantStandards = 1 + LatestDataCollection = (Get-Date) + ComparisonDetails = @( + [pscustomobject]@{ + StandardName = "standards.QuarantineTemplate.$HexName" + Compliant = $false + StandardValue = $true + ComplianceStatus = 'Non-Compliant' + } + ) + }) + } + + $Result = Get-CIPPDrift -TenantFilter 'contoso.onmicrosoft.com' + + $Result[0].currentDeviations[0].standardDisplayName | Should -Be 'Quarantine Policy: Bad Policy' + } + + It 'separates License Missing deviations into their own bucket, not currentDeviations' { + Mock -CommandName Get-CIPPTenantAlignment -MockWith { + @([pscustomobject]@{ + standardType = 'drift' + StandardName = 'Standard' + StandardId = 'sid-5' + AlignmentScore = 90 + CompliantStandards = 1 + LatestDataCollection = (Get-Date) + ComparisonDetails = @( + [pscustomobject]@{ + StandardName = 'standards.SomeStandard' + Compliant = $false + StandardValue = $true + ComplianceStatus = 'License Missing' + } + ) + }) + } + + $Result = Get-CIPPDrift -TenantFilter 'contoso.onmicrosoft.com' + + $Result[0].currentDeviationsCount | Should -Be 0 + $Result[0].licenseMissingDeviationsCount | Should -Be 1 + } +} + +Describe 'Get-CIPPDrift - stale drift entity pruning' { + BeforeEach { + $script:IntuneCapable = $false + $script:ConditionalAccessCapable = $false + $script:IntuneTemplateRows = @() + $script:CATemplateRows = @() + $script:ReusableTemplateRows = @() + $script:GraphResponses = @{} + $script:RemovedDriftEntities = [System.Collections.Generic.List[object]]::new() + + Mock -CommandName Test-CIPPStandardLicense -MockWith { $false } + Mock -CommandName Get-CippTable -MockWith { param($tablename) @{ TableName = $tablename } } + Mock -CommandName New-GraphBulkRequest -MockWith { @() } + Mock -CommandName Add-CIPPAzDataTableEntity -MockWith { param($Entity, [switch]$Force, $TableName) } + Mock -CommandName Remove-AzDataTableEntity -MockWith { + param($Entity, $TableName) + $script:RemovedDriftEntities.Add($Entity) + } + Mock -CommandName Get-CIPPTenantAlignment -MockWith { + @([pscustomobject]@{ + standardType = 'drift' + StandardName = 'Standard' + StandardId = 'sid-6' + AlignmentScore = 100 + CompliantStandards = 0 + LatestDataCollection = (Get-Date) + ComparisonDetails = @() + }) + } + } + + It 'removes a stale plain standards drift row that no longer appears in the alignment' { + $script:DriftEntityRows = @(New-DriftEntity -StandardName 'standards.LongGoneStandard') + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { + param($Filter, $TableName) + switch -Wildcard ($Filter) { + "*PartitionKey eq 'IntuneTemplate'*" { return @() } + "*PartitionKey eq 'CATemplate'*" { return @() } + "*PartitionKey eq 'IntuneReusableSettingTemplate'*" { return @() } + default { return @($script:DriftEntityRows) } + } + } + + Get-CIPPDrift -TenantFilter 'contoso.onmicrosoft.com' | Out-Null + + $script:RemovedDriftEntities.Count | Should -Be 1 + $script:RemovedDriftEntities[0].StandardName | Should -Be 'standards.LongGoneStandard' + } + + It 'does not remove a stale IntuneTemplates row when the Intune policy collection did not run' { + # IntuneCapable is $false in this Describe block, so IntunePoliciesCollected never becomes + # $true; a stale IntuneTemplates.* row must be protected from deletion in that case, since we + # cannot prove the tenant policy is actually gone without a successful Graph collection. + $script:DriftEntityRows = @(New-DriftEntity -StandardName 'IntuneTemplates.some-tenant-policy-id') + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { + param($Filter, $TableName) + switch -Wildcard ($Filter) { + "*PartitionKey eq 'IntuneTemplate'*" { return @() } + "*PartitionKey eq 'CATemplate'*" { return @() } + "*PartitionKey eq 'IntuneReusableSettingTemplate'*" { return @() } + default { return @($script:DriftEntityRows) } + } + } + + Get-CIPPDrift -TenantFilter 'contoso.onmicrosoft.com' | Out-Null + + $script:RemovedDriftEntities.Count | Should -Be 0 + } + + It 'does not remove a drift row that is still referenced by the current alignment' { + $script:DriftEntityRows = @(New-DriftEntity -StandardName 'standards.StillRelevant') + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { + param($Filter, $TableName) + switch -Wildcard ($Filter) { + "*PartitionKey eq 'IntuneTemplate'*" { return @() } + "*PartitionKey eq 'CATemplate'*" { return @() } + "*PartitionKey eq 'IntuneReusableSettingTemplate'*" { return @() } + default { return @($script:DriftEntityRows) } + } + } + Mock -CommandName Get-CIPPTenantAlignment -MockWith { + @([pscustomobject]@{ + standardType = 'drift' + StandardName = 'Standard' + StandardId = 'sid-7' + AlignmentScore = 90 + CompliantStandards = 1 + LatestDataCollection = (Get-Date) + ComparisonDetails = @( + [pscustomobject]@{ + StandardName = 'standards.StillRelevant' + Compliant = $true + StandardValue = $true + ComplianceStatus = 'Compliant' + } + ) + }) + } + + Get-CIPPDrift -TenantFilter 'contoso.onmicrosoft.com' | Out-Null + + $script:RemovedDriftEntities.Count | Should -Be 0 + } +} From e247438fc29f2ac70d98f569189b3a57ba5a1dbf Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Thu, 16 Jul 2026 14:39:37 +0800 Subject: [PATCH 13/16] feat(config): add copilot policy permissions Add CopilotPolicySettings.ReadWrite to the permissions translator for both Delegated and Application origins, and include the corresponding scope/role IDs in SAMManifest requiredResourceAccess. This updates the SAM app permission set so Copilot policy settings can be managed in both signed-in and app-only flows. --- Config/PermissionsTranslator.json | 18 ++++++++++++++++++ Config/SAMManifest.json | 8 ++++++++ 2 files changed, 26 insertions(+) diff --git a/Config/PermissionsTranslator.json b/Config/PermissionsTranslator.json index 52f29c1194335..c7163822eb098 100644 --- a/Config/PermissionsTranslator.json +++ b/Config/PermissionsTranslator.json @@ -5371,5 +5371,23 @@ "userConsentDescription": "Allows the app to read your tenant's acquired telephone number details, without a signed-in user. Acquired telephone numbers may include attributes related to assigned object, emergency location, network site, etc.", "userConsentDisplayName": "Allows the app to read your tenant's acquired telephone number details, without a signed-in user. Acquired telephone numbers may include attributes related to assigned object, emergency location, network site, etc.", "value": "TeamsTelephoneNumber.ReadWrite.All" + }, + { + "description": "Allows the app to read and write Copilot policy settings for the organization, on behalf of the signed-in user.", + "displayName": "Read and write Copilot policy settings", + "id": "e2edbde8-4448-4e49-8ebb-d53ba72df0f3", + "Origin": "Delegated", + "userConsentDescription": "Allows the app to read and write Copilot policy settings for the organization, on your behalf.", + "userConsentDisplayName": "Read and write Copilot policy settings", + "value": "CopilotPolicySettings.ReadWrite" + }, + { + "description": "Allows the app to read and write Copilot policy settings for the organization, without a signed-in user.", + "displayName": "Read and write Copilot policy settings", + "id": "cc147c17-b8e8-4d3f-9f94-aa9e279a079a", + "Origin": "Application", + "userConsentDescription": "Allows the app to read and write Copilot policy settings for the organization, without a signed-in user.", + "userConsentDisplayName": "Read and write Copilot policy settings", + "value": "CopilotPolicySettings.ReadWrite" } ] diff --git a/Config/SAMManifest.json b/Config/SAMManifest.json index 1f7dd716f19c2..56fc2811de489 100644 --- a/Config/SAMManifest.json +++ b/Config/SAMManifest.json @@ -610,6 +610,14 @@ { "id": "1ff1be21-34eb-448c-9ac9-ce1f506b2a68", "type": "Scope" + }, + { + "id": "cc147c17-b8e8-4d3f-9f94-aa9e279a079a", + "type": "Role" + }, + { + "id": "e2edbde8-4448-4e49-8ebb-d53ba72df0f3", + "type": "Scope" } ] }, From 8cde47ba55c8623074e2c68c306658a82b4692f3 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Thu, 16 Jul 2026 15:43:27 +0800 Subject: [PATCH 14/16] Skip SharePointSharingLinks in DB cache run Remove the scheduled SharePointSharingLinks cache task from Push-CIPPDBCacheData and leave it as an ad-hoc-only operation, since full sharing-link enumeration can run too long on large tenants. --- .../Activity Triggers/Push-CIPPDBCacheData.ps1 | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-CIPPDBCacheData.ps1 b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-CIPPDBCacheData.ps1 index f0eac8d4d0020..a0bb9f7693082 100644 --- a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-CIPPDBCacheData.ps1 +++ b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-CIPPDBCacheData.ps1 @@ -198,7 +198,7 @@ function Push-CIPPDBCacheData { } else { Write-Host "Skipping Compliance data collection for $TenantFilter - no required license" } - + if ($DefenderCapable) { $Tasks.Add(@{ FunctionName = 'ExecCIPPDBCache' @@ -219,15 +219,7 @@ function Push-CIPPDBCacheData { QueueId = $QueueId QueueName = "DB Cache SharePoint - $TenantFilter" }) - # SharePointSharingLinks runs as its own activity — it's heavy (scans every drive) and - # spawns a child orchestrator (one activity per site) that needs its own time budget. - $Tasks.Add(@{ - FunctionName = 'ExecCIPPDBCache' - Name = 'SharePointSharingLinks' - TenantFilter = $TenantFilter - QueueId = $QueueId - QueueName = "DB Cache SharePointSharingLinks - $TenantFilter" - }) + # SharePointSharingLinks runs adhoc since it can take a long time to enumerate all sharing links for large tenants } else { Write-Host "Skipping SharePoint data collection for $TenantFilter - no required license" } From 01242ff5724c9d3766d604b99d59feaa10b20e47 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Thu, 16 Jul 2026 16:44:00 +0800 Subject: [PATCH 15/16] fix(user): route group updates by effective type Refactor Set-CIPPUser to use a shared helper that decides whether group membership changes should use Exchange cmdlets based on `addedFields.calculatedGroupType`, with fallback to legacy `groupType` values. This fixes inconsistent add/remove behavior caused by the previous inline condition and variable-case mismatch, ensuring distribution lists and mail-enabled security groups are handled consistently. --- Modules/CIPPCore/Public/Set-CIPPUser.ps1 | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/Modules/CIPPCore/Public/Set-CIPPUser.ps1 b/Modules/CIPPCore/Public/Set-CIPPUser.ps1 index 691801cb6706e..f3f317103eba0 100644 --- a/Modules/CIPPCore/Public/Set-CIPPUser.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPUser.ps1 @@ -12,6 +12,15 @@ function Set-CIPPUser { $AddToGroups = $UserObj.AddToGroups $RemoveFromGroups = $UserObj.RemoveFromGroups + $UseExchangeForGroup = { + param($AddedFields) + $Calculated = $AddedFields.calculatedGroupType + if ($Calculated) { + return $Calculated -in @('distributionList', 'security') + } + return $AddedFields.groupType -in @('Distribution List', 'Mail-Enabled Security', 'distributionList') + } + #Edit the user try { @@ -189,13 +198,12 @@ function Set-CIPPUser { $AddToGroups | ForEach-Object { $GroupType = $_.addedFields.groupType - $CalculatedGroupType = $_.addedFields.calculatedGroupType ?? $null $GroupID = $_.value $GroupName = $_.label Write-Host "About to add $($UserObj.userPrincipalName) to $GroupName. Group ID is: $GroupID and type is: $GroupType" try { - if ($GroupType -eq 'distributionList' -or $GroupType -eq 'security' -and ($calculatedGroupType -ne 'generic' )) { + if (& $UseExchangeForGroup $_.addedFields) { Write-Host 'Adding to group via Add-DistributionGroupMember' $Params = @{ Identity = $GroupID; Member = $UserObj.id; BypassSecurityGroupManagerCheck = $true } $null = New-ExoRequest -tenantid $UserObj.tenantFilter -cmdlet 'Add-DistributionGroupMember' -cmdParams $params -UseSystemMailbox $true @@ -222,13 +230,12 @@ function Set-CIPPUser { $RemoveFromGroups | ForEach-Object { $GroupType = $_.addedFields.groupType - $CalculatedGroupType = $_.addedFields.calculatedGroupType ?? $null $GroupID = $_.value $GroupName = $_.label Write-Host "About to remove $($UserObj.userPrincipalName) from $GroupName. Group ID is: $GroupID and type is: $GroupType" try { - if ($GroupType -eq 'distributionList' -or $GroupType -eq 'security' -and ($calculatedGroupType -ne 'generic' )) { + if (& $UseExchangeForGroup $_.addedFields) { Write-Host 'Removing From group via Remove-DistributionGroupMember' $Params = @{ Identity = $GroupID; Member = $UserObj.id; BypassSecurityGroupManagerCheck = $true } $null = New-ExoRequest -tenantid $UserObj.tenantFilter -cmdlet 'Remove-DistributionGroupMember' -cmdParams $params -UseSystemMailbox $true From bc7ace6dadca332e62faa907b46f4af36ac5ec3d Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Thu, 16 Jul 2026 11:03:02 +0200 Subject: [PATCH 16/16] Update version_latest.txt Signed-off-by: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> --- version_latest.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/version_latest.txt b/version_latest.txt index 6842906c6fc24..15c6f3e7f724a 100644 --- a/version_latest.txt +++ b/version_latest.txt @@ -1 +1 @@ -10.6.2 +10.6.3