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 3b304150e7cf8..56fc2811de489 100644
--- a/Config/SAMManifest.json
+++ b/Config/SAMManifest.json
@@ -582,6 +582,42 @@
{
"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"
+ },
+ {
+ "id": "cc147c17-b8e8-4d3f-9f94-aa9e279a079a",
+ "type": "Role"
+ },
+ {
+ "id": "e2edbde8-4448-4e49-8ebb-d53ba72df0f3",
+ "type": "Scope"
}
]
},
@@ -689,4 +725,4 @@
"isEnabled": true,
"allProperties": true
}
-}
\ No newline at end of file
+}
diff --git a/Config/standards.json b/Config/standards.json
index 5a8d0a83eba9f..52630842cdce3 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",
@@ -5070,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. ",
@@ -6530,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": [
@@ -8043,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",
@@ -8070,7 +8121,7 @@
"label": "On-Premises Mode",
"options": [
{ "label": "Audit", "value": "Audit" },
- { "label": "Enforced", "value": "Enforced" }
+ { "label": "Enforce", "value": "Enforce" }
]
}
],
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"
}
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/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/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/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/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
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
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/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
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
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') {
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/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'
}
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'
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
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/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