From 0a43c2d57217d803bb9665a15abcf58eb1361af1 Mon Sep 17 00:00:00 2001 From: James Tarran Date: Wed, 25 Feb 2026 08:43:19 +0000 Subject: [PATCH 01/28] Replace New-CippDbRequest with New-ExoRequest Replace the New-CippDbRequest call with New-ExoRequest to retrieve the transport configuration. --- .../Standards/Invoke-CIPPStandardDisableBasicAuthSMTP.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardDisableBasicAuthSMTP.ps1 b/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardDisableBasicAuthSMTP.ps1 index 4e2146a003d4..474cfbead706 100644 --- a/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardDisableBasicAuthSMTP.ps1 +++ b/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardDisableBasicAuthSMTP.ps1 @@ -42,7 +42,7 @@ function Invoke-CIPPStandardDisableBasicAuthSMTP { ##$Rerun -Type Standard -Tenant $Tenant -Settings $Settings 'DisableBasicAuthSMTP' try { - $CurrentInfo = New-CippDbRequest -TenantFilter $Tenant -Type 'Get-TransportConfig' + $CurrentInfo = New-ExoRequest -tenantid $Tenant -cmdlet 'Get-TransportConfig' $SMTPusers = New-CippDbRequest -TenantFilter $Tenant -Type 'CASMailbox' | Where-Object { ($_.SmtpClientAuthenticationDisabled -eq $false) } } catch { $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message From b87756697483e73eb1c2ee175840cf3b02ccb0ec Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Wed, 25 Feb 2026 17:20:25 +0800 Subject: [PATCH 02/28] Fix: Update GDAP relationship check to use 15-role recommended group set Update GDAP relationship check to use 15-role recommended group set FIxes: https://github.com/KelvinTegelaar/CIPP/issues/5457 --- Modules/CIPPCore/Public/Test-CIPPGDAPRelationships.ps1 | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/Modules/CIPPCore/Public/Test-CIPPGDAPRelationships.ps1 b/Modules/CIPPCore/Public/Test-CIPPGDAPRelationships.ps1 index 44defc8c5b28..a9a9cf745444 100644 --- a/Modules/CIPPCore/Public/Test-CIPPGDAPRelationships.ps1 +++ b/Modules/CIPPCore/Public/Test-CIPPGDAPRelationships.ps1 @@ -53,7 +53,10 @@ function Test-CIPPGDAPRelationships { 'M365 GDAP SharePoint Administrator', 'M365 GDAP Authentication Policy Administrator', 'M365 GDAP Privileged Role Administrator', - 'M365 GDAP Privileged Authentication Administrator' + 'M365 GDAP Privileged Authentication Administrator', + 'M365 GDAP Billing Administrator', + 'M365 GDAP Global Reader', + 'M365 GDAP Domain Name Administrator' ) $RoleAssignableGroups = $SAMUserMemberships | Where-Object { $_.isAssignableToRole } $NestedGroups = [System.Collections.Generic.List[object]]::new() @@ -85,10 +88,10 @@ function Test-CIPPGDAPRelationships { }) | Out-Null } } - if ($CIPPGroupCount -lt 12) { + if ($CIPPGroupCount -lt 15) { $GDAPissues.add([PSCustomObject]@{ Type = 'Warning' - Issue = "We only found $($CIPPGroupCount) of the 12 required groups. If you have migrated outside of CIPP this is to be expected. Please perform an access check to make sure you have the correct set of permissions." + Issue = "We only found $($CIPPGroupCount) of the 15 required groups. If you have migrated outside of CIPP this is to be expected. Please perform an access check to make sure you have the correct set of permissions." Tenant = '*Partner Tenant' Relationship = 'None' Link = 'https://docs.cipp.app/setup/gdap/troubleshooting#groups' From b51a860e43274a4d8bb3456cd8ba486585c31766 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Wed, 25 Feb 2026 23:44:21 +0800 Subject: [PATCH 03/28] Normalize licenses and batch user lookups Pass UserPrincipalName to license operations and normalize license inputs. Invoke-EditUser now includes UserPrincipalName when calling Set-CIPPUserLicense. Invoke-ExecBulkLicense normalizes userId values to strings, chunks user ID OData filters to avoid Graph OR limits, issues a bulk Graph lookup request, aggregates chunk results, and handles missing users and lookup errors. Set-CIPPUserLicense coerces AddLicenses/RemoveLicenses to string arrays, filters out empty values, defaults UserPrincipalName to UserId when missing, and sends clean arrays in assignLicense payloads to prevent null/nested skuId issues. --- .../Administration/Users/Invoke-EditUser.ps1 | 11 ++-- .../Users/Invoke-ExecBulkLicense.ps1 | 63 ++++++++++++++++--- .../CIPPCore/Public/Set-CIPPUserLicense.ps1 | 27 ++++++-- 3 files changed, 83 insertions(+), 18 deletions(-) diff --git a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-EditUser.ps1 b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-EditUser.ps1 index 2ffcc5041778..b3e91b0b4bc1 100644 --- a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-EditUser.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-EditUser.ps1 @@ -104,9 +104,10 @@ function Invoke-EditUser { value = 'Set-CIPPUserLicense' } Parameters = [pscustomobject]@{ - UserId = $UserObj.id - APIName = 'Sherweb License Assignment' - AddLicenses = $licenses + UserId = $UserObj.id + APIName = 'Sherweb License Assignment' + AddLicenses = $licenses + UserPrincipalName = $UserPrincipalName } ScheduledTime = 0 #right now, which is in the next 15 minutes and should cover most cases. PostExecution = @{ @@ -124,12 +125,12 @@ function Invoke-EditUser { $Results.Add( 'Success. User license is already correct.' ) } else { if ($UserObj.removeLicenses) { - $licResults = Set-CIPPUserLicense -UserId $UserObj.id -TenantFilter $UserObj.tenantFilter -RemoveLicenses $CurrentLicenses.assignedLicenses.skuId -Headers $Headers -APIName $APIName + $licResults = Set-CIPPUserLicense -UserPrincipalName $UserPrincipalName -UserId $UserObj.id -TenantFilter $UserObj.tenantFilter -RemoveLicenses $CurrentLicenses.assignedLicenses.skuId -Headers $Headers -APIName $APIName $Results.Add($licResults) } else { #Remove all objects from $CurrentLicenses.assignedLicenses.skuId that are in $licenses $RemoveLicenses = $CurrentLicenses.assignedLicenses.skuId | Where-Object { $_ -notin $licenses } - $licResults = Set-CIPPUserLicense -UserId $UserObj.id -TenantFilter $UserObj.tenantFilter -RemoveLicenses $RemoveLicenses -AddLicenses $licenses -Headers $Headers -APIName $APIName + $licResults = Set-CIPPUserLicense -UserPrincipalName $UserPrincipalName -UserId $UserObj.id -TenantFilter $UserObj.tenantFilter -RemoveLicenses $RemoveLicenses -AddLicenses $licenses -Headers $Headers -APIName $APIName $Results.Add($licResults) } diff --git a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ExecBulkLicense.ps1 b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ExecBulkLicense.ps1 index 58c4446912ba..5b284b29e1f9 100644 --- a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ExecBulkLicense.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ExecBulkLicense.ps1 @@ -27,15 +27,53 @@ function Invoke-ExecBulkLicense { # Initialize list for bulk license requests $LicenseRequests = [System.Collections.Generic.List[object]]::new() - # Get unique user IDs for this tenant - $UserIds = $TenantRequests.userIds | Select-Object -Unique + # Get unique user IDs for this tenant and normalize to a string array + $UserIds = @( + $TenantRequests | + ForEach-Object { + if ($null -ne $_.userIds) { + @($_.userIds) | ForEach-Object { [string]$_ } + } + } | + Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | + Select-Object -Unique + ) + + # Build OData filters in chunks to avoid Graph's OR clause limit + $MaxUserIdFilterClauses = 15 + $UserLookupRequests = [System.Collections.Generic.List[object]]::new() + $AllUsers = [System.Collections.Generic.List[object]]::new() - # Build OData filter for specific users only - $UserIdFilters = $UserIds | ForEach-Object { "id eq '$_'" } - $FilterQuery = $UserIdFilters -join ' or ' + for ($i = 0; $i -lt $UserIds.Count; $i += $MaxUserIdFilterClauses) { + $EndIndex = [Math]::Min($i + $MaxUserIdFilterClauses - 1, $UserIds.Count - 1) + $UserIdChunk = @($UserIds[$i..$EndIndex]) + $UserIdFilters = $UserIdChunk | ForEach-Object { "id eq '$_'" } + $FilterQuery = $UserIdFilters -join ' or ' - # Fetch only the users we need with server-side filtering - $AllUsers = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/users?`$filter=$FilterQuery&`$select=id,userPrincipalName,assignedLicenses&top=999" -tenantid $TenantFilter + $UserLookupRequests.Add(@{ + id = "UserLookup$i" + method = 'GET' + url = "/users?`$filter=$FilterQuery&`$select=id,userPrincipalName,assignedLicenses&`$top=999" + }) + } + + # Fetch all user chunks in one Graph bulk request + try { + $UserLookupResults = New-GraphBulkRequest -tenantid $TenantFilter -Requests @($UserLookupRequests) + } catch { + $LookupError = Get-CippException -Exception $_ + throw "Failed to lookup users before license assignment for tenant $TenantFilter. Error: $($LookupError.NormalizedError)" + } + foreach ($UserLookupResult in $UserLookupResults) { + if ($UserLookupResult.status -lt 200 -or $UserLookupResult.status -gt 299) { + $LookupErrorMessage = $UserLookupResult.body.error.message + if ([string]::IsNullOrEmpty($LookupErrorMessage)) { $LookupErrorMessage = 'Unknown Graph batch error' } + throw "Failed to fetch users for chunk $($UserLookupResult.id): $LookupErrorMessage" + } + foreach ($ChunkUser in @($UserLookupResult.body.value)) { + $AllUsers.Add($ChunkUser) + } + } # Create lookup for quick access $UserLookup = @{} @@ -45,8 +83,17 @@ function Invoke-ExecBulkLicense { # Process each user request foreach ($UserRequest in $TenantRequests) { - $UserId = $UserRequest.userIds + $UserId = @($UserRequest.userIds | ForEach-Object { [string]$_ } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -First 1) + if ($UserId.Count -eq 0) { + $Results.Add("No valid user ID found in request for tenant $TenantFilter") + continue + } + $UserId = $UserId[0] $User = $UserLookup[$UserId] + if ($null -eq $User) { + $Results.Add("User $UserId not found in tenant $TenantFilter") + continue + } $UserPrincipalName = $User.userPrincipalName $LicenseOperation = $UserRequest.LicenseOperation $RemoveAllLicenses = [bool]$UserRequest.RemoveAllLicenses diff --git a/Modules/CIPPCore/Public/Set-CIPPUserLicense.ps1 b/Modules/CIPPCore/Public/Set-CIPPUserLicense.ps1 index 587728681c63..eff802655a91 100644 --- a/Modules/CIPPCore/Public/Set-CIPPUserLicense.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPUserLicense.ps1 @@ -17,8 +17,8 @@ function Set-CIPPUserLicense { $LicenseRequests.Add([PSCustomObject]@{ UserId = $UserId UserPrincipalName = $UserPrincipalName - AddLicenses = $AddLicenses - RemoveLicenses = $RemoveLicenses + AddLicenses = @($AddLicenses) + RemoveLicenses = @($RemoveLicenses) IsReplace = $false }) } @@ -31,6 +31,23 @@ function Set-CIPPUserLicense { if ($UserSettings) { $DefaultUsageLocation = (ConvertFrom-Json $UserSettings.JSON -Depth 5 -ErrorAction SilentlyContinue).usageLocation.value } $DefaultUsageLocation ??= 'US' + # Normalize license arrays to avoid sending null skuIds to Graph + foreach ($Request in $LicenseRequests) { + $Request.AddLicenses = @( + @($Request.AddLicenses) | + ForEach-Object { [string]$_ } | + Where-Object { -not [string]::IsNullOrWhiteSpace($_) } + ) + $Request.RemoveLicenses = @( + @($Request.RemoveLicenses) | + ForEach-Object { [string]$_ } | + Where-Object { -not [string]::IsNullOrWhiteSpace($_) } + ) + if ([string]::IsNullOrWhiteSpace($Request.UserPrincipalName)) { + $Request.UserPrincipalName = $Request.UserId + } + } + # Process Replace operations first (remove all licenses) $ReplaceRequests = $LicenseRequests | Where-Object { $_.IsReplace -and $_.RemoveLicenses.Count -gt 0 } if ($ReplaceRequests.Count -gt 0) { @@ -41,7 +58,7 @@ function Set-CIPPUserLicense { url = "/users/$($Request.UserId)/assignLicense" body = @{ 'addLicenses' = @() - 'removeLicenses' = @($Request.RemoveLicenses) + 'removeLicenses' = $Request.RemoveLicenses } headers = @{ 'Content-Type' = 'application/json' } } @@ -72,7 +89,7 @@ function Set-CIPPUserLicense { url = "/users/$($Request.UserId)/assignLicense" body = @{ 'addLicenses' = @($AddLicensesArray) - 'removeLicenses' = $Request.IsReplace ? @() : @($Request.RemoveLicenses) + 'removeLicenses' = $Request.IsReplace ? @() : $Request.RemoveLicenses } headers = @{ 'Content-Type' = 'application/json' } } @@ -133,7 +150,7 @@ function Set-CIPPUserLicense { url = "/users/$($Request.UserId)/assignLicense" body = @{ 'addLicenses' = @($AddLicensesArray) - 'removeLicenses' = $Request.IsReplace ? @() : @($Request.RemoveLicenses) + 'removeLicenses' = $Request.IsReplace ? @() : $Request.RemoveLicenses } headers = @{ 'Content-Type' = 'application/json' } } From bbd8575057aa959385472cc9087b5d70b2dcaf8d Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Thu, 26 Feb 2026 14:12:23 +0800 Subject: [PATCH 04/28] Handle defaultDomainName when managing defaults Read defaultDomainName from request and include it when clearing offboarding defaults: build partition keys from customerId and defaultDomainName, find matching OffboardingDefaults entities, and remove each match (with improved log message). In tenant listing, prefer offboarding defaults by customerId, fall back to initialDomainName, and select the first match; adjust parse-failure logging to reference the tenant domain. These changes ensure defaults are resolved/removed for both customer and domain partition keys and avoid multiple-match ambiguity. --- .../Tenant/Invoke-EditTenantOffboardingDefaults.ps1 | 13 +++++++++---- .../Administration/Tenant/Invoke-ListTenants.ps1 | 7 +++++-- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Tenant/Administration/Tenant/Invoke-EditTenantOffboardingDefaults.ps1 b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Tenant/Administration/Tenant/Invoke-EditTenantOffboardingDefaults.ps1 index 014e9aed8d7c..35f90390af22 100644 --- a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Tenant/Administration/Tenant/Invoke-EditTenantOffboardingDefaults.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Tenant/Administration/Tenant/Invoke-EditTenantOffboardingDefaults.ps1 @@ -14,6 +14,7 @@ function Invoke-EditTenantOffboardingDefaults { # Interact with query parameters or the body of the request. $customerId = $Request.Body.customerId + $defaultDomainName = $Request.Body.defaultDomainName $offboardingDefaults = $Request.Body.offboardingDefaults if (!$customerId) { @@ -47,10 +48,14 @@ function Invoke-EditTenantOffboardingDefaults { $resultText = 'Tenant offboarding defaults updated successfully' } else { # Remove offboarding defaults if empty or null - $Existing = Get-CIPPAzDataTableEntity @PropertiesTable -Filter "PartitionKey eq '$customerId' and RowKey eq 'OffboardingDefaults'" - if ($Existing) { - Remove-AzDataTableEntity @PropertiesTable -Entity $Existing - Write-LogMessage -headers $Headers -tenant $customerId -API $APIName -message "Removed tenant offboarding defaults" -Sev 'Info' + $partitionKeys = @($customerId, $defaultDomainName) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -Unique + $existingDefaults = Get-CIPPAzDataTableEntity @PropertiesTable -Filter "RowKey eq 'OffboardingDefaults'" + $toRemove = $existingDefaults | Where-Object { $_.PartitionKey -in $partitionKeys } + if ($toRemove) { + foreach ($Entity in $toRemove) { + Remove-AzDataTableEntity @PropertiesTable -Entity $Entity + } + Write-LogMessage -headers $Headers -tenant $customerId -API $APIName -message "Removed tenant offboarding defaults for partition keys: $($partitionKeys -join ', ')" -Sev 'Info' } $resultText = 'Tenant offboarding defaults cleared successfully' diff --git a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Tenant/Administration/Tenant/Invoke-ListTenants.ps1 b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Tenant/Administration/Tenant/Invoke-ListTenants.ps1 index e114d1a32977..43ee69370193 100644 --- a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Tenant/Administration/Tenant/Invoke-ListTenants.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Tenant/Administration/Tenant/Invoke-ListTenants.ps1 @@ -89,12 +89,15 @@ function Invoke-ListTenants { # Add offboarding defaults to each tenant foreach ($Tenant in $Tenants) { - $TenantDefaults = $AllOffboardingDefaults | Where-Object { $_.PartitionKey -eq $Tenant.customerId } + $TenantDefaults = $AllOffboardingDefaults | Where-Object { $_.PartitionKey -eq $Tenant.customerId } | Select-Object -First 1 + if (-not $TenantDefaults) { + $TenantDefaults = $AllOffboardingDefaults | Where-Object { $_.PartitionKey -eq $Tenant.initialDomainName } | Select-Object -First 1 + } if ($TenantDefaults) { try { $Tenant | Add-Member -MemberType NoteProperty -Name 'offboardingDefaults' -Value ($TenantDefaults.Value | ConvertFrom-Json) -Force } catch { - Write-LogMessage -headers $Headers -API $APIName -message "Failed to parse offboarding defaults for tenant $($Tenant.customerId): $($_.Exception.Message)" -sev 'Warn' + Write-LogMessage -headers $Headers -API $APIName -message "Failed to parse offboarding defaults for tenant $($Tenant.defaultDomainName): $($_.Exception.Message)" -sev 'Warn' $Tenant | Add-Member -MemberType NoteProperty -Name 'offboardingDefaults' -Value $null -Force } } else { From 43b7b3cfc65d35a9325d6ef361f98f6c28bb95d9 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Thu, 26 Feb 2026 07:54:53 -0500 Subject: [PATCH 05/28] change standards to run every 12 hours --- CIPPTimers.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CIPPTimers.json b/CIPPTimers.json index ed7cd4c31819..bebf203ef638 100644 --- a/CIPPTimers.json +++ b/CIPPTimers.json @@ -75,7 +75,7 @@ "Id": "9b0c8e50-f798-49db-9a8b-dbcc0fcadeea", "Command": "Start-StandardsOrchestrator", "Description": "Orchestrator to process standards", - "Cron": "0 0 */4 * * *", + "Cron": "0 0 */12 * * *", "Priority": 4, "RunOnProcessor": true, "PreferredProcessor": "standards" From db0521ca6ce53454b6d2076c9f5428e24d4ae298 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Thu, 26 Feb 2026 08:49:30 -0500 Subject: [PATCH 06/28] add group type to membership change --- .../Public/Alerts/Get-CIPPAlertGroupMembershipChange.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertGroupMembershipChange.ps1 b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertGroupMembershipChange.ps1 index 79336a3eed63..cfb1ca4e888e 100644 --- a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertGroupMembershipChange.ps1 +++ b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertGroupMembershipChange.ps1 @@ -19,7 +19,7 @@ function Get-CIPPAlertGroupMembershipChange { $AuditLogs = New-GraphGetRequest -uri "https://graph.microsoft.com/v1.0/auditLogs/directoryAudits?`$filter=activityDateTime ge $OneHourAgo and (activityDisplayName eq 'Add member to group' or activityDisplayName eq 'Remove member from group')" -tenantid $TenantFilter $AlertData = foreach ($Log in $AuditLogs) { - $Member = ($Log.targetResources | Where-Object { $_.type -in @('User', 'ServicePrincipal') })[0] + $Member = ($Log.targetResources | Where-Object { $_.type -in @('User', 'ServicePrincipal', 'Group') })[0] $GroupProp = ($Member.modifiedProperties | Where-Object { $_.displayName -eq 'Group.DisplayName' }) $GroupDisplayName = (($GroupProp.newValue ?? $GroupProp.oldValue) -replace '"', '') if (!$GroupDisplayName -or !($MonitoredGroups | Where-Object { $GroupDisplayName -like $_ })) { continue } From c5e561c4a865af60d146a4e972e06e89008fcf33 Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Thu, 26 Feb 2026 17:13:06 +0100 Subject: [PATCH 07/28] fix compares for #5477 --- .../Invoke-CIPPStandardConditionalAccessTemplate.ps1 | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardConditionalAccessTemplate.ps1 b/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardConditionalAccessTemplate.ps1 index 85261df84091..b56cb6dc4b79 100644 --- a/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardConditionalAccessTemplate.ps1 +++ b/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardConditionalAccessTemplate.ps1 @@ -99,6 +99,13 @@ function Invoke-CIPPStandardConditionalAccessTemplate { $Filter = "PartitionKey eq 'CATemplate' and RowKey eq '$($Settings.TemplateList.value)'" $Policy = (Get-CippAzDataTableEntity @Table -Filter $Filter).JSON | ConvertFrom-Json -Depth 10 + # Override the template's state with the Drift Standard's state if specified + # This ensures drift detection compares against the desired state, not the original template state + if ($Settings.state -and $Settings.state -ne 'donotchange') { + Write-Information "Overriding template state from '$($Policy.state)' to '$($Settings.state)' for drift comparison" + $Policy.state = $Settings.state + } + $CheckExististing = $AllCAPolicies | Where-Object -Property displayName -EQ $Settings.TemplateList.label if (!$CheckExististing) { if ($Policy.conditions.userRiskLevels.Count -gt 0 -or $Policy.conditions.signInRiskLevels.Count -gt 0) { From 90aaa3d0580eecf78c848b069eef213b4d0d6614 Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Thu, 26 Feb 2026 17:59:34 +0100 Subject: [PATCH 08/28] add alertcomment to payload --- .../Webhooks/Invoke-CIPPWebhookProcessing.ps1 | 6 ++++-- .../Public/Webhooks/Test-CIPPAuditLogRules.ps1 | 14 +++++++++----- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/Modules/CIPPCore/Public/Webhooks/Invoke-CIPPWebhookProcessing.ps1 b/Modules/CIPPCore/Public/Webhooks/Invoke-CIPPWebhookProcessing.ps1 index 0d7f1aa20e1d..90257fe4a22d 100644 --- a/Modules/CIPPCore/Public/Webhooks/Invoke-CIPPWebhookProcessing.ps1 +++ b/Modules/CIPPCore/Public/Webhooks/Invoke-CIPPWebhookProcessing.ps1 @@ -6,6 +6,7 @@ function Invoke-CippWebhookProcessing { $Resource, $Operations, $CIPPURL, + $AlertComment, $APIName = 'Process webhook', $Headers ) @@ -79,7 +80,7 @@ function Invoke-CippWebhookProcessing { # Save audit log entry to table $LocationInfo = $Data.CIPPLocationInfo | ConvertFrom-Json -ErrorAction SilentlyContinue $AuditRecord = $Data.AuditRecord | ConvertFrom-Json -ErrorAction SilentlyContinue - $GenerateJSON = New-CIPPAlertTemplate -format 'json' -data $Data -ActionResults $ActionResults -CIPPURL $CIPPURL -AlertComment $WebhookRule.AlertComment + $GenerateJSON = New-CIPPAlertTemplate -format 'json' -data $Data -ActionResults $ActionResults -CIPPURL $CIPPURL -AlertComment $AlertComment $JsonContent = @{ Title = $GenerateJSON.Title ActionUrl = $GenerateJSON.ButtonUrl @@ -89,6 +90,7 @@ function Invoke-CippWebhookProcessing { PotentialLocationInfo = $LocationInfo ActionsTaken = $ActionResults AuditRecord = $AuditRecord + AlertComment = $AlertComment } | ConvertTo-Json -Depth 15 -Compress $CIPPAlert = @{ @@ -102,7 +104,7 @@ function Invoke-CippWebhookProcessing { $LogId = Send-CIPPAlert @CIPPAlert $AuditLogLink = '{0}/tenant/administration/audit-logs/log?logId={1}&tenantFilter={2}' -f $CIPPURL, $LogId, $Tenant.defaultDomainName - $GenerateEmail = New-CIPPAlertTemplate -format 'html' -data $Data -ActionResults $ActionResults -CIPPURL $CIPPURL -Tenant $Tenant.defaultDomainName -AuditLogLink $AuditLogLink -AlertComment $WebhookRule.AlertComment + $GenerateEmail = New-CIPPAlertTemplate -format 'html' -data $Data -ActionResults $ActionResults -CIPPURL $CIPPURL -Tenant $Tenant.defaultDomainName -AuditLogLink $AuditLogLink -AlertComment $AlertComment Write-Host 'Going to create the content' foreach ($action in $ActionList ) { diff --git a/Modules/CIPPCore/Public/Webhooks/Test-CIPPAuditLogRules.ps1 b/Modules/CIPPCore/Public/Webhooks/Test-CIPPAuditLogRules.ps1 index c33d4d006a53..96536f481a0f 100644 --- a/Modules/CIPPCore/Public/Webhooks/Test-CIPPAuditLogRules.ps1 +++ b/Modules/CIPPCore/Public/Webhooks/Test-CIPPAuditLogRules.ps1 @@ -149,11 +149,12 @@ function Test-CIPPAuditLogRules { # Check if the TenantFilter matches any tenant in the expanded list or AllTenants if ($ExpandedTenants.value -contains $TenantFilter -or $ExpandedTenants.value -contains 'AllTenants') { [pscustomobject]@{ - Tenants = $Tenants - Excluded = ($ConfigEntry.excludedTenants | ConvertFrom-Json -ErrorAction SilentlyContinue) - Conditions = $ConfigEntry.Conditions - Actions = $ConfigEntry.Actions - LogType = $ConfigEntry.Type + Tenants = $Tenants + Excluded = ($ConfigEntry.excludedTenants | ConvertFrom-Json -ErrorAction SilentlyContinue) + Conditions = $ConfigEntry.Conditions + Actions = $ConfigEntry.Actions + LogType = $ConfigEntry.Type + AlertComment = $ConfigEntry.AlertComment } } } @@ -553,6 +554,7 @@ function Test-CIPPAuditLogRules { clause = $finalCondition expectedAction = $actions CIPPClause = $CIPPClause + AlertComment = $Config.AlertComment } } } catch { @@ -574,6 +576,7 @@ function Test-CIPPAuditLogRules { $ReturnedData = foreach ($item in $ReturnedData) { $item.CIPPAction = $clause.expectedAction $item.CIPPClause = $clause.CIPPClause -join ' and ' + $item.CIPPAlertComment = $clause.AlertComment $MatchedRules.Add($clause.CIPPClause -join ' and ') $item } @@ -601,6 +604,7 @@ function Test-CIPPAuditLogRules { Data = $AuditLog CIPPURL = [string]$CIPPURL TenantFilter = $TenantFilter + AlertComment = $AuditLog.CIPPAlertComment } try { Invoke-CippWebhookProcessing @Webhook From 578bf70d2ea81d47ab285e5f19cbf19a7f79d81c Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Thu, 26 Feb 2026 20:41:36 +0100 Subject: [PATCH 09/28] auditlog rentention cleanup speed and rerun protection --- .../Start-LogRetentionCleanup.ps1 | 54 +++++++++++++++---- 1 file changed, 45 insertions(+), 9 deletions(-) diff --git a/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-LogRetentionCleanup.ps1 b/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-LogRetentionCleanup.ps1 index 19ad42a6c5ad..ac6a6585bf84 100644 --- a/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-LogRetentionCleanup.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-LogRetentionCleanup.ps1 @@ -9,6 +9,19 @@ function Start-LogRetentionCleanup { param() try { + # Check rerun protection - only run once every 24 hours (86400 seconds) + $RerunParams = @{ + TenantFilter = 'AllTenants' + Type = 'LogCleanup' + API = 'LogRetentionCleanup' + Interval = 86400 + } + $Rerun = Test-CIPPRerun @RerunParams + if ($Rerun) { + Write-Host 'Log cleanup was recently executed. Skipping to prevent duplicate execution (runs once every 24 hours)' + return $true + } + # Get retention settings $ConfigTable = Get-CippTable -tablename Config $Filter = "PartitionKey eq 'LogRetention' and RowKey eq 'Settings'" @@ -36,27 +49,50 @@ function Start-LogRetentionCleanup { # Calculate cutoff date $CutoffDate = (Get-Date).AddDays(-$RetentionDays).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') - $DeletedCount = 0 + $TotalDeletedCount = 0 + $BatchSize = 5000 # Clean up CIPP Logs if ($PSCmdlet.ShouldProcess('CippLogs', 'Cleaning up old logs')) { $CippLogsTable = Get-CippTable -tablename 'CippLogs' $CutoffFilter = "Timestamp lt datetime'$CutoffDate'" - # Fetch all old log entries - $OldLogs = Get-AzDataTableEntity @CippLogsTable -Filter $CutoffFilter -Property @('PartitionKey', 'RowKey', 'ETag') + # Process deletions in batches of 10k to avoid timeout + $HasMoreRecords = $true + $BatchNumber = 0 + + while ($HasMoreRecords) { + $BatchNumber++ + Write-Host "Processing batch $BatchNumber..." + + # Fetch up to 10k old log entries + $OldLogs = Get-AzDataTableEntity @CippLogsTable -Filter $CutoffFilter -Property @('PartitionKey', 'RowKey', 'ETag') -First $BatchSize + + if ($OldLogs -and ($OldLogs | Measure-Object).Count -gt 0) { + $BatchCount = ($OldLogs | Measure-Object).Count + Remove-AzDataTableEntity @CippLogsTable -Entity $OldLogs -Force + $TotalDeletedCount += $BatchCount + Write-Host "Batch $BatchNumber`: Deleted $BatchCount log entries" + + # If we got less than the batch size, we're done + if ($BatchCount -lt $BatchSize) { + $HasMoreRecords = $false + } + } else { + Write-Host 'No more old logs found' + $HasMoreRecords = $false + } + } - if ($OldLogs) { - Remove-AzDataTableEntity @CippLogsTable -Entity $OldLogs -Force - $DeletedCount = ($OldLogs | Measure-Object).Count - Write-LogMessage -API 'LogRetentionCleanup' -message "Deleted $DeletedCount old log entries (retention: $RetentionDays days)" -Sev 'Info' - Write-Host "Deleted $DeletedCount old log entries" + if ($TotalDeletedCount -gt 0) { + Write-LogMessage -API 'LogRetentionCleanup' -message "Deleted $TotalDeletedCount old log entries in $BatchNumber batch(es) (retention: $RetentionDays days)" -Sev 'Info' + Write-Host "Total deleted: $TotalDeletedCount old log entries" } else { Write-Host 'No old logs found' } } - Write-LogMessage -API 'LogRetentionCleanup' -message "Log cleanup completed. Total logs deleted: $DeletedCount (retention: $RetentionDays days)" -Sev 'Info' + Write-LogMessage -API 'LogRetentionCleanup' -message "Log cleanup completed. Total logs deleted: $TotalDeletedCount (retention: $RetentionDays days)" -Sev 'Info' } catch { $ErrorMessage = Get-CippException -Exception $_ From c7d872a57a40c1fa73ddd7035766c369e74a81cc Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Thu, 26 Feb 2026 20:55:25 +0100 Subject: [PATCH 10/28] updated log retention --- .../Entrypoints/Timer Functions/Start-LogRetentionCleanup.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-LogRetentionCleanup.ps1 b/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-LogRetentionCleanup.ps1 index ac6a6585bf84..22719d9a63b4 100644 --- a/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-LogRetentionCleanup.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-LogRetentionCleanup.ps1 @@ -66,7 +66,7 @@ function Start-LogRetentionCleanup { Write-Host "Processing batch $BatchNumber..." # Fetch up to 10k old log entries - $OldLogs = Get-AzDataTableEntity @CippLogsTable -Filter $CutoffFilter -Property @('PartitionKey', 'RowKey', 'ETag') -First $BatchSize + $OldLogs = Get-AzDataTableEntity @CippLogsTable -Filter $CutoffFilter -Property @('PartitionKey', 'RowKey') -First $BatchSize if ($OldLogs -and ($OldLogs | Measure-Object).Count -gt 0) { $BatchCount = ($OldLogs | Measure-Object).Count From eb0e683ee43f6bb824cb9a532b237d7c99ba49ee Mon Sep 17 00:00:00 2001 From: Bobby <31723128+kris6673@users.noreply.github.com> Date: Thu, 26 Feb 2026 21:57:41 +0100 Subject: [PATCH 11/28] feat: Add WindowsBackupRestore standard for Intune WBfO enrollment config Implements standard to enable/disable Windows Backup and Restore for Organizations (WBfO) enrollment setting in Intune via Graph API. --- ...nvoke-CIPPStandardWindowsBackupRestore.ps1 | 101 ++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardWindowsBackupRestore.ps1 diff --git a/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardWindowsBackupRestore.ps1 b/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardWindowsBackupRestore.ps1 new file mode 100644 index 000000000000..88927affbf3e --- /dev/null +++ b/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardWindowsBackupRestore.ps1 @@ -0,0 +1,101 @@ +function Invoke-CIPPStandardWindowsBackupRestore { + <# + .FUNCTIONALITY + Internal + .COMPONENT + (APIName) WindowsBackupRestore + .SYNOPSIS + (Label) Set Windows Backup and Restore state + .DESCRIPTION + (Helptext) Configures the Windows Backup and Restore enrollment setting in Intune. When enabled, users see a restore page during Windows Autopilot/OOBE that allows them to restore their apps and settings from a previous device backup. **Before you can restore a backup, a policy to enable it on devices must be set up in Settings Catalog.** + (DocsDescription) Configures the Windows Backup and Restore (WBfO) device enrollment setting in Intune. This feature allows users to restore apps and settings from a previous device backup during Windows setup. Enabling this shows a restore page during enrollment (OOBE) so users can migrate their workspace configuration to a new device. More information can be found in [Microsoft's documentation.](https://learn.microsoft.com/en-us/intune/intune-service/enrollment/windows-backup-restore) + .NOTES + CAT + Intune Standards + TAG + EXECUTIVETEXT + Controls the Windows Backup and Restore for Organizations feature in Intune. When enabled, employees setting up new devices can restore their apps and settings from a previous backup during Windows enrollment. This streamlines device provisioning, reduces setup time for new or replacement devices, and improves the employee experience during device transitions. + ADDEDCOMPONENT + {"type":"autoComplete","multiple":false,"creatable":false,"label":"Select value","name":"standards.WindowsBackupRestore.state","options":[{"label":"Enabled","value":"enabled"},{"label":"Disabled","value":"disabled"},{"label":"Not Configured","value":"notConfigured"}]} + IMPACT + Low Impact + ADDEDDATE + 2026-02-26 + POWERSHELLEQUIVALENT + Graph API + RECOMMENDEDBY + UPDATECOMMENTBLOCK + Run the Tools\Update-StandardsComments.ps1 script to update this comment block + .LINK + https://docs.cipp.app/user-documentation/tenant/standards/list-standards + #> + + [CmdletBinding()] + param($Tenant, $Settings) + + $TestResult = Test-CIPPStandardLicense -StandardName 'WindowsBackupRestore' -TenantFilter $Tenant -RequiredCapabilities @('INTUNE_A', 'MDM_Services', 'EMS', 'SCCM', 'MICROSOFTINTUNEPLAN1') + + if ($TestResult -eq $false) { + return $true + } + + # Get state value using null-coalescing operator + $WantedState = $Settings.state.value ?? $Settings.state + + try { + $Config = New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/deviceManagement/deviceEnrollmentConfigurations?$filter=deviceEnrollmentConfigurationType eq ''windowsRestore''' -tenantid $Tenant + $CurrentState = $Config.state + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'Standards' -tenant $Tenant -message "Failed to retrieve Windows Backup and Restore configuration. Error: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage + return + } + + $StateIsCorrect = $CurrentState -eq $WantedState + + $CurrentValue = [PSCustomObject]@{ + state = $CurrentState + } + $ExpectedValue = [PSCustomObject]@{ + state = $WantedState + } + + # Input validation + if ([string]::IsNullOrWhiteSpace($WantedState)) { + Write-LogMessage -API 'Standards' -tenant $Tenant -message 'WindowsBackupRestore: Invalid state parameter set' -sev Error + return + } + + if ($Settings.remediate -eq $true) { + if ($StateIsCorrect -eq $true) { + Write-LogMessage -API 'Standards' -tenant $Tenant -message "Windows Backup and Restore is already set to $WantedState." -sev Info + } else { + try { + $Body = @{ + '@odata.type' = '#microsoft.graph.windowsRestoreDeviceEnrollmentConfiguration' + state = $WantedState + } | ConvertTo-Json -Depth 10 + + New-GraphPostRequest -uri "https://graph.microsoft.com/beta/deviceManagement/deviceEnrollmentConfigurations/$($Config.id)" -tenantid $Tenant -type PATCH -body $Body + Write-LogMessage -API 'Standards' -tenant $Tenant -message "Successfully set Windows Backup and Restore to $WantedState." -sev Info + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'Standards' -tenant $Tenant -message "Failed to set Windows Backup and Restore to $WantedState. Error: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage + } + } + } + + if ($Settings.alert -eq $true) { + if ($StateIsCorrect -eq $true) { + Write-LogMessage -API 'Standards' -tenant $Tenant -message "Windows Backup and Restore is set correctly to $WantedState." -sev Info + } else { + Write-StandardsAlert -message "Windows Backup and Restore is not set correctly. Expected: $WantedState, Current: $CurrentState" -object @{ CurrentState = $CurrentState; WantedState = $WantedState } -tenant $Tenant -standardName 'WindowsBackupRestore' -standardId $Settings.standardId + Write-LogMessage -API 'Standards' -tenant $Tenant -message "Windows Backup and Restore is not set correctly to $WantedState." -sev Info + } + } + + if ($Settings.report -eq $true) { + Set-CIPPStandardsCompareField -FieldName 'standards.WindowsBackupRestore' -CurrentValue $CurrentValue -ExpectedValue $ExpectedValue -TenantFilter $Tenant + Add-CIPPBPAField -FieldName 'WindowsBackupRestore' -FieldValue $StateIsCorrect -StoreAs bool -Tenant $Tenant + } +} From d0d25955baad004f8789b9556c71b8fd6d47cbc0 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Thu, 26 Feb 2026 16:20:09 -0500 Subject: [PATCH 12/28] Enhance Teams federation config parsing and updates Improve parsing and comparison of Teams AllowedDomains and BlockedDomains when evaluating/updating tenant federation settings. Handles multiple API return shapes (AllowAllKnownDomains, AllowedDomain arrays, Domain arrays, empty PSObject), normalizes domain lists for comparisons, and correctly decides whether to send AllowedDomains or AllowedDomainsAsAList to Set-CsTenantFederationConfiguration. Also normalizes blocked domains comparisons, adds informational logging for detected structures and update parameters, and adjusts reporting to return consistent Current/Expected values. Minor formatting tweaks to license capability array and try/catch alignment. --- ...PPStandardTeamsFederationConfiguration.ps1 | 112 +++++++++++++++--- 1 file changed, 93 insertions(+), 19 deletions(-) diff --git a/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardTeamsFederationConfiguration.ps1 b/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardTeamsFederationConfiguration.ps1 index 2ac7b7ae4898..4439ef4c4e72 100644 --- a/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardTeamsFederationConfiguration.ps1 +++ b/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardTeamsFederationConfiguration.ps1 @@ -33,7 +33,7 @@ function Invoke-CIPPStandardTeamsFederationConfiguration { #> param($Tenant, $Settings) - $TestResult = Test-CIPPStandardLicense -StandardName 'TeamsFederationConfiguration' -TenantFilter $Tenant -RequiredCapabilities @('MCOSTANDARD', 'MCOEV', 'MCOIMP', 'TEAMS1','Teams_Room_Standard') + $TestResult = Test-CIPPStandardLicense -StandardName 'TeamsFederationConfiguration' -TenantFilter $Tenant -RequiredCapabilities @('MCOSTANDARD', 'MCOEV', 'MCOIMP', 'TEAMS1', 'Teams_Room_Standard') if ($TestResult -eq $false) { return $true @@ -41,9 +41,8 @@ function Invoke-CIPPStandardTeamsFederationConfiguration { try { $CurrentState = New-TeamsRequest -TenantFilter $Tenant -Cmdlet 'Get-CsTenantFederationConfiguration' -CmdParams @{Identity = 'Global' } | - Select-Object * - } - catch { + Select-Object * + } catch { $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message Write-LogMessage -API 'Standards' -Tenant $Tenant -Message "Could not get the TeamsFederationConfiguration state for $Tenant. Error: $ErrorMessage" -Sev Error return @@ -56,15 +55,18 @@ function Invoke-CIPPStandardTeamsFederationConfiguration { 'AllowAllExternal' { $AllowFederatedUsers = $true $AllowedDomains = $AllowAllKnownDomains + $AllowedDomainsAsAList = @() $BlockedDomains = @() } 'BlockAllExternal' { $AllowFederatedUsers = $false $AllowedDomains = $AllowAllKnownDomains + $AllowedDomainsAsAList = @() $BlockedDomains = @() } 'AllowSpecificExternal' { $AllowFederatedUsers = $true + $AllowedDomains = $null $BlockedDomains = @() if ($null -ne $Settings.DomainList) { $AllowedDomainsAsAList = @($Settings.DomainList).Split(',').Trim() @@ -74,7 +76,8 @@ function Invoke-CIPPStandardTeamsFederationConfiguration { } 'BlockSpecificExternal' { $AllowFederatedUsers = $true - $AllowedDomainsAsAList = 'AllowAllKnownDomains' + $AllowedDomains = $AllowAllKnownDomains + $AllowedDomainsAsAList = @() if ($null -ne $Settings.DomainList) { $BlockedDomains = @($Settings.DomainList).Split(',').Trim() } else { @@ -87,17 +90,69 @@ function Invoke-CIPPStandardTeamsFederationConfiguration { } } + # Parse current allowed domains and compare with expected configuration $CurrentAllowedDomains = $CurrentState.AllowedDomains - if ($CurrentAllowedDomains.GetType().Name -eq 'PSObject') { - $CurrentAllowedDomains = $CurrentAllowedDomains.Domain | Sort-Object - $DomainList = ($CurrentAllowedDomains | Sort-Object) ?? @() - $AllowedDomainsMatches = -not (Compare-Object -ReferenceObject $AllowedDomainsAsAList -DifferenceObject $DomainList) + $AllowedDomainsMatches = $false + $IsCurrentAllowAllKnownDomains = $false + + if (!$CurrentAllowedDomains) { + # Current state has no allowed domains set + $CurrentAllowedDomains = @() + $AllowedDomainsMatches = (!$AllowedDomains -and $AllowedDomainsAsAList.Count -eq 0) + } elseif ($CurrentAllowedDomains.GetType().Name -eq 'PSObject') { + # Current state is a PSObject - check if it has AllowAllKnownDomains, AllowedDomain, or Domain property + $properties = Get-Member -InputObject $CurrentAllowedDomains -MemberType Properties, NoteProperty + + if ($null -ne $CurrentAllowedDomains.AllowAllKnownDomains -or (Get-Member -InputObject $CurrentAllowedDomains -Name 'AllowAllKnownDomains')) { + # PSObject with AllowAllKnownDomains property = Allow all known domains + $IsCurrentAllowAllKnownDomains = $true + $CurrentAllowedDomains = 'AllowAllKnownDomains' + Write-Information 'Detected AllowAllKnownDomains configuration (via property)' + $AllowedDomainsMatches = ($null -ne $AllowedDomains) -and (!$AllowedDomainsAsAList -or $AllowedDomainsAsAList.Count -eq 0) + } elseif ($null -ne $CurrentAllowedDomains.AllowedDomain -or (Get-Member -InputObject $CurrentAllowedDomains -Name 'AllowedDomain')) { + # PSObject with AllowedDomain property = Specific domain list (array of objects with Domain property) + $CurrentAllowedDomains = @($CurrentAllowedDomains.AllowedDomain | ForEach-Object { $_.Domain }) | Sort-Object + $DomainList = ($CurrentAllowedDomains | Sort-Object) ?? @() + Write-Information "Detected AllowedDomain list: $($CurrentAllowedDomains -join ', ')" + # Compare with expected domain list + if ($AllowedDomainsAsAList -and $AllowedDomainsAsAList.Count -gt 0) { + $AllowedDomainsMatches = -not (Compare-Object -ReferenceObject $AllowedDomainsAsAList -DifferenceObject $DomainList) + } else { + $AllowedDomainsMatches = $false + } + } elseif ($null -ne $CurrentAllowedDomains.Domain -or (Get-Member -InputObject $CurrentAllowedDomains -Name 'Domain')) { + # PSObject with Domain property = Specific domain list (direct array) + $CurrentAllowedDomains = $CurrentAllowedDomains.Domain | Sort-Object + $DomainList = ($CurrentAllowedDomains | Sort-Object) ?? @() + # Compare with expected domain list + if ($AllowedDomainsAsAList -and $AllowedDomainsAsAList.Count -gt 0) { + $AllowedDomainsMatches = -not (Compare-Object -ReferenceObject $AllowedDomainsAsAList -DifferenceObject $DomainList) + } else { + $AllowedDomainsMatches = $false + } + } elseif (!$properties -or $properties.Count -eq 0) { + # Empty PSObject with no properties = AllowAllKnownDomains (this is how Teams API returns it) + $IsCurrentAllowAllKnownDomains = $true + $CurrentAllowedDomains = 'AllowAllKnownDomains' + Write-Information 'Detected AllowAllKnownDomains configuration (empty PSObject)' + $AllowedDomainsMatches = ($null -ne $AllowedDomains) -and (!$AllowedDomainsAsAList -or $AllowedDomainsAsAList.Count -eq 0) + } else { + # Unknown PSObject structure + Write-Information "Unknown PSObject structure with properties: $($properties.Name -join ', ')" + $CurrentAllowedDomains = @() + $AllowedDomainsMatches = $false + } } elseif ($CurrentAllowedDomains.GetType().Name -eq 'Deserialized.Microsoft.Rtc.Management.WritableConfig.Settings.Edge.AllowAllKnownDomains') { - $CurrentAllowedDomains = $CurrentAllowedDomains.ToString() - $AllowedDomainsMatches = $CurrentAllowedDomains -eq $AllowedDomains.ToString() + # Current state is set to AllowAllKnownDomains + $IsCurrentAllowAllKnownDomains = $true + # Match if expected is also AllowAllKnownDomains (not a specific list) + $AllowedDomainsMatches = ($null -ne $AllowedDomains) -and (!$AllowedDomainsAsAList -or $AllowedDomainsAsAList.Count -eq 0) } - $BlockedDomainsMatches = -not (Compare-Object -ReferenceObject $BlockedDomains -DifferenceObject $CurrentState.BlockedDomains) + # Normalize blocked domains for comparison + $CurrentBlockedDomains = $CurrentState.BlockedDomains ?? @() + $ExpectedBlockedDomains = $BlockedDomains ?? @() + $BlockedDomainsMatches = -not (Compare-Object -ReferenceObject $ExpectedBlockedDomains -DifferenceObject $CurrentBlockedDomains) $StateIsCorrect = ($CurrentState.AllowTeamsConsumer -eq $Settings.AllowTeamsConsumer) -and ($CurrentState.AllowFederatedUsers -eq $AllowFederatedUsers) -and @@ -115,14 +170,16 @@ function Invoke-CIPPStandardTeamsFederationConfiguration { BlockedDomains = $BlockedDomains } - if (!$AllowedDomainsAsAList) { - $cmdParams.AllowedDomains = $AllowedDomains - } else { + if ($AllowedDomainsAsAList -and $AllowedDomainsAsAList.Count -gt 0) { $cmdParams.AllowedDomainsAsAList = $AllowedDomainsAsAList + } else { + $cmdParams.AllowedDomains = $AllowedDomains } try { New-TeamsRequest -TenantFilter $Tenant -Cmdlet 'Set-CsTenantFederationConfiguration' -CmdParams $cmdParams + Write-Information "Updated Teams Federation Configuration for tenant $Tenant with parameters: $($cmdParams | ConvertTo-Json -Compress -Depth 5)" + Write-LogMessage -API 'Standards' -tenant $Tenant -message 'Updated Federation Configuration Policy' -sev Info } catch { $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message @@ -143,17 +200,34 @@ function Invoke-CIPPStandardTeamsFederationConfiguration { if ($Settings.report -eq $true) { Add-CIPPBPAField -FieldName 'FederationConfiguration' -FieldValue $StateIsCorrect -StoreAs bool -Tenant $Tenant + $CurrentAllowedDomainsForReport = if ($IsCurrentAllowAllKnownDomains) { + 'AllowAllKnownDomains' + } elseif ($CurrentAllowedDomains) { + $CurrentAllowedDomains + } else { + @() + } + + # Normalize expected allowed domains for reporting + $ExpectedAllowedDomainsForReport = if ($AllowedDomainsAsAList -and $AllowedDomainsAsAList.Count -gt 0) { + $AllowedDomainsAsAList + } elseif ($AllowedDomains) { + 'AllowAllKnownDomains' + } else { + @() + } + $CurrentValue = @{ AllowTeamsConsumer = $CurrentState.AllowTeamsConsumer AllowFederatedUsers = $CurrentState.AllowFederatedUsers - AllowedDomains = if ($CurrentAllowedDomains.GetType().Name -eq 'Deserialized.Microsoft.Rtc.Management.WritableConfig.Settings.Edge.AllowAllKnownDomains') { $CurrentAllowedDomains.ToString() } else { $CurrentAllowedDomains } - BlockedDomains = $CurrentState.BlockedDomains + AllowedDomains = $CurrentAllowedDomainsForReport + BlockedDomains = $CurrentBlockedDomains } $ExpectedValue = @{ AllowTeamsConsumer = $Settings.AllowTeamsConsumer AllowFederatedUsers = $AllowFederatedUsers - AllowedDomains = $AllowedDomains - BlockedDomains = $BlockedDomains + AllowedDomains = $ExpectedAllowedDomainsForReport + BlockedDomains = $ExpectedBlockedDomains } Set-CIPPStandardsCompareField -FieldName 'standards.TeamsFederationConfiguration' -CurrentValue $CurrentValue -ExpectedValue $ExpectedValue -Tenant $Tenant } From 829afc908e1ff756f15eff57bd6ef2729bbd7e7c Mon Sep 17 00:00:00 2001 From: John Duprey Date: Thu, 26 Feb 2026 17:20:29 -0500 Subject: [PATCH 13/28] Improve Teams federation domains parsing and validation Refactor parsing and comparison logic for Teams federation AllowedDomains/BlockedDomains. Handle PSObject and deserialized types, detect AllowAllKnownDomains, extract AllowedDomain/Domain properties, and normalize blocked domains up-front. Add DomainControl-specific validation for allow/block modes and normalize values for reporting. Remove a noisy Update info log and tidy comparison initialization to avoid false mismatches. --- ...PPStandardTeamsFederationConfiguration.ps1 | 140 +++++++++++------- 1 file changed, 84 insertions(+), 56 deletions(-) diff --git a/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardTeamsFederationConfiguration.ps1 b/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardTeamsFederationConfiguration.ps1 index 4439ef4c4e72..c65891ba08e6 100644 --- a/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardTeamsFederationConfiguration.ps1 +++ b/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardTeamsFederationConfiguration.ps1 @@ -90,69 +90,86 @@ function Invoke-CIPPStandardTeamsFederationConfiguration { } } - # Parse current allowed domains and compare with expected configuration + # Parse current state based on DomainControl mode $CurrentAllowedDomains = $CurrentState.AllowedDomains - $AllowedDomainsMatches = $false + $CurrentBlockedDomains = $CurrentState.BlockedDomains $IsCurrentAllowAllKnownDomains = $false + $AllowedDomainsMatches = $false + $BlockedDomainsMatches = $false - if (!$CurrentAllowedDomains) { - # Current state has no allowed domains set - $CurrentAllowedDomains = @() - $AllowedDomainsMatches = (!$AllowedDomains -and $AllowedDomainsAsAList.Count -eq 0) - } elseif ($CurrentAllowedDomains.GetType().Name -eq 'PSObject') { - # Current state is a PSObject - check if it has AllowAllKnownDomains, AllowedDomain, or Domain property - $properties = Get-Member -InputObject $CurrentAllowedDomains -MemberType Properties, NoteProperty - - if ($null -ne $CurrentAllowedDomains.AllowAllKnownDomains -or (Get-Member -InputObject $CurrentAllowedDomains -Name 'AllowAllKnownDomains')) { - # PSObject with AllowAllKnownDomains property = Allow all known domains - $IsCurrentAllowAllKnownDomains = $true - $CurrentAllowedDomains = 'AllowAllKnownDomains' - Write-Information 'Detected AllowAllKnownDomains configuration (via property)' - $AllowedDomainsMatches = ($null -ne $AllowedDomains) -and (!$AllowedDomainsAsAList -or $AllowedDomainsAsAList.Count -eq 0) - } elseif ($null -ne $CurrentAllowedDomains.AllowedDomain -or (Get-Member -InputObject $CurrentAllowedDomains -Name 'AllowedDomain')) { - # PSObject with AllowedDomain property = Specific domain list (array of objects with Domain property) - $CurrentAllowedDomains = @($CurrentAllowedDomains.AllowedDomain | ForEach-Object { $_.Domain }) | Sort-Object - $DomainList = ($CurrentAllowedDomains | Sort-Object) ?? @() - Write-Information "Detected AllowedDomain list: $($CurrentAllowedDomains -join ', ')" - # Compare with expected domain list - if ($AllowedDomainsAsAList -and $AllowedDomainsAsAList.Count -gt 0) { - $AllowedDomainsMatches = -not (Compare-Object -ReferenceObject $AllowedDomainsAsAList -DifferenceObject $DomainList) + # Check if current allowed domains is AllowAllKnownDomains, and parse specific domains if not + if ($CurrentAllowedDomains) { + if ($CurrentAllowedDomains.GetType().Name -eq 'PSObject') { + $properties = Get-Member -InputObject $CurrentAllowedDomains -MemberType Properties, NoteProperty + if (($null -ne $CurrentAllowedDomains.AllowAllKnownDomains) -or + (Get-Member -InputObject $CurrentAllowedDomains -Name 'AllowAllKnownDomains') -or + (!$properties -or $properties.Count -eq 0)) { + $IsCurrentAllowAllKnownDomains = $true + Write-Information "Current AllowedDomains is AllowAllKnownDomains" } else { - $AllowedDomainsMatches = $false + # Parse specific allowed domains list + if ($null -ne $CurrentAllowedDomains.AllowedDomain -or (Get-Member -InputObject $CurrentAllowedDomains -Name 'AllowedDomain')) { + $CurrentAllowedDomains = @($CurrentAllowedDomains.AllowedDomain | ForEach-Object { $_.Domain }) | Sort-Object + Write-Information "Current AllowedDomains (extracted): $($CurrentAllowedDomains -join ', ')" + } elseif ($null -ne $CurrentAllowedDomains.Domain -or (Get-Member -InputObject $CurrentAllowedDomains -Name 'Domain')) { + $CurrentAllowedDomains = @($CurrentAllowedDomains.Domain) | Sort-Object + Write-Information "Current AllowedDomains (via Domain property): $($CurrentAllowedDomains -join ', ')" + } else { + $CurrentAllowedDomains = @() + } } - } elseif ($null -ne $CurrentAllowedDomains.Domain -or (Get-Member -InputObject $CurrentAllowedDomains -Name 'Domain')) { - # PSObject with Domain property = Specific domain list (direct array) - $CurrentAllowedDomains = $CurrentAllowedDomains.Domain | Sort-Object - $DomainList = ($CurrentAllowedDomains | Sort-Object) ?? @() - # Compare with expected domain list - if ($AllowedDomainsAsAList -and $AllowedDomainsAsAList.Count -gt 0) { - $AllowedDomainsMatches = -not (Compare-Object -ReferenceObject $AllowedDomainsAsAList -DifferenceObject $DomainList) + } elseif ($CurrentAllowedDomains.GetType().Name -eq 'Deserialized.Microsoft.Rtc.Management.WritableConfig.Settings.Edge.AllowAllKnownDomains') { + $IsCurrentAllowAllKnownDomains = $true + Write-Information "Current AllowedDomains is AllowAllKnownDomains (Deserialized type)" + } + } else { + $CurrentAllowedDomains = @() + } + + # Parse blocked domains upfront (always extract Domain property if present) + if ($CurrentBlockedDomains -is [System.Collections.IEnumerable] -and $CurrentBlockedDomains -isnot [string]) { + $blockedDomainsArray = @($CurrentBlockedDomains) + if ($blockedDomainsArray.Count -gt 0) { + $firstElement = $blockedDomainsArray[0] + $hasDomainProperty = ($null -ne $firstElement.Domain) -or (Get-Member -InputObject $firstElement -Name 'Domain' -MemberType Properties, NoteProperty) + + if ($hasDomainProperty) { + $CurrentBlockedDomains = @($blockedDomainsArray | ForEach-Object { $_.Domain }) | Sort-Object + Write-Information "Current BlockedDomains (extracted): $($CurrentBlockedDomains -join ', ')" } else { - $AllowedDomainsMatches = $false + $CurrentBlockedDomains = @($blockedDomainsArray) | Sort-Object + Write-Information "Current BlockedDomains (plain strings): $($CurrentBlockedDomains -join ', ')" } - } elseif (!$properties -or $properties.Count -eq 0) { - # Empty PSObject with no properties = AllowAllKnownDomains (this is how Teams API returns it) - $IsCurrentAllowAllKnownDomains = $true - $CurrentAllowedDomains = 'AllowAllKnownDomains' - Write-Information 'Detected AllowAllKnownDomains configuration (empty PSObject)' - $AllowedDomainsMatches = ($null -ne $AllowedDomains) -and (!$AllowedDomainsAsAList -or $AllowedDomainsAsAList.Count -eq 0) } else { - # Unknown PSObject structure - Write-Information "Unknown PSObject structure with properties: $($properties.Name -join ', ')" - $CurrentAllowedDomains = @() - $AllowedDomainsMatches = $false - } - } elseif ($CurrentAllowedDomains.GetType().Name -eq 'Deserialized.Microsoft.Rtc.Management.WritableConfig.Settings.Edge.AllowAllKnownDomains') { - # Current state is set to AllowAllKnownDomains - $IsCurrentAllowAllKnownDomains = $true - # Match if expected is also AllowAllKnownDomains (not a specific list) - $AllowedDomainsMatches = ($null -ne $AllowedDomains) -and (!$AllowedDomainsAsAList -or $AllowedDomainsAsAList.Count -eq 0) + $CurrentBlockedDomains = @() + } + } else { + $CurrentBlockedDomains = @() + } + + # Mode-specific validation + switch ($DomainControl) { + 'AllowAllExternal' { + $AllowedDomainsMatches = $IsCurrentAllowAllKnownDomains + $BlockedDomainsMatches = (!$CurrentBlockedDomains -or @($CurrentBlockedDomains).Count -eq 0) + } + 'BlockAllExternal' { + # When blocking all, federation must be disabled + $AllowedDomainsMatches = $true + $BlockedDomainsMatches = $true + } + 'AllowSpecificExternal' { + $AllowedDomainsMatches = -not (Compare-Object -ReferenceObject $AllowedDomainsAsAList -DifferenceObject $CurrentAllowedDomains) + $BlockedDomainsMatches = (!$CurrentBlockedDomains -or @($CurrentBlockedDomains).Count -eq 0) + } + 'BlockSpecificExternal' { + # Allowed should be AllowAllKnownDomains, blocked domains already parsed above + $AllowedDomainsMatches = $IsCurrentAllowAllKnownDomains + $BlockedDomainsMatches = -not (Compare-Object -ReferenceObject $BlockedDomains -DifferenceObject $CurrentBlockedDomains) + } } - # Normalize blocked domains for comparison - $CurrentBlockedDomains = $CurrentState.BlockedDomains ?? @() $ExpectedBlockedDomains = $BlockedDomains ?? @() - $BlockedDomainsMatches = -not (Compare-Object -ReferenceObject $ExpectedBlockedDomains -DifferenceObject $CurrentBlockedDomains) $StateIsCorrect = ($CurrentState.AllowTeamsConsumer -eq $Settings.AllowTeamsConsumer) -and ($CurrentState.AllowFederatedUsers -eq $AllowFederatedUsers) -and @@ -178,8 +195,6 @@ function Invoke-CIPPStandardTeamsFederationConfiguration { try { New-TeamsRequest -TenantFilter $Tenant -Cmdlet 'Set-CsTenantFederationConfiguration' -CmdParams $cmdParams - Write-Information "Updated Teams Federation Configuration for tenant $Tenant with parameters: $($cmdParams | ConvertTo-Json -Compress -Depth 5)" - Write-LogMessage -API 'Standards' -tenant $Tenant -message 'Updated Federation Configuration Policy' -sev Info } catch { $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message @@ -217,17 +232,30 @@ function Invoke-CIPPStandardTeamsFederationConfiguration { @() } + # Normalize blocked domains for reporting + $CurrentBlockedDomainsForReport = if ($null -ne $CurrentBlockedDomains -and @($CurrentBlockedDomains).Count -gt 0) { + @($CurrentBlockedDomains) + } else { + @() + } + + $ExpectedBlockedDomainsForReport = if ($null -ne $ExpectedBlockedDomains -and @($ExpectedBlockedDomains).Count -gt 0) { + @($ExpectedBlockedDomains) + } else { + @() + } + $CurrentValue = @{ AllowTeamsConsumer = $CurrentState.AllowTeamsConsumer AllowFederatedUsers = $CurrentState.AllowFederatedUsers AllowedDomains = $CurrentAllowedDomainsForReport - BlockedDomains = $CurrentBlockedDomains + BlockedDomains = $CurrentBlockedDomainsForReport } $ExpectedValue = @{ AllowTeamsConsumer = $Settings.AllowTeamsConsumer AllowFederatedUsers = $AllowFederatedUsers AllowedDomains = $ExpectedAllowedDomainsForReport - BlockedDomains = $ExpectedBlockedDomains + BlockedDomains = $ExpectedBlockedDomainsForReport } Set-CIPPStandardsCompareField -FieldName 'standards.TeamsFederationConfiguration' -CurrentValue $CurrentValue -ExpectedValue $ExpectedValue -Tenant $Tenant } From b2a45996926639cdbca6276669a8fba36caaca17 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Thu, 26 Feb 2026 17:39:10 -0500 Subject: [PATCH 14/28] Add mailboxes report API and use-report flag Introduce Get-CIPPMailboxesReport to retrieve mailbox records from the reporting DB (supports TenantFilter and 'AllTenants' aggregation, attaches a CacheTimestamp, sorts by displayName and logs errors). Update Invoke-ListMailboxes to accept a UseReportDB query parameter; when set to 'true' it calls the new report function and returns the results (with HTTP status/error handling), otherwise it continues to use the existing live EXO logic. --- .../Administration/Invoke-ListMailboxes.ps1 | 20 +++++ .../Public/Get-CIPPMailboxesReport.ps1 | 74 +++++++++++++++++++ 2 files changed, 94 insertions(+) create mode 100644 Modules/CIPPCore/Public/Get-CIPPMailboxesReport.ps1 diff --git a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ListMailboxes.ps1 b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ListMailboxes.ps1 index 353c6e3fe395..900fb02a9e33 100644 --- a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ListMailboxes.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ListMailboxes.ps1 @@ -9,7 +9,27 @@ function Invoke-ListMailboxes { param($Request, $TriggerMetadata) # Interact with query parameters or the body of the request. $TenantFilter = $Request.Query.tenantFilter + $UseReportDB = $Request.Query.UseReportDB + try { + # If UseReportDB is specified, retrieve from report database + if ($UseReportDB -eq 'true') { + try { + $GraphRequest = Get-CIPPMailboxesReport -TenantFilter $TenantFilter -ErrorAction Stop + $StatusCode = [HttpStatusCode]::OK + } catch { + Write-Host "Error retrieving mailboxes from report database: $($_.Exception.Message)" + $StatusCode = [HttpStatusCode]::InternalServerError + $GraphRequest = $_.Exception.Message + } + + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = @($GraphRequest) + }) + } + + # Original live EXO logic $Select = 'id,ExchangeGuid,ArchiveGuid,UserPrincipalName,DisplayName,PrimarySMTPAddress,RecipientType,RecipientTypeDetails,EmailAddresses,WhenSoftDeleted,IsInactiveMailbox,ForwardingSmtpAddress,DeliverToMailboxAndForward,ForwardingAddress,HiddenFromAddressListsEnabled,ExternalDirectoryObjectId,MessageCopyForSendOnBehalfEnabled,MessageCopyForSentAsEnabled,PersistedCapabilities,LitigationHoldEnabled,LitigationHoldDate,LitigationHoldDuration,ComplianceTagHoldApplied,RetentionHoldEnabled,InPlaceHolds,RetentionPolicy' $ExoRequest = @{ tenantid = $TenantFilter diff --git a/Modules/CIPPCore/Public/Get-CIPPMailboxesReport.ps1 b/Modules/CIPPCore/Public/Get-CIPPMailboxesReport.ps1 new file mode 100644 index 000000000000..7e87d65243f9 --- /dev/null +++ b/Modules/CIPPCore/Public/Get-CIPPMailboxesReport.ps1 @@ -0,0 +1,74 @@ +function Get-CIPPMailboxesReport { + <# + .SYNOPSIS + Generates a mailboxes report from the CIPP Reporting database + + .DESCRIPTION + Retrieves mailbox data for a tenant from the reporting database + + .PARAMETER TenantFilter + The tenant to generate the report for + + .EXAMPLE + Get-CIPPMailboxesReport -TenantFilter 'contoso.onmicrosoft.com' + Gets all mailboxes for the tenant from the report database + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter + ) + + try { + # Handle AllTenants + if ($TenantFilter -eq 'AllTenants') { + # Get all tenants that have mailbox data + $AllMailboxItems = Get-CIPPDbItem -TenantFilter 'allTenants' -Type 'Mailboxes' + $Tenants = @($AllMailboxItems | Where-Object { $_.RowKey -ne 'Mailboxes-Count' } | Select-Object -ExpandProperty PartitionKey -Unique) + + $TenantList = Get-Tenants -IncludeErrors + $Tenants = $Tenants | Where-Object { $TenantList.defaultDomainName -contains $_ } + + $AllResults = [System.Collections.Generic.List[PSCustomObject]]::new() + foreach ($Tenant in $Tenants) { + try { + $TenantResults = Get-CIPPMailboxesReport -TenantFilter $Tenant + foreach ($Result in $TenantResults) { + # Add Tenant property to each result + $Result | Add-Member -NotePropertyName 'Tenant' -NotePropertyValue $Tenant -Force + $AllResults.Add($Result) + } + } catch { + Write-LogMessage -API 'MailboxesReport' -tenant $Tenant -message "Failed to get report for tenant: $($_.Exception.Message)" -sev Warning + } + } + return $AllResults + } + + # Get mailboxes from reporting DB + $MailboxItems = Get-CIPPDbItem -TenantFilter $TenantFilter -Type 'Mailboxes' | Where-Object { $_.RowKey -ne 'Mailboxes-Count' } + if (-not $MailboxItems) { + throw 'No mailbox data found in reporting database. Sync the report data first.' + } + + # Get the most recent cache timestamp + $CacheTimestamp = ($MailboxItems | Where-Object { $_.Timestamp } | Sort-Object Timestamp -Descending | Select-Object -First 1).Timestamp + + # Parse mailbox data + $AllMailboxes = [System.Collections.Generic.List[PSCustomObject]]::new() + foreach ($Item in $MailboxItems | Where-Object { $_.RowKey -ne 'Mailboxes-Count' }) { + $Mailbox = $Item.Data | ConvertFrom-Json + + # Add cache timestamp + $Mailbox | Add-Member -NotePropertyName 'CacheTimestamp' -NotePropertyValue $CacheTimestamp -Force + + $AllMailboxes.Add($Mailbox) + } + + return $AllMailboxes | Sort-Object -Property displayName + + } catch { + Write-LogMessage -API 'MailboxesReport' -tenant $TenantFilter -message "Failed to generate mailboxes report: $($_.Exception.Message)" -sev Error + throw + } +} From edb353539abac663e15cba83486d158b0bb14b5a Mon Sep 17 00:00:00 2001 From: John Duprey Date: Thu, 26 Feb 2026 18:00:58 -0500 Subject: [PATCH 15/28] fix permission on contact templates --- .../Contacts/Invoke-ListContactTemplates.ps1 | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Contacts/Invoke-ListContactTemplates.ps1 b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Contacts/Invoke-ListContactTemplates.ps1 index 05ae42c1ed52..0e26f9895474 100644 --- a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Contacts/Invoke-ListContactTemplates.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Contacts/Invoke-ListContactTemplates.ps1 @@ -1,9 +1,9 @@ -Function Invoke-ListContactTemplates { +function Invoke-ListContactTemplates { <# .FUNCTIONALITY Entrypoint,AnyTenant .ROLE - Exchange.Read + Exchange.Contact.Read #> [CmdletBinding()] param($Request, $TriggerMetadata) @@ -39,9 +39,9 @@ Function Invoke-ListContactTemplates { if (-not $Templates) { Write-LogMessage -headers $Headers -API $APIName -message "Template with ID $RequestedID not found" -sev 'Warn' return ([HttpResponseContext]@{ - StatusCode = [HttpStatusCode]::NotFound - Body = @{ Error = "Template with ID $RequestedID not found" } - }) + StatusCode = [HttpStatusCode]::NotFound + Body = @{ Error = "Template with ID $RequestedID not found" } + }) return } } else { From 547599be767ed1a711b210a4b71ac84db4cfc6d5 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Thu, 26 Feb 2026 21:23:04 -0500 Subject: [PATCH 16/28] Use raw SkuPartNumber for license names Stop using Convert-SKUname/convert-skuname and culture-based formatting for license display names. Use the raw SkuPartNumber value (with a fallback of 'Unknown License' when missing) and simplify list output. Also add informational logging of license objects. Changes touch Invoke-HuduExtensionSync.ps1 and Invoke-NinjaOneTenantSync.ps1 to avoid conversion errors and reduce formatting complexity. --- .../Public/Hudu/Invoke-HuduExtensionSync.ps1 | 9 ++++----- .../Public/NinjaOne/Invoke-NinjaOneTenantSync.ps1 | 13 ++++--------- 2 files changed, 8 insertions(+), 14 deletions(-) diff --git a/Modules/CippExtensions/Public/Hudu/Invoke-HuduExtensionSync.ps1 b/Modules/CippExtensions/Public/Hudu/Invoke-HuduExtensionSync.ps1 index fc68634b5f29..6639a99ff76f 100644 --- a/Modules/CippExtensions/Public/Hudu/Invoke-HuduExtensionSync.ps1 +++ b/Modules/CippExtensions/Public/Hudu/Invoke-HuduExtensionSync.ps1 @@ -246,7 +246,7 @@ function Invoke-HuduExtensionSync { $post = '' - $licenseOut = $Licenses | Where-Object { $_.PrepaidUnits.Enabled -gt 0 } | Select-Object @{N = 'License Name'; E = { Convert-SKUname -skuName $_.SkuPartNumber -ConvertTable $LicTable } }, @{N = 'Active'; E = { $_.PrepaidUnits.Enabled } }, @{N = 'Consumed'; E = { $_.ConsumedUnits } }, @{N = 'Unused'; E = { $_.PrepaidUnits.Enabled - $_.ConsumedUnits } } + $licenseOut = $Licenses | Where-Object { $_.PrepaidUnits.Enabled -gt 0 } | Select-Object @{N = 'License Name'; E = { $_.SkuPartNumber } }, @{N = 'Active'; E = { $_.PrepaidUnits.Enabled } }, @{N = 'Consumed'; E = { $_.ConsumedUnits } }, @{N = 'Unused'; E = { $_.PrepaidUnits.Enabled - $_.ConsumedUnits } } $licenseHTML = $licenseOut | ConvertTo-Html -PreContent $pre -PostContent $post -Fragment | Out-String } @@ -519,11 +519,10 @@ function Invoke-HuduExtensionSync { $userLicenses = ($user.AssignedLicenses.SkuID | ForEach-Object { $UserLic = $_ $SkuPartNumber = ($Licenses | Where-Object { $_.SkuId -eq $UserLic }).SkuPartNumber - $DisplayName = Convert-SKUname -skuName $SkuPartNumber -ConvertTable $LicTable - if (!$DisplayName) { - $DisplayName = $SkuPartNumber + if (!$SkuPartNumber) { + $SkuPartNumber = 'Unknown License' } - $DisplayName + $SkuPartNumber }) -join ', ' $UserOneDriveDetails = $OneDriveDetails | Where-Object { $_.ownerPrincipalName -eq $user.userPrincipalName } diff --git a/Modules/CippExtensions/Public/NinjaOne/Invoke-NinjaOneTenantSync.ps1 b/Modules/CippExtensions/Public/NinjaOne/Invoke-NinjaOneTenantSync.ps1 index bca90aa87eba..941c5883775e 100644 --- a/Modules/CippExtensions/Public/NinjaOne/Invoke-NinjaOneTenantSync.ps1 +++ b/Modules/CippExtensions/Public/NinjaOne/Invoke-NinjaOneTenantSync.ps1 @@ -358,7 +358,7 @@ function Invoke-NinjaOneTenantSync { # Get the license overview for the tenant if ($Licenses) { - $LicensesParsed = $Licenses | Where-Object { $_.PrepaidUnits.Enabled -gt 0 } | Select-Object @{N = 'License Name'; E = { (Get-Culture).TextInfo.ToTitleCase((convert-skuname -skuname $_.SkuPartNumber).Tolower()) } }, @{N = 'Active'; E = { $_.PrepaidUnits.Enabled } }, @{N = 'Consumed'; E = { $_.ConsumedUnits } }, @{N = 'Unused'; E = { $_.PrepaidUnits.Enabled - $_.ConsumedUnits } } + $LicensesParsed = $Licenses | Where-Object { $_.PrepaidUnits.Enabled -gt 0 } | Select-Object @{N = 'License Name'; E = { $_.skuPartNumber } }, @{N = 'Active'; E = { $_.PrepaidUnits.Enabled } }, @{N = 'Consumed'; E = { $_.ConsumedUnits } }, @{N = 'Unused'; E = { $_.PrepaidUnits.Enabled - $_.ConsumedUnits } } } Write-Verbose "$(Get-Date) - Parsing Device Compliance Policies" @@ -891,7 +891,7 @@ function Invoke-NinjaOneTenantSync { $UserLic = $_ try { $SkuPartNumber = ($Licenses | Where-Object { $_.SkuId -eq $UserLic }).SkuPartNumber - '
  • ' + "$((Get-Culture).TextInfo.ToTitleCase((convert-skuname -skuname $SkuPartNumber).Tolower()))
  • " + '
  • ' + "$($SkuPartNumber)
  • " } catch {} }) -join '' @@ -1385,13 +1385,8 @@ function Invoke-NinjaOneTenantSync { $LicenseDetails = foreach ($License in $Licenses) { $MatchedSubscriptions = $Subscriptions | Where-Object -Property skuid -EQ $License.skuId - - try { - $FriendlyLicenseName = $((Get-Culture).TextInfo.ToTitleCase((convert-skuname -skuname $License.SkuPartNumber).Tolower())) - } catch { - $FriendlyLicenseName = $License.SkuPartNumber - } - + Write-Information "License info: $($License | ConvertTo-Json -Depth 100)" + $FriendlyLicenseName = $License.skuPartNumber $LicenseUsers = foreach ($SubUser in $Users) { $MatchedLicense = $SubUser.assignedLicenses | Where-Object { $License.skuId -in $_.skuId } From 01aa9b5a6b158506e5edde1881146f014f747ad6 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Fri, 27 Feb 2026 00:18:53 -0500 Subject: [PATCH 17/28] fix: offboarding job conditions --- .../Administration/Users/Invoke-CIPPOffboardingJob.ps1 | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-CIPPOffboardingJob.ps1 b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-CIPPOffboardingJob.ps1 index b36cf8ea3987..60ca9abf1fa0 100644 --- a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-CIPPOffboardingJob.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-CIPPOffboardingJob.ps1 @@ -155,7 +155,7 @@ function Invoke-CIPPOffboardingJob { } } @{ - Condition = { ![string]::IsNullOrEmpty($Options.OnedriveAccess) } + Condition = { $Options.OnedriveAccess.Count -gt 0 } Cmdlet = 'Set-CIPPSharePointPerms' Parameters = @{ tenantFilter = $TenantFilter @@ -166,7 +166,7 @@ function Invoke-CIPPOffboardingJob { } } @{ - Condition = { ![string]::IsNullOrEmpty($Options.AccessNoAutomap) } + Condition = { $Options.AccessNoAutomap.Count -gt 0 } Cmdlet = 'Set-CIPPMailboxAccess' Parameters = @{ tenantFilter = $TenantFilter @@ -179,7 +179,7 @@ function Invoke-CIPPOffboardingJob { } } @{ - Condition = { ![string]::IsNullOrEmpty($Options.AccessAutomap) } + Condition = { $Options.AccessAutomap.Count -gt 0 } Cmdlet = 'Set-CIPPMailboxAccess' Parameters = @{ tenantFilter = $TenantFilter From 34ec15b4c91e174931cac320cf398b6ec03282a3 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Fri, 27 Feb 2026 15:05:31 +0800 Subject: [PATCH 18/28] Add RouteMessageOutboundConnector support --- .../Email-Exchange/Transport/Invoke-AddEditTransportRule.ps1 | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Email-Exchange/Transport/Invoke-AddEditTransportRule.ps1 b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Email-Exchange/Transport/Invoke-AddEditTransportRule.ps1 index 6d0cae8a4d61..0783685af1da 100644 --- a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Email-Exchange/Transport/Invoke-AddEditTransportRule.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Email-Exchange/Transport/Invoke-AddEditTransportRule.ps1 @@ -82,6 +82,7 @@ function Invoke-AddEditTransportRule { $DeleteMessage = $Request.Body.DeleteMessage $Quarantine = $Request.Body.Quarantine $RedirectMessageTo = $Request.Body.RedirectMessageTo + $RouteMessageOutboundConnector = $Request.Body.RouteMessageOutboundConnector $BlindCopyTo = $Request.Body.BlindCopyTo $CopyTo = $Request.Body.CopyTo $ModerateMessageByUser = $Request.Body.ModerateMessageByUser @@ -436,6 +437,7 @@ function Invoke-AddEditTransportRule { if ($null -ne $RedirectMessageTo -and $RedirectMessageTo.Count -gt 0) { $ruleParams.Add('RedirectMessageTo', $RedirectMessageTo) } + if ($null -ne$RouteMessageOutboundConnector) {$ruleParams.Add('RouteMessageOutboundConnector', $RouteMessageOutboundConnector)} if ($null -ne $BlindCopyTo -and $BlindCopyTo.Count -gt 0) { $ruleParams.Add('BlindCopyTo', $BlindCopyTo) } if ($null -ne $CopyTo -and $CopyTo.Count -gt 0) { $ruleParams.Add('CopyTo', $CopyTo) } if ($null -ne $ModerateMessageByUser -and $ModerateMessageByUser.Count -gt 0) { From db91dd0ad793f11ad820c6db4eab34b4e9804e9d Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Fri, 27 Feb 2026 11:05:22 +0100 Subject: [PATCH 19/28] fixes bug weith adding removing locations. --- .../Tenant/Conditional/Invoke-ExecNamedLocation.ps1 | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Tenant/Conditional/Invoke-ExecNamedLocation.ps1 b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Tenant/Conditional/Invoke-ExecNamedLocation.ps1 index 1a7c6b021b23..038e00dc022b 100644 --- a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Tenant/Conditional/Invoke-ExecNamedLocation.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Tenant/Conditional/Invoke-ExecNamedLocation.ps1 @@ -15,7 +15,10 @@ function Invoke-ExecNamedLocation { $TenantFilter = $Request.Body.tenantFilter ?? $Request.Query.tenantFilter $NamedLocationId = $Request.Body.namedLocationId ?? $Request.Query.namedLocationId $Change = $Request.Body.change ?? $Request.Query.change - $Content = $Request.Body.input.value ?? $Request.Query.input.value + $Content = $Request.Body.input ?? $Request.Query.input + #reintroduced because iut can come from autocomplete OR textinput. + if ($content.value) { $content = $content.value } + try { $Results = Set-CIPPNamedLocation -NamedLocationId $NamedLocationId -TenantFilter $TenantFilter -Change $Change -Content $Content -Headers $Headers From d91ef1a63b5d481b2941f2dbfef3174411533456 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Fri, 27 Feb 2026 21:20:08 +0800 Subject: [PATCH 20/28] Fix comparison object --- .../Standards/Invoke-CIPPStandardRetentionPolicyTag.ps1 | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardRetentionPolicyTag.ps1 b/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardRetentionPolicyTag.ps1 index f8e204371c98..1f0573da72fc 100644 --- a/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardRetentionPolicyTag.ps1 +++ b/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardRetentionPolicyTag.ps1 @@ -52,10 +52,12 @@ function Invoke-CIPPStandardRetentionPolicyTag { return } + $CurrentAgeLimitForRetention = ([timespan]$CurrentState.AgeLimitForRetention).TotalDays + $StateIsCorrect = ($CurrentState.Name -eq $PolicyName) -and ($CurrentState.RetentionEnabled -eq $true) -and ($CurrentState.RetentionAction -eq 'PermanentlyDelete') -and - ($CurrentState.AgeLimitForRetention -eq ([timespan]::FromDays($Settings.AgeLimitForRetention))) -and + ($CurrentAgeLimitForRetention -eq $Settings.AgeLimitForRetention) -and ($CurrentState.Type -eq 'DeletedItems') -and ($PolicyState.RetentionPolicyTagLinks -contains $PolicyName) @@ -125,7 +127,7 @@ function Invoke-CIPPStandardRetentionPolicyTag { $CurrentValue = @{ retentionEnabled = $CurrentState.RetentionEnabled retentionAction = $CurrentState.RetentionAction - ageLimitForRetention = $CurrentState.AgeLimitForRetention.TotalDays + ageLimitForRetention = $CurrentAgeLimitForRetention type = $CurrentState.Type policyTagLinked = $PolicyState.RetentionPolicyTagLinks -contains $PolicyName From e87d5e800e75c6def79394f14929d5c3a743eddc Mon Sep 17 00:00:00 2001 From: Jr7468 <126574444+Jr7468@users.noreply.github.com> Date: Fri, 27 Feb 2026 14:28:06 +0000 Subject: [PATCH 21/28] fix: Update role in Invoke-ExecDnsConfig.ps1 --- .../HTTP Functions/CIPP/Settings/Invoke-ExecDnsConfig.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecDnsConfig.ps1 b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecDnsConfig.ps1 index 04440b5926f7..50e56981f25f 100644 --- a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecDnsConfig.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecDnsConfig.ps1 @@ -3,7 +3,7 @@ function Invoke-ExecDnsConfig { .FUNCTIONALITY Entrypoint .ROLE - CIPP.AppSettings.ReadWrite + Tenant.Domains.ReadWrite #> [CmdletBinding()] param($Request, $TriggerMetadata) From 237467e75e03edc0e24053fda7230084670e317e Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Fri, 27 Feb 2026 15:44:17 +0100 Subject: [PATCH 22/28] fixes ordered sets in intune policies --- .../Public/Compare-CIPPIntuneObject.ps1 | 104 +++++++++++++++--- 1 file changed, 88 insertions(+), 16 deletions(-) diff --git a/Modules/CIPPCore/Public/Compare-CIPPIntuneObject.ps1 b/Modules/CIPPCore/Public/Compare-CIPPIntuneObject.ps1 index de8c605da2a0..0c7664dfed25 100644 --- a/Modules/CIPPCore/Public/Compare-CIPPIntuneObject.ps1 +++ b/Modules/CIPPCore/Public/Compare-CIPPIntuneObject.ps1 @@ -49,6 +49,38 @@ function Compare-CIPPIntuneObject { $excludeProps -contains $PropertyName) } + function ShouldCompareAsUnorderedSet { + param ( + [string]$PropertyPath + ) + # Properties that should be compared as sets (order doesn't matter) + $unorderedSetPatterns = @( + 'includeGroups', + 'excludeGroups', + 'includeUsers', + 'excludeUsers', + 'includeApplications', + 'excludeApplications', + 'includeRoles', + 'excludeRoles', + 'includePlatforms', + 'excludePlatforms', + 'includeLocations', + 'excludeLocations', + 'includeDevices', + 'excludeDevices', + 'includeGuestOrExternalUserTypes', + 'excludeGuestOrExternalUserTypes' + ) + + foreach ($pattern in $unorderedSetPatterns) { + if ($PropertyPath -match "\.$pattern(\.\d+)?$" -or $PropertyPath -eq $pattern) { + return $true + } + } + return $false + } + function Compare-ObjectsRecursively { param ( [Parameter(Mandatory = $true)] @@ -136,25 +168,65 @@ function Compare-CIPPIntuneObject { } } } elseif ($Object1 -is [Array] -or $Object1 -is [System.Collections.IList]) { - $maxLength = [Math]::Max($Object1.Count, $Object2.Count) - - for ($i = 0; $i -lt $maxLength; $i++) { - $newPath = "$PropertyPath.$i" - - if ($i -lt $Object1.Count -and $i -lt $Object2.Count) { - Compare-ObjectsRecursively -Object1 $Object1[$i] -Object2 $Object2[$i] -PropertyPath $newPath -Depth ($Depth + 1) -MaxDepth $MaxDepth - } elseif ($i -lt $Object1.Count) { + # Check if this array should be compared as an unordered set + if (ShouldCompareAsUnorderedSet -PropertyPath $PropertyPath) { + # For unordered sets, compare contents regardless of order + if ($Object1.Count -ne $Object2.Count) { + # Different lengths - report the difference $result.Add([PSCustomObject]@{ - Property = $newPath - ExpectedValue = $Object1[$i] - ReceivedValue = '' + Property = $PropertyPath + ExpectedValue = "Array with $($Object1.Count) items" + ReceivedValue = "Array with $($Object2.Count) items" }) } else { - $result.Add([PSCustomObject]@{ - Property = $newPath - ExpectedValue = '' - ReceivedValue = $Object2[$i] - }) + # Same length - check if all items exist in both arrays + $array1Sorted = @($Object1 | Sort-Object) + $array2Sorted = @($Object2 | Sort-Object) + + for ($i = 0; $i -lt $array1Sorted.Count; $i++) { + $item1 = $array1Sorted[$i] + $item2 = $array2Sorted[$i] + + # For primitive types, direct comparison + if ($item1 -is [string] -or $item1 -is [int] -or $item1 -is [long] -or $item1 -is [bool] -or $item1 -is [double] -or $item1 -is [decimal]) { + if ($item1 -ne $item2) { + # Items don't match even after sorting - arrays have different contents + $result.Add([PSCustomObject]@{ + Property = $PropertyPath + ExpectedValue = ($Object1 -join ', ') + ReceivedValue = ($Object2 -join ', ') + }) + break + } + } else { + # For complex objects, recursively compare with a generic path + $newPath = "$PropertyPath[$i]" + Compare-ObjectsRecursively -Object1 $item1 -Object2 $item2 -PropertyPath $newPath -Depth ($Depth + 1) -MaxDepth $MaxDepth + } + } + } + } else { + # Ordered array comparison (original behavior) + $maxLength = [Math]::Max($Object1.Count, $Object2.Count) + + for ($i = 0; $i -lt $maxLength; $i++) { + $newPath = "$PropertyPath.$i" + + if ($i -lt $Object1.Count -and $i -lt $Object2.Count) { + Compare-ObjectsRecursively -Object1 $Object1[$i] -Object2 $Object2[$i] -PropertyPath $newPath -Depth ($Depth + 1) -MaxDepth $MaxDepth + } elseif ($i -lt $Object1.Count) { + $result.Add([PSCustomObject]@{ + Property = $newPath + ExpectedValue = $Object1[$i] + ReceivedValue = '' + }) + } else { + $result.Add([PSCustomObject]@{ + Property = $newPath + ExpectedValue = '' + ReceivedValue = $Object2[$i] + }) + } } } } elseif ($Object1 -is [PSCustomObject] -or $Object1.PSObject.Properties.Count -gt 0) { From 1186fd6432dd8717656f2256aff7fa4e2223bf71 Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Fri, 27 Feb 2026 16:03:29 +0100 Subject: [PATCH 23/28] nuked source --- Modules/CIPPCore/Public/Compare-CIPPIntuneObject.ps1 | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Modules/CIPPCore/Public/Compare-CIPPIntuneObject.ps1 b/Modules/CIPPCore/Public/Compare-CIPPIntuneObject.ps1 index 0c7664dfed25..f8d591457071 100644 --- a/Modules/CIPPCore/Public/Compare-CIPPIntuneObject.ps1 +++ b/Modules/CIPPCore/Public/Compare-CIPPIntuneObject.ps1 @@ -34,7 +34,8 @@ function Compare-CIPPIntuneObject { 'agents', 'isSynced' 'locationInfo', - 'templateId' + 'templateId', + 'source' ) $excludeProps = $defaultExcludeProperties + $ExcludeProperties From cd45e78baccf3b5a26baad3de93c5fd8894de977 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Fri, 27 Feb 2026 11:26:59 -0500 Subject: [PATCH 24/28] Mark listStandardTemplates as AnyTenant Update the Invoke-listStandardTemplates function comment to include 'AnyTenant' in the .FUNCTIONALITY tag. This documents that the entrypoint can operate in an any-tenant context; no runtime logic was changed. --- .../Tenant/Standards/Invoke-listStandardTemplates.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-listStandardTemplates.ps1 b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-listStandardTemplates.ps1 index cd8821ae41a2..698fbb42deb3 100644 --- a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-listStandardTemplates.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-listStandardTemplates.ps1 @@ -1,7 +1,7 @@ function Invoke-listStandardTemplates { <# .FUNCTIONALITY - Entrypoint + Entrypoint,AnyTenant .ROLE Tenant.Standards.Read #> From 81b46f22251fde23fee7ecc430ec1e5570a23d3d Mon Sep 17 00:00:00 2001 From: John Duprey Date: Fri, 27 Feb 2026 11:27:21 -0500 Subject: [PATCH 25/28] fix: Handle SplitOverProps JSON errors and cleanup Wrap processing of entity.SplitOverProps in a try/catch/finally block to stop and handle ConvertFrom-Json errors (-ErrorAction Stop). On failure, emit a warning including the entity's PartitionKey and RowKey so problematic rows can be identified. Always remove the SplitOverProps property in the finally block to ensure cleanup and prevent leftover properties even when parsing fails. --- .../Public/Get-CIPPAzDatatableEntity.ps1 | 21 ++++++++++++------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/Modules/CIPPCore/Public/Get-CIPPAzDatatableEntity.ps1 b/Modules/CIPPCore/Public/Get-CIPPAzDatatableEntity.ps1 index cd1fbce70771..01def39a8b87 100644 --- a/Modules/CIPPCore/Public/Get-CIPPAzDatatableEntity.ps1 +++ b/Modules/CIPPCore/Public/Get-CIPPAzDatatableEntity.ps1 @@ -81,16 +81,21 @@ function Get-CIPPAzDataTableEntity { foreach ($entity in $finalResults) { if ($entity.SplitOverProps) { - $splitInfoList = $entity.SplitOverProps | ConvertFrom-Json - foreach ($splitInfo in $splitInfoList) { - $mergedData = [string]::Join('', ($splitInfo.SplitHeaders | ForEach-Object { $entity.$_ })) - $entity | Add-Member -NotePropertyName $splitInfo.OriginalHeader -NotePropertyValue $mergedData -Force - $propsToRemove = $splitInfo.SplitHeaders - foreach ($prop in $propsToRemove) { - $entity.PSObject.Properties.Remove($prop) + try { + $splitInfoList = $entity.SplitOverProps | ConvertFrom-Json -ErrorAction Stop + foreach ($splitInfo in $splitInfoList) { + $mergedData = [string]::Join('', ($splitInfo.SplitHeaders | ForEach-Object { $entity.$_ })) + $entity | Add-Member -NotePropertyName $splitInfo.OriginalHeader -NotePropertyValue $mergedData -Force + $propsToRemove = $splitInfo.SplitHeaders + foreach ($prop in $propsToRemove) { + $entity.PSObject.Properties.Remove($prop) + } } + } catch { + Write-Warning "Failed to process SplitOverProps for entity with PartitionKey='$($entity.PartitionKey)' and RowKey='$($entity.RowKey)': $($_.Exception.Message)" + } finally { + $entity.PSObject.Properties.Remove('SplitOverProps') } - $entity.PSObject.Properties.Remove('SplitOverProps') } } From cd2d86a1e09f0dac61e700ecfb3a2c502b9da3f6 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Fri, 27 Feb 2026 11:46:28 -0500 Subject: [PATCH 26/28] fix: Record permission update status and adjust retry logic Capture results from Add-CIPPApplicationPermission/Add-CIPPDelegatedPermission, detect and aggregate permission failures (excluding service principal creation failures), and log success/warn messages accordingly. Persist LastStatus and LastError to the CPV graph row so downstream logic knows whether the update succeeded. Also add an error log in the catch block. Update the orchestrator selection logic to use LastStatus when deciding retry interval: failed or missing statuses are retried after 1 day, successful tenants after 7 days. This makes retries for failing tenants more aggressive while avoiding unnecessary reprocessing of stable tenants. --- .../Push-UpdatePermissionsQueue.ps1 | 26 ++++++++++++++++--- .../Start-UpdatePermissionsOrchestrator.ps1 | 8 +++++- 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/Modules/CIPPCore/Public/Entrypoints/Activity Triggers/Push-UpdatePermissionsQueue.ps1 b/Modules/CIPPCore/Public/Entrypoints/Activity Triggers/Push-UpdatePermissionsQueue.ps1 index cbe2d2cbb47c..84f9ad4456b2 100644 --- a/Modules/CIPPCore/Public/Entrypoints/Activity Triggers/Push-UpdatePermissionsQueue.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/Activity Triggers/Push-UpdatePermissionsQueue.ps1 @@ -25,9 +25,24 @@ function Push-UpdatePermissionsQueue { $DomainRefreshRequired = $true } Write-Information 'Updating permissions' - Add-CIPPApplicationPermission -RequiredResourceAccess 'CIPPDefaults' -ApplicationId $env:ApplicationID -tenantfilter $Item.customerId - Add-CIPPDelegatedPermission -RequiredResourceAccess 'CIPPDefaults' -ApplicationId $env:ApplicationID -tenantfilter $Item.customerId - Write-LogMessage -tenant $Item.defaultDomainName -tenantId $Item.customerId -message "Updated permissions for $($Item.displayName)" -Sev 'Info' -API 'UpdatePermissionsQueue' + $AppResults = Add-CIPPApplicationPermission -RequiredResourceAccess 'CIPPDefaults' -ApplicationId $env:ApplicationID -tenantfilter $Item.customerId + $DelegatedResults = Add-CIPPDelegatedPermission -RequiredResourceAccess 'CIPPDefaults' -ApplicationId $env:ApplicationID -tenantfilter $Item.customerId + + # Check for permission failures (excluding service principal creation failures) + $AllResults = @($AppResults) + @($DelegatedResults) + $PermissionFailures = $AllResults | Where-Object { + $_ -like '*Failed*' -and + $_ -notlike '*Failed to create service principal*' + } + + if ($PermissionFailures) { + $Status = 'Failed' + $FailureMessage = ($PermissionFailures -join '; ') + Write-LogMessage -tenant $Item.defaultDomainName -tenantId $Item.customerId -message "Permission update completed with failures for $($Item.displayName): $FailureMessage" -Sev 'Warn' -API 'UpdatePermissionsQueue' + } else { + $Status = 'Success' + Write-LogMessage -tenant $Item.defaultDomainName -tenantId $Item.customerId -message "Updated permissions for $($Item.displayName)" -Sev 'Info' -API 'UpdatePermissionsQueue' + } if ($Item.defaultDomainName -ne 'PartnerTenant') { Write-Information 'Pushing CIPP-SAM admin roles' @@ -38,11 +53,15 @@ function Push-UpdatePermissionsQueue { $unixtime = [int64](([datetime]::UtcNow) - (Get-Date '1/1/1970')).TotalSeconds $GraphRequest = @{ LastApply = "$unixtime" + LastStatus = "$Status" applicationId = "$($env:ApplicationID)" Tenant = "$($Item.customerId)" PartitionKey = 'Tenant' RowKey = "$($Item.customerId)" } + if ($PermissionFailures) { + $GraphRequest.LastError = $FailureMessage + } Add-CIPPAzDataTableEntity @Table -Entity $GraphRequest -Force if ($DomainRefreshRequired) { @@ -53,5 +72,6 @@ function Push-UpdatePermissionsQueue { } } catch { Write-Information "Error updating permissions for $($Item.displayName)" + Write-LogMessage -tenant $Item.defaultDomainName -tenantId $Item.customerId -message "Error updating permissions for $($Item.displayName) - $($_.Exception.Message)" -Sev 'Error' -API 'UpdatePermissionsQueue' } } diff --git a/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-UpdatePermissionsOrchestrator.ps1 b/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-UpdatePermissionsOrchestrator.ps1 index 323c6d437219..0ccd44cd0c62 100644 --- a/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-UpdatePermissionsOrchestrator.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-UpdatePermissionsOrchestrator.ps1 @@ -43,7 +43,13 @@ function Start-UpdatePermissionsOrchestrator { $Tenants = $Tenants | ForEach-Object { $CPVRow = $CPVRows | Where-Object -Property Tenant -EQ $_.customerId - if (!$CPVRow -or $env:ApplicationID -notin $CPVRow.applicationId -or $SAMPermissions.Timestamp -gt $CPVRow.Timestamp.DateTime -or $CPVRow.Timestamp.DateTime -le (Get-Date).AddDays(-7).ToUniversalTime() -or !$_.defaultDomainName -or ($SAMroles.Timestamp.DateTime -gt $CPVRow.Timestamp.DateTime -and ($SAMRoles.Tenants -contains $_.defaultDomainName -or $SAMRoles.Tenants.value -contains $_.defaultDomainName -or $SAMRoles.Tenants -contains 'AllTenants' -or $SAMRoles.Tenants.value -contains 'AllTenants'))) { + + # Determine retry interval based on last status + # No status or Failed status: retry after 1 day, Success: retry after 7 days + $RetryDays = if (!$CPVRow.LastStatus -or $CPVRow.LastStatus -eq 'Failed') { -1 } else { -7 } + $NeedsRetry = $CPVRow.Timestamp.DateTime -le (Get-Date).AddDays($RetryDays).ToUniversalTime() + + if (!$CPVRow -or $env:ApplicationID -notin $CPVRow.applicationId -or $SAMPermissions.Timestamp -gt $CPVRow.Timestamp.DateTime -or $NeedsRetry -or !$_.defaultDomainName -or ($SAMroles.Timestamp.DateTime -gt $CPVRow.Timestamp.DateTime -and ($SAMRoles.Tenants -contains $_.defaultDomainName -or $SAMRoles.Tenants.value -contains $_.defaultDomainName -or $SAMRoles.Tenants -contains 'AllTenants' -or $SAMRoles.Tenants.value -contains 'AllTenants'))) { $_ } } From b5af99c44cb4317ccacdf1aa54b70da5e9a4a195 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Fri, 27 Feb 2026 11:47:40 -0500 Subject: [PATCH 27/28] fix: filter down to UserPrincipalName to limit object size --- .../Standards/Invoke-CIPPStandardMailboxRecipientLimits.ps1 | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardMailboxRecipientLimits.ps1 b/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardMailboxRecipientLimits.ps1 index ae62b19cd6eb..62e0fc53a654 100644 --- a/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardMailboxRecipientLimits.ps1 +++ b/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardMailboxRecipientLimits.ps1 @@ -215,7 +215,7 @@ function Invoke-CIPPStandardMailboxRecipientLimits { } # Add to alert objects list efficiently foreach ($Mailbox in $MailboxesToUpdate) { - $AlertObjects.Add($Mailbox) + $AlertObjects.Add($Mailbox.UserPrincipalName) } } @@ -232,7 +232,7 @@ function Invoke-CIPPStandardMailboxRecipientLimits { } # Add to alert objects list efficiently foreach ($Mailbox in $MailboxesWithPlanIssues) { - $AlertObjects.Add($Mailbox) + $AlertObjects.Add($Mailbox.UserPrincipalName) } } From e6f3c6a182a2c15ca03393b02882f67b3c68ea9e Mon Sep 17 00:00:00 2001 From: John Duprey Date: Fri, 27 Feb 2026 11:52:36 -0500 Subject: [PATCH 28/28] bump version --- host.json | 2 +- version_latest.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/host.json b/host.json index e215e52d2205..5462a094374b 100644 --- a/host.json +++ b/host.json @@ -16,7 +16,7 @@ "distributedTracingEnabled": false, "version": "None" }, - "defaultVersion": "10.1.1", + "defaultVersion": "10.1.2", "versionMatchStrategy": "Strict", "versionFailureStrategy": "Fail" } diff --git a/version_latest.txt b/version_latest.txt index 23127993ac05..b6132546fce8 100644 --- a/version_latest.txt +++ b/version_latest.txt @@ -1 +1 @@ -10.1.1 +10.1.2