From 334f5d3a2023c308360aa9f1d643291f4dfd273c Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Fri, 13 Feb 2026 09:52:52 +0100 Subject: [PATCH 01/97] POST Retry --- Modules/CIPPCore/Public/GraphHelper/New-GraphPOSTRequest.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Modules/CIPPCore/Public/GraphHelper/New-GraphPOSTRequest.ps1 b/Modules/CIPPCore/Public/GraphHelper/New-GraphPOSTRequest.ps1 index ebc8a4efc2dd..074cc701e07e 100644 --- a/Modules/CIPPCore/Public/GraphHelper/New-GraphPOSTRequest.ps1 +++ b/Modules/CIPPCore/Public/GraphHelper/New-GraphPOSTRequest.ps1 @@ -17,7 +17,7 @@ function New-GraphPOSTRequest { $contentType, $IgnoreErrors = $false, $returnHeaders = $false, - $maxRetries = 1 + $maxRetries = 3 ) if ($NoAuthCheck -or (Get-AuthorisedRequest -Uri $uri -TenantID $tenantid)) { From 2647486eb4305f4d2e93b326d781a6d193fcd635 Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Fri, 13 Feb 2026 12:18:05 +0100 Subject: [PATCH 02/97] first go at retry logic --- .../Public/GraphHelper/New-CIPPGraphRetry.ps1 | 94 +++++++++++++++++++ .../GraphHelper/New-GraphPOSTRequest.ps1 | 47 +++++++++- 2 files changed, 140 insertions(+), 1 deletion(-) create mode 100644 Modules/CIPPCore/Public/GraphHelper/New-CIPPGraphRetry.ps1 diff --git a/Modules/CIPPCore/Public/GraphHelper/New-CIPPGraphRetry.ps1 b/Modules/CIPPCore/Public/GraphHelper/New-CIPPGraphRetry.ps1 new file mode 100644 index 000000000000..4666d9b47d23 --- /dev/null +++ b/Modules/CIPPCore/Public/GraphHelper/New-CIPPGraphRetry.ps1 @@ -0,0 +1,94 @@ +function New-CIPPGraphRetry { + <# + .SYNOPSIS + Retries a failed Graph API request + .DESCRIPTION + This function is called by scheduled tasks when a Graph API request has exhausted retries. + It attempts to execute the request again with the original parameters. + .PARAMETER uri + The Graph API URI to call + .PARAMETER tenantid + The tenant ID for the request + .PARAMETER type + The HTTP method (POST, PATCH, DELETE, etc.) + .PARAMETER body + The request body + .PARAMETER scope + Optional OAuth scope + .PARAMETER AsApp + Whether to use application authentication + .PARAMETER NoAuthCheck + Whether to skip authorization check + .PARAMETER skipTokenCache + Whether to skip token cache + .PARAMETER AddedHeaders + Additional headers to include + .PARAMETER contentType + Content type for the request + .PARAMETER IgnoreErrors + Whether to ignore HTTP errors + .PARAMETER returnHeaders + Whether to return response headers + .PARAMETER maxRetries + Maximum number of retries + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$uri, + + [Parameter(Mandatory = $true)] + [string]$tenantid, + + [Parameter(Mandatory = $true)] + [string]$type, + + [string]$body, + [string]$scope, + [switch]$AsApp, + [switch]$NoAuthCheck, + [switch]$skipTokenCache, + [hashtable]$AddedHeaders, + [string]$contentType, + [bool]$IgnoreErrors, + [bool]$returnHeaders, + [int]$maxRetries = 3 + ) + + Write-Information "Retrying Graph API request for URI: $uri | Tenant: $tenantid" + + try { + # Build the parameter splat for New-GraphPOSTRequest + $GraphParams = @{ + uri = $uri + tenantid = $tenantid + type = $type + body = $body + maxRetries = $maxRetries + ScheduleRetry = $false # Do NOT schedule again if this retry fails + } + + # Add optional parameters if they were provided + if ($scope) { $GraphParams.scope = $scope } + if ($AsApp) { $GraphParams.AsApp = $AsApp } + if ($NoAuthCheck) { $GraphParams.NoAuthCheck = $NoAuthCheck } + if ($skipTokenCache) { $GraphParams.skipTokenCache = $skipTokenCache } + if ($AddedHeaders) { $GraphParams.AddedHeaders = $AddedHeaders } + if ($contentType) { $GraphParams.contentType = $contentType } + if ($IgnoreErrors) { $GraphParams.IgnoreErrors = $IgnoreErrors } + if ($returnHeaders) { $GraphParams.returnHeaders = $returnHeaders } + + # Execute the Graph request + $Result = New-GraphPOSTRequest @GraphParams + + Write-LogMessage -API 'GraphRetry' -message "Successfully retried Graph request for URI: $uri | Tenant: $tenantid" -Sev 'Info' -tenant $tenantid + + return $Result + } catch { + $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message + Write-LogMessage -API 'GraphRetry' -message "Failed to retry Graph request for URI: $uri | Tenant: $tenantid. Error: $ErrorMessage" -Sev 'Error' -tenant $tenantid + throw $ErrorMessage + } +} diff --git a/Modules/CIPPCore/Public/GraphHelper/New-GraphPOSTRequest.ps1 b/Modules/CIPPCore/Public/GraphHelper/New-GraphPOSTRequest.ps1 index 074cc701e07e..2bfdd9028f56 100644 --- a/Modules/CIPPCore/Public/GraphHelper/New-GraphPOSTRequest.ps1 +++ b/Modules/CIPPCore/Public/GraphHelper/New-GraphPOSTRequest.ps1 @@ -17,7 +17,8 @@ function New-GraphPOSTRequest { $contentType, $IgnoreErrors = $false, $returnHeaders = $false, - $maxRetries = 3 + $maxRetries = 3, + $ScheduleRetry = $false ) if ($NoAuthCheck -or (Get-AuthorisedRequest -Uri $uri -TenantID $tenantid)) { @@ -56,6 +57,50 @@ function New-GraphPOSTRequest { Start-Sleep -Seconds (2 * $x) } } while (($x -lt $maxRetries) -and ($success -eq $false)) + if (($maxRetries -and $success -eq $false) -and $ScheduleRetry -eq $true) { + #Create a scheduled task to retry the task later, when there is less pressure on the system, but only if ScheduledRetry is true. + try { + $TaskId = (New-Guid).Guid.ToString() + + # Prepare parameters for the retry + $RetryParameters = @{ + uri = $uri + tenantid = $tenantid + type = $type + body = $body + } + + # Add optional parameters if they were provided + if ($scope) { $RetryParameters.scope = $scope } + if ($AsApp) { $RetryParameters.AsApp = $AsApp } + if ($NoAuthCheck) { $RetryParameters.NoAuthCheck = $NoAuthCheck } + if ($skipTokenCache) { $RetryParameters.skipTokenCache = $skipTokenCache } + if ($AddedHeaders) { $RetryParameters.AddedHeaders = $AddedHeaders } + if ($contentType) { $RetryParameters.contentType = $contentType } + if ($IgnoreErrors) { $RetryParameters.IgnoreErrors = $IgnoreErrors } + if ($returnHeaders) { $RetryParameters.ReturnHeaders = $returnHeaders } + if ($maxRetries) { $RetryParameters.maxRetries = $maxRetries } + + # Create the scheduled task object + $TaskObject = [PSCustomObject]@{ + TenantFilter = $tenantid + Name = "Graph API Retry - $($uri -replace 'https://graph.microsoft.com/(beta|v1.0)/', '')" + Command = [PSCustomObject]@{ value = 'New-CIPPGraphRetry' } + Parameters = $RetryParameters + ScheduledTime = [int64](([datetime]::UtcNow.AddMinutes(15)) - (Get-Date '1/1/1970')).TotalSeconds + Recurrence = '0' + PostExecution = @{} + Reference = "GraphRetry-$TaskId" + } + + # Add the scheduled task (hidden = system task) + $null = Add-CIPPScheduledTask -Task $TaskObject -Hidden $true + + return @{Result = "Scheduled job with id $TaskId as Graph API was too busy to respond" } + } catch { + Write-Warning "Failed to schedule retry task: $($_.Exception.Message)" + } + } if ($success -eq $false) { throw $Message From cffa9bdc7828ea96342bf88acba1889d8b810f7f Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Fri, 13 Feb 2026 12:18:13 +0100 Subject: [PATCH 03/97] retry logic --- .../Public/GraphHelper/New-CIPPGraphRetry.ps1 | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/Modules/CIPPCore/Public/GraphHelper/New-CIPPGraphRetry.ps1 b/Modules/CIPPCore/Public/GraphHelper/New-CIPPGraphRetry.ps1 index 4666d9b47d23..eae86faad9d5 100644 --- a/Modules/CIPPCore/Public/GraphHelper/New-CIPPGraphRetry.ps1 +++ b/Modules/CIPPCore/Public/GraphHelper/New-CIPPGraphRetry.ps1 @@ -38,13 +38,13 @@ function New-CIPPGraphRetry { param( [Parameter(Mandatory = $true)] [string]$uri, - + [Parameter(Mandatory = $true)] [string]$tenantid, - + [Parameter(Mandatory = $true)] [string]$type, - + [string]$body, [string]$scope, [switch]$AsApp, @@ -56,9 +56,9 @@ function New-CIPPGraphRetry { [bool]$returnHeaders, [int]$maxRetries = 3 ) - + Write-Information "Retrying Graph API request for URI: $uri | Tenant: $tenantid" - + try { # Build the parameter splat for New-GraphPOSTRequest $GraphParams = @{ @@ -69,7 +69,7 @@ function New-CIPPGraphRetry { maxRetries = $maxRetries ScheduleRetry = $false # Do NOT schedule again if this retry fails } - + # Add optional parameters if they were provided if ($scope) { $GraphParams.scope = $scope } if ($AsApp) { $GraphParams.AsApp = $AsApp } @@ -79,12 +79,12 @@ function New-CIPPGraphRetry { if ($contentType) { $GraphParams.contentType = $contentType } if ($IgnoreErrors) { $GraphParams.IgnoreErrors = $IgnoreErrors } if ($returnHeaders) { $GraphParams.returnHeaders = $returnHeaders } - + # Execute the Graph request $Result = New-GraphPOSTRequest @GraphParams - + Write-LogMessage -API 'GraphRetry' -message "Successfully retried Graph request for URI: $uri | Tenant: $tenantid" -Sev 'Info' -tenant $tenantid - + return $Result } catch { $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message From f164421e0e3c93b50017cd1fc6006bb0a9770cc4 Mon Sep 17 00:00:00 2001 From: rvdwegen Date: Fri, 13 Feb 2026 14:00:57 +0100 Subject: [PATCH 04/97] universal search --- .../Invoke-ExecUniversalSearchV2.ps1 | 7 +- Modules/CIPPCore/Public/Search-CIPPDbData.ps1 | 107 +++++++++++++++--- 2 files changed, 94 insertions(+), 20 deletions(-) diff --git a/Modules/CIPPCore/Public/Entrypoints/Invoke-ExecUniversalSearchV2.ps1 b/Modules/CIPPCore/Public/Entrypoints/Invoke-ExecUniversalSearchV2.ps1 index 25cb9e964f4c..f110b9a4cab6 100644 --- a/Modules/CIPPCore/Public/Entrypoints/Invoke-ExecUniversalSearchV2.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/Invoke-ExecUniversalSearchV2.ps1 @@ -8,11 +8,14 @@ function Invoke-ExecUniversalSearchV2 { [CmdletBinding()] param($Request, $TriggerMetadata) - $TenantFilter = $Request.Query.tenantFilter $SearchTerms = $Request.Query.searchTerms $Limit = if ($Request.Query.limit) { [int]$Request.Query.limit } else { 10 } - $Results = Search-CIPPDbData -TenantFilter $TenantFilter -SearchTerms $SearchTerms -Types 'Users' -Limit $Limit + # Always search all tenants - do not pass TenantFilter parameter + $Results = Search-CIPPDbData -SearchTerms $SearchTerms -Types 'Users' -Limit $Limit -UserProperties 'id', 'userPrincipalName', 'displayName' + + + Write-Information "Results: $($Results | ConvertTo-Json -Depth 10)" return [HttpResponseContext]@{ StatusCode = [HttpStatusCode]::OK diff --git a/Modules/CIPPCore/Public/Search-CIPPDbData.ps1 b/Modules/CIPPCore/Public/Search-CIPPDbData.ps1 index a782821fa989..b5aca603896f 100644 --- a/Modules/CIPPCore/Public/Search-CIPPDbData.ps1 +++ b/Modules/CIPPCore/Public/Search-CIPPDbData.ps1 @@ -30,12 +30,24 @@ function Search-CIPPDbData { .PARAMETER Limit Maximum total number of results to return across all types. Default is unlimited (0) + .PARAMETER UserProperties + Array of property names to return for Users type. If not specified, all properties are returned. + Only applies when Types includes 'Users'. Valid properties include: id, accountEnabled, businessPhones, + city, createdDateTime, companyName, country, department, displayName, faxNumber, givenName, + isResourceAccount, jobTitle, mail, mailNickname, mobilePhone, onPremisesDistinguishedName, + officeLocation, onPremisesLastSyncDateTime, otherMails, postalCode, preferredDataLocation, + preferredLanguage, proxyAddresses, showInAddressList, state, streetAddress, surname, + usageLocation, userPrincipalName, userType, assignedLicenses, onPremisesSyncEnabled, signInActivity + .EXAMPLE Search-CIPPDbData -TenantFilter 'contoso.onmicrosoft.com' -SearchTerms 'john.doe' -Types 'Users', 'Groups' .EXAMPLE Search-CIPPDbData -SearchTerms 'admin' -Types 'Users' + .EXAMPLE + Search-CIPPDbData -SearchTerms 'admin' -Types 'Users' -UserProperties 'id', 'displayName', 'userPrincipalName', 'mail' + .EXAMPLE Search-CIPPDbData -SearchTerms 'SecurityDefaults', 'ConditionalAccess' -Types 'ConditionalAccessPolicies', 'Organization' @@ -69,7 +81,10 @@ function Search-CIPPDbData { [int]$MaxResultsPerType = 0, [Parameter(Mandatory = $false)] - [int]$Limit = 0 + [int]$Limit = 0, + + [Parameter(Mandatory = $false)] + [string[]]$UserProperties ) try { @@ -143,26 +158,82 @@ function Search-CIPPDbData { if ($IsMatch) { try { $Data = $Item.Data | ConvertFrom-Json - $ResultItem = [PSCustomObject]@{ - Tenant = $Item.PartitionKey - Type = $Type - RowKey = $Item.RowKey - Data = $Data - Timestamp = $Item.Timestamp - } - $Results.Add($ResultItem) - $TypeResultCount++ - # Check total limit first - if ($Limit -gt 0 -and $Results.Count -ge $Limit) { - Write-Verbose "Reached total limit of $Limit results" - break typeLoop + # For Users type with UserProperties, verify match is in target properties + $IsVerifiedMatch = $true + if ($Type -eq 'Users' -and $UserProperties -and $UserProperties.Count -gt 0) { + $IsVerifiedMatch = $false + + if ($MatchAll) { + # All search terms must match in target properties + $IsVerifiedMatch = $true + foreach ($SearchTerm in $SearchTerms) { + $SearchPattern = [regex]::Escape($SearchTerm) + $TermMatches = $false + foreach ($Property in $UserProperties) { + if ($Data.PSObject.Properties.Name -contains $Property -and + $null -ne $Data.$Property -and + $Data.$Property.ToString() -match $SearchPattern) { + $TermMatches = $true + break + } + } + if (-not $TermMatches) { + $IsVerifiedMatch = $false + break + } + } + } else { + # Any search term can match in target properties + foreach ($SearchTerm in $SearchTerms) { + $SearchPattern = [regex]::Escape($SearchTerm) + foreach ($Property in $UserProperties) { + if ($Data.PSObject.Properties.Name -contains $Property -and + $null -ne $Data.$Property -and + $Data.$Property.ToString() -match $SearchPattern) { + $IsVerifiedMatch = $true + break + } + } + if ($IsVerifiedMatch) { break } + } + } } - # Check max results per type - if ($MaxResultsPerType -gt 0 -and $TypeResultCount -ge $MaxResultsPerType) { - Write-Verbose "Reached max results per type ($MaxResultsPerType) for type '$Type'" - continue typeLoop + # Only add to results if verified (or not Users/UserProperties) + if ($IsVerifiedMatch) { + # Filter user properties if specified and type is Users + if ($Type -eq 'Users' -and $UserProperties -and $UserProperties.Count -gt 0) { + $FilteredData = [PSCustomObject]@{} + foreach ($Property in $UserProperties) { + if ($Data.PSObject.Properties.Name -contains $Property) { + $FilteredData | Add-Member -MemberType NoteProperty -Name $Property -Value $Data.$Property -Force + } + } + $Data = $FilteredData + } + + $ResultItem = [PSCustomObject]@{ + Tenant = $Item.PartitionKey + Type = $Type + RowKey = $Item.RowKey + Data = $Data + Timestamp = $Item.Timestamp + } + $Results.Add($ResultItem) + $TypeResultCount++ + + # Check total limit first (only for verified matches) + if ($Limit -gt 0 -and $Results.Count -ge $Limit) { + Write-Verbose "Reached total limit of $Limit results" + break typeLoop + } + + # Check max results per type (only for verified matches) + if ($MaxResultsPerType -gt 0 -and $TypeResultCount -ge $MaxResultsPerType) { + Write-Verbose "Reached max results per type ($MaxResultsPerType) for type '$Type'" + continue typeLoop + } } } catch { Write-Verbose "Failed to parse JSON for $($Item.RowKey): $($_.Exception.Message)" From cb04385a73a9c3246d2c0f200c330003e8ba91c2 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Fri, 13 Feb 2026 09:05:22 -0500 Subject: [PATCH 05/97] fix permission --- .../Administration/Contacts/Invoke-AddContactTemplates.ps1 | 2 +- .../Administration/Contacts/Invoke-EditContactTemplates.ps1 | 2 +- .../Administration/Contacts/Invoke-RemoveContactTemplates.ps1 | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Contacts/Invoke-AddContactTemplates.ps1 b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Contacts/Invoke-AddContactTemplates.ps1 index af9b208dded7..afdc1d1b1d98 100644 --- a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Contacts/Invoke-AddContactTemplates.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Contacts/Invoke-AddContactTemplates.ps1 @@ -3,7 +3,7 @@ Function Invoke-AddContactTemplates { .FUNCTIONALITY Entrypoint,AnyTenant .ROLE - Exchange.ReadWrite + Exchange.Contact.ReadWrite #> [CmdletBinding()] param($Request, $TriggerMetadata) diff --git a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Contacts/Invoke-EditContactTemplates.ps1 b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Contacts/Invoke-EditContactTemplates.ps1 index 8edcaf294af2..87faed97031a 100644 --- a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Contacts/Invoke-EditContactTemplates.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Contacts/Invoke-EditContactTemplates.ps1 @@ -3,7 +3,7 @@ Function Invoke-EditContactTemplates { .FUNCTIONALITY Entrypoint,AnyTenant .ROLE - Exchange.ReadWrite + Exchange.Contact.ReadWrite #> [CmdletBinding()] param($Request, $TriggerMetadata) diff --git a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Contacts/Invoke-RemoveContactTemplates.ps1 b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Contacts/Invoke-RemoveContactTemplates.ps1 index de4e79bf2b70..43b43dff8917 100644 --- a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Contacts/Invoke-RemoveContactTemplates.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Contacts/Invoke-RemoveContactTemplates.ps1 @@ -3,7 +3,7 @@ function Invoke-RemoveContactTemplates { .FUNCTIONALITY Entrypoint,AnyTenant .ROLE - Exchange.ReadWrite + Exchange.Contact.ReadWrite #> [CmdletBinding()] param($Request, $TriggerMetadata) From 8cd1a81885ec2c463b304e398d85b37b1685fa64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kristian=20Kj=C3=A6rg=C3=A5rd?= Date: Fri, 13 Feb 2026 14:05:15 +0100 Subject: [PATCH 06/97] chore: add .claude to .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 073300712d7e..93317828cfe5 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,7 @@ yarn.lock # Cursor IDE .cursor/rules +.claude # Ignore all root PowerShell files except profile.ps1 /*.ps1 From 72260b35192e68681020a4495ab2d147f3e056a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kristian=20Kj=C3=A6rg=C3=A5rd?= Date: Fri, 13 Feb 2026 16:47:09 +0100 Subject: [PATCH 07/97] fix: move AffectedDevices down in alert --- Modules/CIPPCore/Public/Alerts/Get-CIPPAlertVulnerabilities.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertVulnerabilities.ps1 b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertVulnerabilities.ps1 index f6cb1d37f0b8..7b6c374eecf7 100644 --- a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertVulnerabilities.ps1 +++ b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertVulnerabilities.ps1 @@ -81,7 +81,6 @@ function Get-CIPPAlertVulnerabilities { DaysOld = $DaysOld HoursOld = $HoursOld AffectedDeviceCount = $Group.Count - AffectedDevices = $AffectedDevices SoftwareName = $FirstVuln.softwareName SoftwareVendor = $FirstVuln.softwareVendor SoftwareVersion = $FirstVuln.softwareVersion @@ -90,6 +89,7 @@ function Get-CIPPAlertVulnerabilities { RecommendedUpdate = $FirstVuln.recommendedSecurityUpdate RecommendedUpdateId = $FirstVuln.recommendedSecurityUpdateId RecommendedUpdateUrl = $FirstVuln.recommendedSecurityUpdateUrl + AffectedDevices = $AffectedDevices Tenant = $TenantFilter } $AlertData.Add($VulnerabilityAlert) From 457f13d45862a98748129dca81a8b29417071974 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kristian=20Kj=C3=A6rg=C3=A5rd?= Date: Fri, 13 Feb 2026 16:48:26 +0100 Subject: [PATCH 08/97] refactor: change property type of affectedDevices in Invoke-ListDefenderTVM Changed 'affectedDevices' to create an array of objects instead of joining device names with commas. This makes them look a lot nicer in the tables. --- .../HTTP Functions/Endpoint/MEM/Invoke-ListDefenderTVM.ps1 | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ListDefenderTVM.ps1 b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ListDefenderTVM.ps1 index 3d6a59a25d46..ad2065be4e42 100644 --- a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ListDefenderTVM.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ListDefenderTVM.ps1 @@ -25,8 +25,8 @@ function Invoke-ListDefenderTVM { # Add all properties from the group with appropriate processing foreach ($property in $allProperties) { if ($property -eq 'deviceName') { - # Special handling for deviceName - join with comma - $obj['affectedDevices'] = ($cve.group.$property -join ', ') + # Special handling for deviceName - create array of objects + $obj['affectedDevices'] = @($cve.group.$property | ForEach-Object { @{ $property = $_ } }) } else { # For all other properties, get unique values $obj[$property] = ($cve.group.$property | Sort-Object -Unique) | Select-Object -First 1 From eecda8baa6343fb4d3c5457abede2c37410b0f89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kristian=20Kj=C3=A6rg=C3=A5rd?= Date: Fri, 13 Feb 2026 18:57:14 +0100 Subject: [PATCH 09/97] feat: add Invoke-ExecSyncDEP function for DEP sync --- .../Endpoint/MEM/Invoke-ExecSyncDEP.ps1 | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ExecSyncDEP.ps1 diff --git a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ExecSyncDEP.ps1 b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ExecSyncDEP.ps1 new file mode 100644 index 000000000000..5672f6a984ee --- /dev/null +++ b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ExecSyncDEP.ps1 @@ -0,0 +1,50 @@ +function Invoke-ExecSyncDEP { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Endpoint.MEM.ReadWrite + .DESCRIPTION + Syncs devices from Apple Business Manager to Intune + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers + + $TenantFilter = $Request.Body.tenantFilter + try { + $DepOnboardingSettings = @(New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/deviceManagement/depOnboardingSettings' -tenantid $TenantFilter) + + if ($null -eq $DepOnboardingSettings -or $DepOnboardingSettings.Count -eq 0) { + $Result = 'No Apple Business Manager connections found' + Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter -message $Result -Sev Info + } else { + $SyncCount = 0 + foreach ($DepSetting in $DepOnboardingSettings) { + if ($DepSetting.id) { + $null = New-GraphPOSTRequest -uri "https://graph.microsoft.com/beta/deviceManagement/depOnboardingSettings/$($DepSetting.id)/syncWithAppleDeviceEnrollmentProgram" -tenantid $TenantFilter + $SyncCount++ + } + } + if ($SyncCount -eq 0) { + $Result = 'No Apple Business Manager connections found' + } else { + $Result = "Successfully started device sync for $SyncCount Apple Business Manager connection$(if ($SyncCount -gt 1) { 's' })" + } + Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter -message $Result -Sev Info + } + $StatusCode = [HttpStatusCode]::OK + } catch { + $ErrorMessage = Get-CippException -Exception $_ + $Result = 'Failed to start Apple Business Manager device sync' + Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter -message $Result -Sev Error -LogData $ErrorMessage + $StatusCode = [HttpStatusCode]::Forbidden + } + + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = @{ Results = $Result } + }) + +} From 5d5492e8a8c34311259f59ce2708d43398007162 Mon Sep 17 00:00:00 2001 From: Bobby <31723128+kris6673@users.noreply.github.com> Date: Fri, 13 Feb 2026 20:29:14 +0100 Subject: [PATCH 10/97] chore: remove some useless logging and an unneeded null check --- .../Endpoint/Applications/Invoke-ExecSyncVPP.ps1 | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/Applications/Invoke-ExecSyncVPP.ps1 b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/Applications/Invoke-ExecSyncVPP.ps1 index 2dc3f4ed534a..d94b31cad906 100644 --- a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/Applications/Invoke-ExecSyncVPP.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/Applications/Invoke-ExecSyncVPP.ps1 @@ -9,9 +9,8 @@ function Invoke-ExecSyncVPP { param($Request, $TriggerMetadata) $APIName = $Request.Params.CIPPEndpoint $Headers = $Request.Headers - Write-LogMessage -Headers $Headers -API $APIName -message 'Accessed this API' -Sev Debug - $TenantFilter = $Request.Body.tenantFilter ?? $Request.Query.tenantFilter + $TenantFilter = $Request.Body.tenantFilter try { # Get all VPP tokens and sync them $VppTokens = New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/deviceAppManagement/vppTokens' -tenantid $TenantFilter | Where-Object { $_.state -eq 'valid' } From ac2c0e150d1781a3cc429e5d8130c709a7b99867 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Fri, 13 Feb 2026 14:30:24 -0500 Subject: [PATCH 11/97] Import: dedupe templates and return status Enhance Import-CommunityTemplate to detect duplicate templates (GroupTemplate, CATemplate, IntuneTemplate), preserve existing GUID/RowKey when updating, and skip imports when SHA matches (unless -Force). Introduce a $StatusMessage, log informative messages for create/update/skip cases, preserve Package from duplicates, and return the status string. Update callers (Invoke-ExecCommunityRepo and New-CIPPTemplateRun) to capture and use the import result (write/log it and include it in results), and pass Source where needed. These changes add feedback and prevent creating duplicate template records. --- .../Tools/GitHub/Invoke-ExecCommunityRepo.ps1 | 4 +- .../CIPPCore/Public/New-CIPPTemplateRun.ps1 | 7 +- .../Public/Tools/Import-CommunityTemplate.ps1 | 123 ++++++++++++++++-- 3 files changed, 122 insertions(+), 12 deletions(-) diff --git a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Tools/GitHub/Invoke-ExecCommunityRepo.ps1 b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Tools/GitHub/Invoke-ExecCommunityRepo.ps1 index ffe14702ce63..fd7feed3c243 100644 --- a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Tools/GitHub/Invoke-ExecCommunityRepo.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Tools/GitHub/Invoke-ExecCommunityRepo.ps1 @@ -178,10 +178,10 @@ function Invoke-ExecCommunityRepo { (Get-GitHubFileContents -FullName $FullName -Branch $Branch -Path $Location.path).content | ConvertFrom-Json } } - Import-CommunityTemplate -Template $Content -SHA $Template.sha -MigrationTable $MigrationTable -LocationData $LocationData + $ImportResult = Import-CommunityTemplate -Template $Content -SHA $Template.sha -MigrationTable $MigrationTable -LocationData $LocationData -Source $FullName $Results = @{ - resultText = 'Template imported' + resultText = $ImportResult ?? 'Template imported' state = 'success' } } catch { diff --git a/Modules/CIPPCore/Public/New-CIPPTemplateRun.ps1 b/Modules/CIPPCore/Public/New-CIPPTemplateRun.ps1 index 76ff020eb9ef..530a45c29fe5 100644 --- a/Modules/CIPPCore/Public/New-CIPPTemplateRun.ps1 +++ b/Modules/CIPPCore/Public/New-CIPPTemplateRun.ps1 @@ -64,8 +64,11 @@ function New-CIPPTemplateRun { if (!$ExistingTemplate -or $UpdateNeeded) { $Template = (Get-GitHubFileContents -FullName $TemplateSettings.templateRepo.value -Branch $TemplateSettings.templateRepoBranch.value -Path $File.path).content | ConvertFrom-Json - Import-CommunityTemplate -Template $Template -SHA $File.sha -MigrationTable $MigrationTable -LocationData $LocationData -Source $TemplateSettings.templateRepo.value - if ($UpdateNeeded) { + $ImportResult = Import-CommunityTemplate -Template $Template -SHA $File.sha -MigrationTable $MigrationTable -LocationData $LocationData -Source $TemplateSettings.templateRepo.value + if ($ImportResult) { + Write-Information $ImportResult + $ImportResult + } elseif ($UpdateNeeded) { Write-Information "Template $($File.name) needs to be updated as the SHA is different" "Template $($File.name) updated" } else { diff --git a/Modules/CIPPCore/Public/Tools/Import-CommunityTemplate.ps1 b/Modules/CIPPCore/Public/Tools/Import-CommunityTemplate.ps1 index d099f2a280f8..603df66a4c96 100644 --- a/Modules/CIPPCore/Public/Tools/Import-CommunityTemplate.ps1 +++ b/Modules/CIPPCore/Public/Tools/Import-CommunityTemplate.ps1 @@ -14,6 +14,7 @@ function Import-CommunityTemplate { ) $Table = Get-CippTable -TableName 'templates' + $StatusMessage = $null try { if ($Template.RowKey) { @@ -67,11 +68,20 @@ function Import-CommunityTemplate { $Template | Add-Member -MemberType NoteProperty -Name SHA -Value $SHA -Force $Template | Add-Member -MemberType NoteProperty -Name Source -Value $Source -Force Add-CIPPAzDataTableEntity @Table -Entity $Template -Force + + if ($Existing -and $Existing.SHA -ne $SHA) { + $StatusMessage = "Updated template '$($Template.RowKey)' from source '$Source' (SHA changed)." + } elseif ($Existing) { + $StatusMessage = "Template '$($Template.RowKey)' from source '$Source' is already up to date." + } else { + $StatusMessage = "Created template '$($Template.RowKey)' from source '$Source'." + } } else { $id = [guid]::NewGuid().ToString() if ($Template.mailNickname) { $Type = 'Group' } if ($Template.'@odata.type' -like '*conditionalAccessPolicy*') { $Type = 'ConditionalAccessPolicy' } Write-Host "The type is $Type" + switch -Wildcard ($Type) { '*Group*' { $RawJsonObj = [PSCustomObject]@{ @@ -82,12 +92,42 @@ function Import-CommunityTemplate { GUID = $id groupType = 'generic' } | ConvertTo-Json -Depth 100 + + # Check for duplicate template + $DuplicateFilter = "PartitionKey eq 'GroupTemplate'" + $ExistingTemplates = Get-CIPPAzDataTableEntity @Table -Filter $DuplicateFilter -ErrorAction SilentlyContinue + $Duplicate = $ExistingTemplates | Where-Object { + try { + $ExistingJSON = if (Test-Json $_.JSON -ErrorAction SilentlyContinue) { + $_.JSON | ConvertFrom-Json + } else { + $_.JSON + } + $ExistingJSON.Displayname -eq $Template.displayName -and $_.Source -eq $Source + } catch { + $false + } + } | Select-Object -First 1 + + if ($Duplicate -and $Duplicate.SHA -eq $SHA -and -not $Force) { + $StatusMessage = "Group template '$($Template.displayName)' from source '$Source' is already up to date. Skipping import." + Write-Information $StatusMessage + break + } + + if ($Duplicate) { + $StatusMessage = "Updating Group template '$($Template.displayName)' from source '$Source' (SHA changed)." + Write-Information $StatusMessage + } else { + $StatusMessage = "Created Group template '$($Template.displayName)' from source '$Source'." + } + $entity = @{ JSON = "$RawJsonObj" PartitionKey = 'GroupTemplate' SHA = $SHA - GUID = $id - RowKey = $id + GUID = if ($Duplicate) { $Duplicate.GUID } else { $id } + RowKey = if ($Duplicate) { $Duplicate.RowKey } else { $id } Source = $Source } Add-CIPPAzDataTableEntity @Table -Entity $entity -Force @@ -125,12 +165,41 @@ function Import-CommunityTemplate { } } + # Check for duplicate template + $DuplicateFilter = "PartitionKey eq 'CATemplate'" + $ExistingTemplates = Get-CIPPAzDataTableEntity @Table -Filter $DuplicateFilter -ErrorAction SilentlyContinue + $Duplicate = $ExistingTemplates | Where-Object { + try { + $ExistingJSON = if (Test-Json $_.JSON -ErrorAction SilentlyContinue) { + $_.JSON | ConvertFrom-Json + } else { + $_.JSON + } + $ExistingJSON.displayName -eq $Template.displayName -and $_.Source -eq $Source + } catch { + $false + } + } | Select-Object -First 1 + + if ($Duplicate -and $Duplicate.SHA -eq $SHA -and -not $Force) { + $StatusMessage = "Conditional Access template '$($Template.displayName)' from source '$Source' is already up to date. Skipping import." + Write-Information $StatusMessage + break + } + + if ($Duplicate) { + $StatusMessage = "Updating Conditional Access template '$($Template.displayName)' from source '$Source' (SHA changed)." + Write-Information $StatusMessage + } else { + $StatusMessage = "Created Conditional Access template '$($Template.displayName)' from source '$Source'." + } + $entity = @{ JSON = "$RawJson" PartitionKey = 'CATemplate' SHA = $SHA - GUID = $id - RowKey = $id + GUID = if ($Duplicate) { $Duplicate.GUID } else { $id } + RowKey = if ($Duplicate) { $Duplicate.RowKey } else { $id } Source = $Source } Write-Information "Final entity: $($entity | ConvertTo-Json -Depth 10)" @@ -153,20 +222,51 @@ function Import-CommunityTemplate { $RawJson = $RawJson | ConvertTo-Json -Depth 100 -Compress #create a new template + $DisplayName = $Template.displayName ?? $template.Name + $RawJsonObj = [PSCustomObject]@{ - Displayname = $Template.displayName ?? $template.Name + Displayname = $DisplayName Description = $Template.Description RAWJson = $RawJson Type = $URLName GUID = $id } | ConvertTo-Json -Depth 100 -Compress + # Check for duplicate template + $DuplicateFilter = "PartitionKey eq 'IntuneTemplate'" + $ExistingTemplates = Get-CIPPAzDataTableEntity @Table -Filter $DuplicateFilter -ErrorAction SilentlyContinue + $Duplicate = $ExistingTemplates | Where-Object { + try { + $ExistingJSON = if (Test-Json $_.JSON -ErrorAction SilentlyContinue) { + $_.JSON | ConvertFrom-Json + } else { + $_.JSON + } + $ExistingJSON.Displayname -eq $DisplayName -and $_.Source -eq $Source + } catch { + $false + } + } | Select-Object -First 1 + + if ($Duplicate -and $Duplicate.SHA -eq $SHA -and -not $Force) { + $StatusMessage = "Intune template '$DisplayName' from source '$Source' is already up to date. Skipping import." + Write-Information $StatusMessage + return $StatusMessage + } + + if ($Duplicate) { + $StatusMessage = "Updating Intune template '$DisplayName' from source '$Source' (SHA changed)." + Write-Information $StatusMessage + } else { + $StatusMessage = "Created Intune template '$DisplayName' from source '$Source'." + } + $entity = @{ JSON = "$RawJsonObj" PartitionKey = 'IntuneTemplate' SHA = $SHA - GUID = $id - RowKey = $id + GUID = if ($Duplicate) { $Duplicate.GUID } else { $id } + RowKey = if ($Duplicate) { $Duplicate.RowKey } else { $id } Source = $Source } @@ -174,13 +274,20 @@ function Import-CommunityTemplate { $entity.Package = $Existing.Package } + if ($Duplicate -and $Duplicate.Package) { + $entity.Package = $Duplicate.Package + } + Add-CIPPAzDataTableEntity @Table -Entity $entity -Force } } } } catch { - Write-Warning "Community template import failed. Error: $($_.Exception.Message)" + $StatusMessage = "Community template import failed. Error: $($_.Exception.Message)" + Write-Warning $StatusMessage Write-Information $_.InvocationInfo.PositionMessage } + + return $StatusMessage } From 454154c43aa04ea475404ecfdeaa5b4e7e7633fa Mon Sep 17 00:00:00 2001 From: Logan Cook <2997336+MWG-Logan@users.noreply.github.com> Date: Fri, 13 Feb 2026 16:47:26 -0500 Subject: [PATCH 12/97] fix(reusable-settings): better normalize reusable setting metadata --- .../Get-CIPPReusableSettingsFromPolicy.ps1 | 64 +++++++++- .../Remove-CIPPReusableSettingMetadata.ps1 | 118 +++++++++++++++--- ...AddIntuneReusableSettingTemplate.Tests.ps1 | 34 ++++- 3 files changed, 194 insertions(+), 22 deletions(-) diff --git a/Modules/CIPPCore/Public/Get-CIPPReusableSettingsFromPolicy.ps1 b/Modules/CIPPCore/Public/Get-CIPPReusableSettingsFromPolicy.ps1 index 8e331a484f00..f7a8e8c32ce2 100644 --- a/Modules/CIPPCore/Public/Get-CIPPReusableSettingsFromPolicy.ps1 +++ b/Modules/CIPPCore/Public/Get-CIPPReusableSettingsFromPolicy.ps1 @@ -98,12 +98,26 @@ function Get-CIPPReusableSettingsFromPolicy { foreach ($settingId in $referencedReusableIds) { try { $setting = New-GraphGETRequest -Uri "https://graph.microsoft.com/beta/deviceManagement/reusablePolicySettings/$settingId" -tenantid $Tenant - if (-not $setting) { + if ($null -eq $setting) { Write-LogMessage -headers $Headers -API $APIName -message "Reusable setting $settingId not returned from Graph" -Sev 'Warn' continue } - $settingDisplayName = $setting.displayName + # Normalize Graph SDK objects into PSCustomObject to ensure cleanup works consistently + $settingNormalized = [ordered]@{} + foreach ($prop in $setting.PSObject.Properties) { + $settingNormalized[$prop.Name] = $prop.Value + } + + if ($settingNormalized.Count -eq 0) { + foreach ($prop in $setting.GetType().GetProperties()) { + $settingNormalized[$prop.Name] = $prop.GetValue($setting) + } + } + + $settingNormalized = $settingNormalized | ConvertTo-Json -Depth 100 -Compress | ConvertFrom-Json -Depth 100 + + $settingDisplayName = $setting.displayName ?? $settingNormalized.displayName if (-not $settingDisplayName) { Write-LogMessage -headers $Headers -API $APIName -message "Reusable setting $settingId missing displayName" -Sev 'Warn' continue @@ -112,9 +126,10 @@ function Get-CIPPReusableSettingsFromPolicy { $matchedTemplate = $existingReusableByName[$settingDisplayName] $templateGuid = $matchedTemplate.RowKey + $cleanSetting = Remove-CIPPReusableSettingMetadata -InputObject $settingNormalized + $sanitizedJson = $cleanSetting | ConvertTo-Json -Depth 100 -Compress + if (-not $templateGuid) { - $cleanSetting = Remove-CIPPReusableSettingMetadata -InputObject $setting - $sanitizedJson = $cleanSetting | ConvertTo-Json -Depth 100 -Compress $templateGuid = (New-Guid).Guid $reusableEntity = [pscustomobject]@{ DisplayName = $settingDisplayName @@ -139,7 +154,46 @@ function Get-CIPPReusableSettingsFromPolicy { Write-LogMessage -headers $Headers -API $APIName -message "Created reusable setting template $templateGuid for '$settingDisplayName'" -Sev 'Info' } else { - Write-LogMessage -headers $Headers -API $APIName -message "Reusing existing reusable setting template $templateGuid for '$settingDisplayName'" -Sev 'Info' + $existingRawJson = $matchedTemplate.RawJSON + if (-not $existingRawJson) { + $existingParsed = $matchedTemplate.JSON | ConvertFrom-Json -ErrorAction SilentlyContinue + $existingRawJson = $existingParsed.RawJSON + } + + $requiresNormalization = $false + if ($existingRawJson -and $existingRawJson -match '"children"\s*:\s*null') { + $requiresNormalization = $true + } + + if ($requiresNormalization) { + $reusableEntity = [pscustomobject]@{ + DisplayName = $settingDisplayName + Description = $setting.description + RawJSON = $sanitizedJson + GUID = $templateGuid + } | ConvertTo-Json -Depth 100 -Compress + + Add-CIPPAzDataTableEntity @templatesTableForAdd -Entity @{ + JSON = "$reusableEntity" + RowKey = "$templateGuid" + PartitionKey = 'IntuneReusableSettingTemplate' + GUID = "$templateGuid" + DisplayName = $settingDisplayName + Description = $setting.description + RawJSON = "$sanitizedJson" + } + + $existingReusableByName[$settingDisplayName] = [pscustomobject]@{ + RowKey = $templateGuid + DisplayName = $settingDisplayName + JSON = $reusableEntity + RawJSON = $sanitizedJson + } + + Write-LogMessage -headers $Headers -API $APIName -message "Normalized reusable setting template $templateGuid for '$settingDisplayName'" -Sev 'Info' + } else { + Write-LogMessage -headers $Headers -API $APIName -message "Reusing existing reusable setting template $templateGuid for '$settingDisplayName'" -Sev 'Info' + } } $result.ReusableSettings.Add([pscustomobject]@{ diff --git a/Modules/CIPPCore/Public/Remove-CIPPReusableSettingMetadata.ps1 b/Modules/CIPPCore/Public/Remove-CIPPReusableSettingMetadata.ps1 index 3a6fcba20866..07ba8b6da4ad 100644 --- a/Modules/CIPPCore/Public/Remove-CIPPReusableSettingMetadata.ps1 +++ b/Modules/CIPPCore/Public/Remove-CIPPReusableSettingMetadata.ps1 @@ -1,23 +1,113 @@ function Remove-CIPPReusableSettingMetadata { param($InputObject) - if ($null -eq $InputObject) { return $null } + $metadataFields = @( + 'id', + 'createdDateTime', + 'lastModifiedDateTime', + 'version', + '@odata.context', + '@odata.etag', + 'referencingConfigurationPolicyCount', + 'settingInstanceTemplateReference', + 'settingValueTemplateReference', + 'auditRuleInformation' + ) - if ($InputObject -is [System.Collections.IEnumerable] -and $InputObject -isnot [string]) { - $cleanArray = [System.Collections.Generic.List[object]]::new() - foreach ($item in $InputObject) { $cleanArray.Add((Remove-CIPPReusableSettingMetadata -InputObject $item)) } - return $cleanArray - } + function Normalize-Object { + param($Value) + + if ($null -eq $Value) { return $null } - if ($InputObject -is [psobject]) { - $output = [ordered]@{} - foreach ($prop in $InputObject.PSObject.Properties) { - if ($null -eq $prop.Value) { continue } - if ($prop.Name -in @('id','createdDateTime','lastModifiedDateTime','version','@odata.context','@odata.etag','referencingConfigurationPolicyCount','settingInstanceTemplateReference','settingValueTemplateReference','auditRuleInformation')) { continue } - $output[$prop.Name] = Remove-CIPPReusableSettingMetadata -InputObject $prop.Value + function Test-IsCollection { + param($Candidate) + return ( + $Candidate -is [System.Collections.IEnumerable] -and + $Candidate -isnot [string] -and + ( + $Candidate -is [System.Array] -or + $Candidate -is [System.Collections.IList] -or + $Candidate -is [System.Collections.ICollection] + ) + ) } - return [pscustomobject]$output + + function Normalize-Entries { + param($Entries) + + $output = [ordered]@{} + foreach ($entry in $Entries) { + $name = $entry.Name + $item = $entry.Value + + if ($name -ieq 'children') { + if ($null -eq $item) { + $output[$name] = @() + } elseif (Test-IsCollection -Candidate $item) { + $output[$name] = Normalize-Object -Value $item + } else { + $output[$name] = @(Normalize-Object -Value $item) + } + continue + } + + if ($name -ieq 'groupSettingCollectionValue') { + if ($null -eq $item) { + $output[$name] = @() + continue + } + + if (Test-IsCollection -Candidate $item) { + $output[$name] = Normalize-Object -Value $item + } else { + $output[$name] = @(Normalize-Object -Value $item) + } + continue + } + + if ($null -eq $item) { continue } + if ($name -in $metadataFields) { continue } + $output[$name] = Normalize-Object -Value $item + } + + if ($output.Contains('children') -and -not (Test-IsCollection -Candidate $output['children'])) { + $output['children'] = @($output['children']) + } + + if ( + $output.Contains('groupSettingCollectionValue') -and + -not (Test-IsCollection -Candidate $output['groupSettingCollectionValue']) + ) { + $output['groupSettingCollectionValue'] = @($output['groupSettingCollectionValue']) + } + + return [pscustomobject]$output + } + + if ($Value -is [System.Collections.IDictionary]) { + $entries = foreach ($key in $Value.Keys) { + [pscustomobject]@{ Name = $key; Value = $Value[$key] } + } + return Normalize-Entries -Entries $entries + } + + if ($Value -is [System.Collections.IEnumerable] -and $Value -isnot [string]) { + $cleanArray = [System.Collections.Generic.List[object]]::new() + foreach ($entry in $Value) { + $cleanArray.Add((Normalize-Object -Value $entry)) + } + return $cleanArray + } + + if ($Value -is [psobject]) { + $entries = foreach ($prop in $Value.PSObject.Properties) { + [pscustomobject]@{ Name = $prop.Name; Value = $prop.Value } + } + return Normalize-Entries -Entries $entries + } + + return $Value } - return $InputObject + return Normalize-Object -Value $InputObject } diff --git a/Tests/Endpoint/Invoke-AddIntuneReusableSettingTemplate.Tests.ps1 b/Tests/Endpoint/Invoke-AddIntuneReusableSettingTemplate.Tests.ps1 index 14a79de3fca1..d29a4530611f 100644 --- a/Tests/Endpoint/Invoke-AddIntuneReusableSettingTemplate.Tests.ps1 +++ b/Tests/Endpoint/Invoke-AddIntuneReusableSettingTemplate.Tests.ps1 @@ -4,6 +4,7 @@ BeforeAll { $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) $FunctionPath = Join-Path $RepoRoot 'Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-AddIntuneReusableSettingTemplate.ps1' + $MetadataPath = Join-Path $RepoRoot 'Modules/CIPPCore/Public/Remove-CIPPReusableSettingMetadata.ps1' class HttpResponseContext { [int]$StatusCode @@ -19,9 +20,7 @@ BeforeAll { [pscustomobject]@{ NormalizedError = $Exception } } - # Pass-through for metadata cleanup used in the function - function Remove-CIPPReusableSettingMetadata { param($InputObject) $InputObject } - + . $MetadataPath . $FunctionPath } @@ -72,4 +71,33 @@ Describe 'Invoke-AddIntuneReusableSettingTemplate' { $response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::InternalServerError) $response.Body.Results | Should -Match 'RawJSON is not valid JSON' } + + It 'normalizes children null values in reusable setting templates' { + $request = [pscustomobject]@{ + Params = @{ CIPPEndpoint = 'AddIntuneReusableSettingTemplate' } + Headers = @{ Authorization = 'Bearer token' } + Body = [pscustomobject]@{ + displayName = 'Template With Children' + rawJSON = '{"displayName":"Template With Children","settingInstance":{"groupSettingCollectionValue":[{"children":[{"choiceSettingValue":{"children":null}}]}]}}' + GUID = 'template-children' + } + } + + $parsed = $request.Body.rawJSON | ConvertFrom-Json -Depth 100 + $clean = Remove-CIPPReusableSettingMetadata -InputObject $parsed + $clean.settingInstance.PSObject.Properties.Name | Should -Contain 'groupSettingCollectionValue' + $clean.settingInstance.groupSettingCollectionValue | Should -Not -BeNullOrEmpty + $clean.settingInstance.groupSettingCollectionValue.GetType().FullName | Should -Be 'System.Object[]' + ($clean.settingInstance.groupSettingCollectionValue -is [System.Collections.IEnumerable]) | Should -BeTrue + ($clean.settingInstance.groupSettingCollectionValue | Measure-Object).Count | Should -Be 1 + ($clean.settingInstance.groupSettingCollectionValue[0].children -is [System.Collections.IEnumerable]) | Should -BeTrue + ($clean.settingInstance.groupSettingCollectionValue[0].children | Measure-Object).Count | Should -Be 1 + ($clean.settingInstance.groupSettingCollectionValue[0].children[0].choiceSettingValue.children -is [System.Collections.IEnumerable]) | Should -BeTrue + + $response = Invoke-AddIntuneReusableSettingTemplate -Request $request -TriggerMetadata $null + + $response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::OK) + $lastEntity.RawJSON | Should -Not -Match '"children":null' + $lastEntity.RawJSON | Should -Match '"children":\[\]' + } } From 4cad64883880824b19b0694fd1b2f27b7891d781 Mon Sep 17 00:00:00 2001 From: Bobby <31723128+kris6673@users.noreply.github.com> Date: Sat, 14 Feb 2026 00:34:45 +0100 Subject: [PATCH 13/97] feat: add assignment filter handling in Invoke-AddPolicy - Introduced logic to handle AssignmentFilterName and AssignmentFilterType. - Updated parameters for Set-CIPPIntunePolicy to include assignment filter details if provided. --- .../Endpoint/MEM/Invoke-AddPolicy.ps1 | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-AddPolicy.ps1 b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-AddPolicy.ps1 index 92dfb409d341..1c633f205e49 100644 --- a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-AddPolicy.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-AddPolicy.ps1 @@ -17,6 +17,15 @@ function Invoke-AddPolicy { $description = $Request.Body.Description $AssignTo = if ($Request.Body.AssignTo -ne 'on') { $Request.Body.AssignTo } $ExcludeGroup = $Request.Body.excludeGroup + $AssignmentFilterSelection = $Request.Body.AssignmentFilterName ?? $Request.Body.assignmentFilter + $AssignmentFilterType = $Request.Body.AssignmentFilterType ?? $Request.Body.assignmentFilterType + $AssignmentFilterName = switch ($AssignmentFilterSelection) { + { $_ -is [string] } { $_; break } + { $_ -and $_.PSObject.Properties['value'] } { $_.value; break } + { $_ -and $_.PSObject.Properties['displayName'] } { $_.displayName; break } + { $_ -and $_.PSObject.Properties['label'] } { $_.label; break } + default { $null } + } $Request.Body.customGroup ? ($AssignTo = $Request.Body.customGroup) : $null $RawJSON = $Request.Body.RAWJson @@ -70,6 +79,12 @@ function Invoke-AddPolicy { Headers = $Headers APIName = $APIName } + + if (-not [string]::IsNullOrWhiteSpace($AssignmentFilterName)) { + $params.AssignmentFilterName = $AssignmentFilterName + $params.AssignmentFilterType = [string]::IsNullOrWhiteSpace($AssignmentFilterType) ? 'include' : $AssignmentFilterType + } + Set-CIPPIntunePolicy @params } catch { "$($_.Exception.Message)" From 0f5efdc4c250f639afe1542405ab585b71373590 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Sat, 14 Feb 2026 17:48:37 +0800 Subject: [PATCH 14/97] Update Invoke-AddUser.ps1 --- .../Identity/Administration/Users/Invoke-AddUser.ps1 | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-AddUser.ps1 b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-AddUser.ps1 index 5fce41b7da5b..f7637cfa04d8 100644 --- a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-AddUser.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-AddUser.ps1 @@ -75,8 +75,10 @@ function Invoke-AddUser { 'User' = $CreationResults.User } } catch { + $ErrorMessage = $_.TargetObject.Results -join ' ' + $ErrorMessage = [string]::IsNullOrWhiteSpace($ErrorMessage) ? $_.Exception.Message : $ErrorMessage $body = [pscustomobject] @{ - 'Results' = @("$($_.Exception.Message)") + 'Results' = @("$ErrorMessage") } $StatusCode = [HttpStatusCode]::InternalServerError } From 9542e72d1c980adc87c680411846c17d8bdd8bc7 Mon Sep 17 00:00:00 2001 From: Bobby <31723128+kris6673@users.noreply.github.com> Date: Sat, 14 Feb 2026 11:08:35 +0100 Subject: [PATCH 15/97] fix: accidental pipeline output in New-CIPPCAPolicy when creating named locations When creating a new named location, the uncaptured Select-Object on line 198 leaked an id-less object into $LocationLookupTable. This caused duplicate lookup matches where $lookup.id resolved to @($null, "guid"), producing invalid nested-array JSON in excludeLocations/includeLocations. Fixes https://github.com/KelvinTegelaar/CIPP/issues/5368 --- Modules/CIPPCore/Public/New-CIPPCAPolicy.ps1 | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/Modules/CIPPCore/Public/New-CIPPCAPolicy.ps1 b/Modules/CIPPCore/Public/New-CIPPCAPolicy.ps1 index 1f908ff4e0f2..8a713165f482 100644 --- a/Modules/CIPPCore/Public/New-CIPPCAPolicy.ps1 +++ b/Modules/CIPPCore/Public/New-CIPPCAPolicy.ps1 @@ -195,18 +195,19 @@ function New-CIPPCAPolicy { } } else { if ($location.countriesAndRegions) { $location.countriesAndRegions = @($location.countriesAndRegions) } - $location | Select-Object * -ExcludeProperty id - Remove-ODataProperties -Object $location - $Body = ConvertTo-Json -InputObject $Location + $LocationBody = $location | Select-Object * -ExcludeProperty id + Remove-ODataProperties -Object $LocationBody + $Body = ConvertTo-Json -InputObject $LocationBody $GraphRequest = New-GraphPOSTRequest -uri 'https://graph.microsoft.com/beta/identity/conditionalAccess/namedLocations' -body $body -Type POST -tenantid $tenantfilter -asApp $true $retryCount = 0 + $MaxRetryCount = 10 do { Write-Host "Checking for location $($GraphRequest.id) attempt $retryCount. $TenantFilter" $LocationRequest = New-GraphGETRequest -uri 'https://graph.microsoft.com/beta/identity/conditionalAccess/namedLocations' -tenantid $tenantfilter -asApp $true | Where-Object -Property id -EQ $GraphRequest.id Write-Host "LocationRequest: $($LocationRequest.id)" Start-Sleep -Seconds 2 $retryCount++ - } while ((!$LocationRequest -or !$LocationRequest.id) -and ($retryCount -lt 5)) + } while ((!$LocationRequest -or !$LocationRequest.id) -and ($retryCount -lt $MaxRetryCount)) Write-LogMessage -Tenant $TenantFilter -Headers $Headers -API $APINAME -message "Created new Named Location: $($location.displayName)" -Sev 'Info' [pscustomobject]@{ id = $GraphRequest.id From 2453809ccc23e9e284cd4215e7a8a07ba94af7e8 Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Sat, 14 Feb 2026 12:23:26 +0100 Subject: [PATCH 16/97] fixes #5373 --- Modules/CIPPCore/Public/New-CIPPBackup.ps1 | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Modules/CIPPCore/Public/New-CIPPBackup.ps1 b/Modules/CIPPCore/Public/New-CIPPBackup.ps1 index b3f8409ade3e..374dfaf4ee8e 100644 --- a/Modules/CIPPCore/Public/New-CIPPBackup.ps1 +++ b/Modules/CIPPCore/Public/New-CIPPBackup.ps1 @@ -54,6 +54,8 @@ function New-CIPPBackup { 'WebhookRules' 'ScheduledTasks' 'TenantProperties' + 'TenantGroups' + 'TenantGroupMembers' ) $CSVfile = foreach ($CSVTable in $BackupTables) { $Table = Get-CippTable -tablename $CSVTable From a35798a83ccf98537ce54e79f1c812b87786da40 Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Sat, 14 Feb 2026 13:24:27 +0100 Subject: [PATCH 17/97] updated domain scores --- Modules/CIPPCore/Public/Alerts/Get-CIPPAlertLowDomainScore.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertLowDomainScore.ps1 b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertLowDomainScore.ps1 index e5284435bcdc..674648585221 100644 --- a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertLowDomainScore.ps1 +++ b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertLowDomainScore.ps1 @@ -13,7 +13,7 @@ function Get-CIPPAlertLowDomainScore { ) $DomainData = Get-CIPPDomainAnalyser -TenantFilter $TenantFilter - $LowScoreDomains = $DomainData | Where-Object { $_.ScorePercentage -lt $InputValue -and $_.ScorePercentage -ne '' } | ForEach-Object { + $LowScoreDomains = $DomainData | Where-Object { $_.ScorePercentage -lt $InputValue -and $_.ScorePercentage -ne '' -and $_.Domain -notlike '*.onmicrosoft.com' -and $_.Domain -notlike '*.mail.onmicrosoft.com' } | ForEach-Object { [PSCustomObject]@{ Message = "$($_.Domain): Domain security score is $($_.ScorePercentage)%, which is below the threshold of $InputValue%. Issues: $($_.ScoreExplanation)" Domain = $_.Domain From 80e9bc19af3c82fbfe2ad834ae950f0f1dfb6559 Mon Sep 17 00:00:00 2001 From: Bobby <31723128+kris6673@users.noreply.github.com> Date: Sat, 14 Feb 2026 13:10:07 +0100 Subject: [PATCH 18/97] fix: logging, appease the great PSScriptAnalyser and casing --- Modules/CIPPCore/Public/New-CIPPCAPolicy.ps1 | 76 ++++++++++---------- 1 file changed, 38 insertions(+), 38 deletions(-) diff --git a/Modules/CIPPCore/Public/New-CIPPCAPolicy.ps1 b/Modules/CIPPCore/Public/New-CIPPCAPolicy.ps1 index 8a713165f482..4f61f708abd7 100644 --- a/Modules/CIPPCore/Public/New-CIPPCAPolicy.ps1 +++ b/Modules/CIPPCore/Public/New-CIPPCAPolicy.ps1 @@ -23,10 +23,10 @@ function New-CIPPCAPolicy { } else { Remove-EmptyArrays $Object[$Key] } } } elseif ($Object -is [PSCustomObject]) { - foreach ($Name in @($Object.psobject.properties.Name)) { + foreach ($Name in @($Object.PSObject.Properties.Name)) { if ($Object.$Name -is [Array] -and $Object.$Name.get_Count() -eq 0) { $Object.PSObject.Properties.Remove($Name) - } elseif ($null -eq $object.$name) { + } elseif ($null -eq $Object.$Name) { $Object.PSObject.Properties.Remove($Name) } else { Remove-EmptyArrays $Object.$Name } } @@ -34,23 +34,23 @@ function New-CIPPCAPolicy { } # Function to check if a string is a GUID function Test-IsGuid($string) { - return [guid]::tryparse($string, [ref][guid]::Empty) + return [guid]::TryParse($string, [ref][guid]::Empty) } # Helper function to replace group display names with GUIDs - function Replace-GroupNameWithId { + function Convert-GroupNameToId { param($TenantFilter, $groupNames, $CreateGroups, $GroupTemplates) $GroupIds = [System.Collections.Generic.List[string]]::new() $groupNames | ForEach-Object { if (Test-IsGuid $_) { - Write-LogMessage -Headers $Headers -API 'Create CA Policy' -message "Already GUID, no need to replace: $_" -Sev 'Debug' + Write-LogMessage -Headers $Headers -API $APIName -message "Already GUID, no need to replace: $_" -Sev 'Debug' $GroupIds.Add($_) # it's a GUID, so we keep it } else { $groupId = ($groups | Where-Object -Property displayName -EQ $_).id # it's a display name, so we get the group ID if ($groupId) { foreach ($gid in $groupId) { Write-Warning "Replaced group name $_ with ID $gid" - $null = Write-LogMessage -Headers $Headers -API 'Create CA Policy' -message "Replaced group name $_ with ID $gid" -Sev 'Debug' + $null = Write-LogMessage -Headers $Headers -API $APIName -message "Replaced group name $_ with ID $gid" -Sev 'Debug' $GroupIds.Add($gid) # add the ID to the list } } elseif ($CreateGroups) { @@ -58,7 +58,7 @@ function New-CIPPCAPolicy { if ($GroupTemplates.displayName -eq $_) { Write-Information "Creating group from template for $_" $GroupTemplate = $GroupTemplates | Where-Object -Property displayName -EQ $_ - $NewGroup = New-CIPPGroup -GroupObject $GroupTemplate -TenantFilter $TenantFilter -APIName 'New-CIPPCAPolicy' + $NewGroup = New-CIPPGroup -GroupObject $GroupTemplate -TenantFilter $TenantFilter -APIName $APIName $GroupIds.Add($NewGroup.GroupId) } else { Write-Information "No template found, creating security group for $_" @@ -72,7 +72,7 @@ function New-CIPPCAPolicy { username = $username securityEnabled = $true } - $NewGroup = New-CIPPGroup -GroupObject $GroupObject -TenantFilter $TenantFilter -APIName 'New-CIPPCAPolicy' + $NewGroup = New-CIPPGroup -GroupObject $GroupObject -TenantFilter $TenantFilter -APIName $APIName $GroupIds.Add($NewGroup.GroupId) } } else { @@ -83,20 +83,20 @@ function New-CIPPCAPolicy { return $GroupIds } - function Replace-UserNameWithId { + function Convert-UserNameToId { param($userNames) $UserIds = [System.Collections.Generic.List[string]]::new() $userNames | ForEach-Object { if (Test-IsGuid $_) { - Write-LogMessage -Headers $Headers -API 'Create CA Policy' -message "Already GUID, no need to replace: $_" -Sev 'Debug' + Write-LogMessage -Headers $Headers -API $APIName -message "Already GUID, no need to replace: $_" -Sev 'Debug' $UserIds.Add($_) # it's a GUID, so we keep it } else { $userId = ($users | Where-Object -Property displayName -EQ $_).id # it's a display name, so we get the user ID if ($userId) { foreach ($uid in $userId) { Write-Warning "Replaced user name $_ with ID $uid" - $null = Write-LogMessage -Headers $Headers -API 'Create CA Policy' -message "Replaced user name $_ with ID $uid" -Sev 'Debug' + $null = Write-LogMessage -Headers $Headers -API $APIName -message "Replaced user name $_ with ID $uid" -Sev 'Debug' $UserIds.Add($uid) # add the ID to the list } } else { @@ -107,7 +107,7 @@ function New-CIPPCAPolicy { return $UserIds } - $displayname = ($RawJSON | ConvertFrom-Json).Displayname + $displayName = ($RawJSON | ConvertFrom-Json).displayName $JSONobj = $RawJSON | ConvertFrom-Json | Select-Object * -ExcludeProperty ID, GUID, *time* Remove-EmptyArrays $JSONobj @@ -125,7 +125,7 @@ function New-CIPPCAPolicy { # no issues here. } - #If Grant Controls contains authenticationstrength, create these and then replace the id + #If Grant Controls contains authenticationStrength, create these and then replace the id if ($JSONobj.GrantControls.authenticationStrength.policyType -eq 'custom' -or $JSONobj.GrantControls.authenticationStrength.policyType -eq 'BuiltIn') { $ExistingStrength = New-GraphGETRequest -uri 'https://graph.microsoft.com/beta/identity/conditionalAccess/authenticationStrength/policies/' -tenantid $TenantFilter -asApp $true | Where-Object -Property displayName -EQ $JSONobj.GrantControls.authenticationStrength.displayName if ($ExistingStrength) { @@ -133,14 +133,13 @@ function New-CIPPCAPolicy { } else { $Body = ConvertTo-Json -InputObject $JSONobj.GrantControls.authenticationStrength - $GraphRequest = New-GraphPOSTRequest -uri 'https://graph.microsoft.com/beta/identity/conditionalAccess/authenticationStrength/policies' -body $body -Type POST -tenantid $tenantfilter -asApp $true + $GraphRequest = New-GraphPOSTRequest -uri 'https://graph.microsoft.com/beta/identity/conditionalAccess/authenticationStrength/policies' -body $body -Type POST -tenantid $TenantFilter -asApp $true $JSONobj.GrantControls.authenticationStrength = @{ id = $ExistingStrength.id } - Write-LogMessage -Headers $Headers -API $APINAME -message "Created new Authentication Strength Policy: $($JSONobj.GrantControls.authenticationStrength.displayName)" -Sev 'Info' + Write-LogMessage -Headers $Headers -API $APIName -message "Created new Authentication Strength Policy: $($JSONobj.GrantControls.authenticationStrength.displayName)" -Sev 'Info' } } #if we have excluded or included applications, we need to remove any appIds that do not have a service principal in the tenant - if (($JSONobj.conditions.applications.includeApplications -and $JSONobj.conditions.applications.includeApplications -notcontains 'All') -or ($JSONobj.conditions.applications.excludeApplications -and $JSONobj.conditions.applications.excludeApplications -notcontains 'All')) { $AllServicePrincipals = New-GraphGETRequest -uri 'https://graph.microsoft.com/v1.0/servicePrincipals?$select=appId' -tenantid $TenantFilter -asApp $true @@ -179,14 +178,14 @@ function New-CIPPCAPolicy { Remove-ODataProperties -Object $LocationUpdate $Body = ConvertTo-Json -InputObject $LocationUpdate -Depth 10 try { - $null = New-GraphPOSTRequest -uri "https://graph.microsoft.com/beta/identity/conditionalAccess/namedLocations/$($ExistingLocation.id)" -body $body -Type PATCH -tenantid $tenantfilter -asApp $true - Write-LogMessage -Tenant $TenantFilter -Headers $Headers -API $APINAME -message "Updated existing Named Location: $($location.displayName)" -Sev 'Info' + $null = New-GraphPOSTRequest -uri "https://graph.microsoft.com/beta/identity/conditionalAccess/namedLocations/$($ExistingLocation.id)" -body $body -Type PATCH -tenantid $TenantFilter -asApp $true + Write-LogMessage -Tenant $TenantFilter -Headers $Headers -API $APIName -message "Updated existing Named Location: $($location.displayName)" -Sev 'Info' } catch { Write-Warning "Failed to update location $($location.displayName): $_" - Write-LogMessage -Tenant $TenantFilter -Headers $Headers -API $APINAME -message "Failed to update existing Named Location: $($location.displayName). Error: $_" -Sev 'Error' + Write-LogMessage -Tenant $TenantFilter -Headers $Headers -API $APIName -message "Failed to update existing Named Location: $($location.displayName). Error: $_" -Sev 'Error' } } else { - Write-LogMessage -Tenant $TenantFilter -Headers $Headers -API $APINAME -message "Matched a CA policy with the existing Named Location: $($location.displayName)" -Sev 'Info' + Write-LogMessage -Tenant $TenantFilter -Headers $Headers -API $APIName -message "Matched a CA policy with the existing Named Location: $($location.displayName)" -Sev 'Info' } [pscustomobject]@{ id = $ExistingLocation.id @@ -198,17 +197,17 @@ function New-CIPPCAPolicy { $LocationBody = $location | Select-Object * -ExcludeProperty id Remove-ODataProperties -Object $LocationBody $Body = ConvertTo-Json -InputObject $LocationBody - $GraphRequest = New-GraphPOSTRequest -uri 'https://graph.microsoft.com/beta/identity/conditionalAccess/namedLocations' -body $body -Type POST -tenantid $tenantfilter -asApp $true + $GraphRequest = New-GraphPOSTRequest -uri 'https://graph.microsoft.com/beta/identity/conditionalAccess/namedLocations' -body $body -Type POST -tenantid $TenantFilter -asApp $true $retryCount = 0 $MaxRetryCount = 10 do { Write-Host "Checking for location $($GraphRequest.id) attempt $retryCount. $TenantFilter" - $LocationRequest = New-GraphGETRequest -uri 'https://graph.microsoft.com/beta/identity/conditionalAccess/namedLocations' -tenantid $tenantfilter -asApp $true | Where-Object -Property id -EQ $GraphRequest.id + $LocationRequest = New-GraphGETRequest -uri 'https://graph.microsoft.com/beta/identity/conditionalAccess/namedLocations' -tenantid $TenantFilter -asApp $true | Where-Object -Property id -EQ $GraphRequest.id Write-Host "LocationRequest: $($LocationRequest.id)" Start-Sleep -Seconds 2 $retryCount++ } while ((!$LocationRequest -or !$LocationRequest.id) -and ($retryCount -lt $MaxRetryCount)) - Write-LogMessage -Tenant $TenantFilter -Headers $Headers -API $APINAME -message "Created new Named Location: $($location.displayName)" -Sev 'Info' + Write-LogMessage -Tenant $TenantFilter -Headers $Headers -API $APIName -message "Created new Named Location: $($location.displayName)" -Sev 'Info' [pscustomobject]@{ id = $GraphRequest.id name = $GraphRequest.displayName @@ -281,14 +280,14 @@ function New-CIPPCAPolicy { foreach ($userType in 'includeUsers', 'excludeUsers') { if ($JSONobj.conditions.users.PSObject.Properties.Name -contains $userType -and $JSONobj.conditions.users.$userType -notin 'All', 'None', 'GuestOrExternalUsers') { - $JSONobj.conditions.users.$userType = @(Replace-UserNameWithId -userNames $JSONobj.conditions.users.$userType) + $JSONobj.conditions.users.$userType = @(Convert-UserNameToId -userNames $JSONobj.conditions.users.$userType) } } # Check the included and excluded groups foreach ($groupType in 'includeGroups', 'excludeGroups') { if ($JSONobj.conditions.users.PSObject.Properties.Name -contains $groupType) { - $JSONobj.conditions.users.$groupType = @(Replace-GroupNameWithId -groupNames $JSONobj.conditions.users.$groupType -CreateGroups $CreateGroups -TenantFilter $TenantFilter -GroupTemplates $GroupTemplates) + $JSONobj.conditions.users.$groupType = @(Convert-GroupNameToId -groupNames $JSONobj.conditions.users.$groupType -CreateGroups $CreateGroups -TenantFilter $TenantFilter -GroupTemplates $GroupTemplates) } } } catch { @@ -323,8 +322,8 @@ function New-CIPPCAPolicy { #Send request to disable security defaults. $body = '{ "isEnabled": false }' try { - $null = New-GraphPostRequest -tenantid $TenantFilter -Uri 'https://graph.microsoft.com/beta/policies/identitySecurityDefaultsEnforcementPolicy' -Type patch -Body $body -asApp $true -ContentType 'application/json' - Write-LogMessage -Headers $Headers -API 'Create CA Policy' -tenant $TenantFilter -message "Disabled Security Defaults for tenant $($TenantFilter)" -Sev 'Info' + $null = New-GraphPostRequest -tenantid $TenantFilter -Uri 'https://graph.microsoft.com/beta/policies/identitySecurityDefaultsEnforcementPolicy' -Type patch -Body $body -asApp $true + Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter -message "Disabled Security Defaults for tenant $($TenantFilter)" -Sev 'Info' Start-Sleep 3 } catch { $ErrorMessage = Get-CippException -Exception $_ @@ -335,10 +334,10 @@ function New-CIPPCAPolicy { Write-Information $RawJSON try { Write-Information 'Checking for existing policies' - $CheckExisting = New-GraphGETRequest -uri 'https://graph.microsoft.com/beta/identity/conditionalAccess/policies' -tenantid $TenantFilter -asApp $true | Where-Object -Property displayName -EQ $displayname + $CheckExisting = New-GraphGETRequest -uri 'https://graph.microsoft.com/beta/identity/conditionalAccess/policies' -tenantid $TenantFilter -asApp $true | Where-Object -Property displayName -EQ $displayName if ($CheckExisting) { if ($Overwrite -ne $true) { - throw "Conditional Access Policy with Display Name $($Displayname) Already exists" + throw "Conditional Access Policy with Display Name $($displayName) Already exists" return $false } else { if ($State -eq 'donotchange') { @@ -370,26 +369,27 @@ function New-CIPPCAPolicy { Write-Information "Failed to preserve vacation exclusion group: $($_.Exception.Message)" } Write-Information "overwriting $($CheckExisting.id)" - $null = New-GraphPOSTRequest -uri "https://graph.microsoft.com/beta/identity/conditionalAccess/policies/$($CheckExisting.id)" -tenantid $tenantfilter -type PATCH -body $RawJSON -asApp $true - Write-LogMessage -Headers $Headers -API 'Create CA Policy' -tenant $($Tenant) -message "Updated Conditional Access Policy $($JSONobj.Displayname) to the template standard." -Sev 'Info' - return "Updated policy $displayname for $tenantfilter" + $null = New-GraphPOSTRequest -uri "https://graph.microsoft.com/beta/identity/conditionalAccess/policies/$($CheckExisting.id)" -tenantid $TenantFilter -type PATCH -body $RawJSON -asApp $true + Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter -message "Updated Conditional Access Policy $($JSONobj.displayName) to the template standard." -Sev 'Info' + return "Updated policy $($JSONobj.displayName) for $TenantFilter" } } else { Write-Information 'Creating new policy' if ($JSOObj.GrantControls.authenticationStrength.policyType -or $JSONobj.$JSONobj.LocationInfo) { Start-Sleep 3 } - $null = New-GraphPOSTRequest -uri 'https://graph.microsoft.com/beta/identity/conditionalAccess/policies' -tenantid $tenantfilter -type POST -body $RawJSON -asApp $true - Write-LogMessage -Headers $Headers -API 'Create CA Policy' -tenant $($Tenant) -message "Added Conditional Access Policy $($JSONobj.Displayname)" -Sev 'Info' - return "Created policy $displayname for $tenantfilter" + $null = New-GraphPOSTRequest -uri 'https://graph.microsoft.com/beta/identity/conditionalAccess/policies' -tenantid $TenantFilter -type POST -body $RawJSON -asApp $true + Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter -message "Added Conditional Access Policy $($JSONobj.displayName)" -Sev 'Info' + return "Created policy $($JSONobj.displayName) for $TenantFilter" } } catch { $ErrorMessage = Get-CippException -Exception $_ - Write-LogMessage -API 'Standards' -tenant $TenantFilter -message "Failed to create or update conditional access rule $($JSONobj.displayName): $($ErrorMessage.NormalizedError) " -sev 'Error' -LogData $ErrorMessage + $Result = "Failed to create or update conditional access rule $($JSONobj.displayName): $($ErrorMessage.NormalizedError)" + Write-LogMessage -API $APIName -tenant $TenantFilter -message $Result -sev 'Error' -LogData $ErrorMessage - Write-Warning "Failed to create or update conditional access rule $($JSONobj.displayName): $($ErrorMessage.NormalizedError)" + Write-Warning $Result Write-Information $_.InvocationInfo.PositionMessage Write-Information ($JSONobj | ConvertTo-Json -Depth 10) - throw "Failed to create or update conditional access rule $($JSONobj.displayName): $($ErrorMessage.NormalizedError)" + throw $Result } } From 476c0612e6bc33544eafedd2c9a224dc3dae85d3 Mon Sep 17 00:00:00 2001 From: Bobby <31723128+kris6673@users.noreply.github.com> Date: Sat, 14 Feb 2026 13:22:09 +0100 Subject: [PATCH 19/97] fix: sort licensed users and groups by display name Possibly fixes https://github.com/KelvinTegelaar/CIPP/issues/5338 Sort licenses by License name by default ADD WORD --- Modules/CIPPCore/Public/Get-CIPPLAPSPassword.ps1 | 2 +- Modules/CIPPCore/Public/Get-CIPPLicenseOverview.ps1 | 6 +++--- cspell.json | 1 + 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/Modules/CIPPCore/Public/Get-CIPPLAPSPassword.ps1 b/Modules/CIPPCore/Public/Get-CIPPLAPSPassword.ps1 index a6718f332a10..b0cd9c6534a0 100644 --- a/Modules/CIPPCore/Public/Get-CIPPLAPSPassword.ps1 +++ b/Modules/CIPPCore/Public/Get-CIPPLAPSPassword.ps1 @@ -9,7 +9,7 @@ function Get-CIPPLapsPassword { ) try { - $GraphRequest = (New-GraphGetRequest -noauthcheck $true -uri "https://graph.microsoft.com/beta/directory/deviceLocalCredentials/$($device)?`$select=credentials" -tenantid $TenantFilter).credentials | Select-Object -First 1 | ForEach-Object { + $GraphRequest = (New-GraphGetRequest -NoAuthCheck $true -uri "https://graph.microsoft.com/beta/directory/deviceLocalCredentials/$($device)?`$select=credentials" -tenantid $TenantFilter).credentials | Select-Object -First 1 | ForEach-Object { $PlainText = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($_.passwordBase64)) $date = $_.BackupDateTime [PSCustomObject]@{ diff --git a/Modules/CIPPCore/Public/Get-CIPPLicenseOverview.ps1 b/Modules/CIPPCore/Public/Get-CIPPLicenseOverview.ps1 index bfa4b519de08..752cf71f8cc7 100644 --- a/Modules/CIPPCore/Public/Get-CIPPLicenseOverview.ps1 +++ b/Modules/CIPPCore/Public/Get-CIPPLicenseOverview.ps1 @@ -63,7 +63,7 @@ function Get-CIPPLicenseOverview { $ExcludedSkuList = Get-CIPPAzDataTableEntity @LicenseTable } - $AllLicensedUsers = @(($Results | Where-Object { $_.id -eq 'licensedUsers' }).body.value) + $AllLicensedUsers = @(($Results | Where-Object { $_.id -eq 'licensedUsers' }).body.value) | Sort-Object -Property displayName $UsersBySku = @{} foreach ($User in $AllLicensedUsers) { if (-not $User.assignedLicenses) { continue } # Skip users with no assigned licenses. Should not happens as the filter is applied, but just in case @@ -84,7 +84,7 @@ function Get-CIPPLicenseOverview { } - $AllLicensedGroups = @(($Results | Where-Object { $_.id -eq 'licensedGroups' }).body.value) + $AllLicensedGroups = @(($Results | Where-Object { $_.id -eq 'licensedGroups' }).body.value) | Sort-Object -Property displayName $GroupsBySku = @{} foreach ($Group in $AllLicensedGroups) { if (-not $Group.assignedLicenses) { continue } @@ -156,5 +156,5 @@ function Get-CIPPLicenseOverview { } } } - return $GraphRequest + return ($GraphRequest | Sort-Object -Property License) } diff --git a/cspell.json b/cspell.json index 28a883c89c5e..597a0ed1277a 100644 --- a/cspell.json +++ b/cspell.json @@ -43,6 +43,7 @@ "SharePoint", "Sherweb", "Signup", + "Skus", "SSPR", "Standardcal", "Terrl", From feb8e7f40803c105983cec5d153d008a6a51f920 Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Sat, 14 Feb 2026 14:05:52 +0100 Subject: [PATCH 20/97] Add new alert --- .../Alerts/Get-CIPPAlertNewMFADevice.ps1 | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 Modules/CIPPCore/Public/Alerts/Get-CIPPAlertNewMFADevice.ps1 diff --git a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertNewMFADevice.ps1 b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertNewMFADevice.ps1 new file mode 100644 index 000000000000..22093da183e2 --- /dev/null +++ b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertNewMFADevice.ps1 @@ -0,0 +1,41 @@ +function Get-CIPPAlertNewMFADevice { + <# + .FUNCTIONALITY + Entrypoint + #> + [CmdletBinding()] + param ( + [Parameter(Mandatory = $false)] + [Alias('input')] + $InputValue, + $TenantFilter + ) + + try { + $OneHourAgo = (Get-Date).AddHours(-1).ToString('yyyy-MM-ddTHH:mm:ssZ') + + $AuditLogs = New-GraphGetRequest -uri "https://graph.microsoft.com/v1.0/auditLogs/directoryAudits?`$filter=activityDateTime ge $OneHourAgo and (activityDisplayName eq 'User registered security info' or activityDisplayName eq 'User deleted security info')" -tenantid $TenantFilter + $AlertData = foreach ($Log in $AuditLogs) { + if ($Log.activityDisplayName -eq 'User registered security info') { + $User = $Log.targetResources[0].userPrincipalName + if (-not $User) { $User = $Log.initiatedBy.user.userPrincipalName } + + [PSCustomObject]@{ + Message = "New MFA method registered: $User" + User = $User + DisplayName = $Log.targetResources[0].displayName + Activity = $Log.activityDisplayName + ActivityTime = $Log.activityDateTime + Tenant = $TenantFilter + } + } + } + + if ($AlertData) { + Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData + } + + } catch { + Write-AlertMessage -tenant $($TenantFilter) -message "Could not check for new MFA devices for $($TenantFilter): $(Get-NormalizedError -message $_.Exception.message)" + } +} From 922c744b91d769594c240d18630960812f97652a Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Sat, 14 Feb 2026 14:05:55 +0100 Subject: [PATCH 21/97] alert add --- .../Public/Alerts/Get-CIPPAlertNewMFADevice.ps1 | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertNewMFADevice.ps1 b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertNewMFADevice.ps1 index 22093da183e2..10254cbb65bc 100644 --- a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertNewMFADevice.ps1 +++ b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertNewMFADevice.ps1 @@ -13,20 +13,20 @@ function Get-CIPPAlertNewMFADevice { try { $OneHourAgo = (Get-Date).AddHours(-1).ToString('yyyy-MM-ddTHH:mm:ssZ') - + $AuditLogs = New-GraphGetRequest -uri "https://graph.microsoft.com/v1.0/auditLogs/directoryAudits?`$filter=activityDateTime ge $OneHourAgo and (activityDisplayName eq 'User registered security info' or activityDisplayName eq 'User deleted security info')" -tenantid $TenantFilter $AlertData = foreach ($Log in $AuditLogs) { if ($Log.activityDisplayName -eq 'User registered security info') { $User = $Log.targetResources[0].userPrincipalName if (-not $User) { $User = $Log.initiatedBy.user.userPrincipalName } - + [PSCustomObject]@{ - Message = "New MFA method registered: $User" - User = $User - DisplayName = $Log.targetResources[0].displayName - Activity = $Log.activityDisplayName - ActivityTime = $Log.activityDateTime - Tenant = $TenantFilter + Message = "New MFA method registered: $User" + User = $User + DisplayName = $Log.targetResources[0].displayName + Activity = $Log.activityDisplayName + ActivityTime = $Log.activityDateTime + Tenant = $TenantFilter } } } From 403588c322675125c16994616ca76aa58d43e954 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Sat, 14 Feb 2026 18:23:33 -0500 Subject: [PATCH 22/97] increase threshold for exchange missing roles --- Modules/CIPPCore/Public/Test-CIPPAccessTenant.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Modules/CIPPCore/Public/Test-CIPPAccessTenant.ps1 b/Modules/CIPPCore/Public/Test-CIPPAccessTenant.ps1 index 4ca993cbc631..a9f8459aec59 100644 --- a/Modules/CIPPCore/Public/Test-CIPPAccessTenant.ps1 +++ b/Modules/CIPPCore/Public/Test-CIPPAccessTenant.ps1 @@ -120,7 +120,7 @@ function Test-CIPPAccessTenant { $AvailableRoles = $RoleDefinitions | Where-Object -Property displayName -In $AllOrgManagementRoles | Select-Object -Property displayName, id, description Write-Information "Found $($AvailableRoles.Count) available Organization Management roles in Exchange" $MissingOrgMgmtRoles = $AvailableRoles | Where-Object { $OrgManagementRoles.Role -notcontains $_.displayName } - if (($MissingOrgMgmtRoles | Measure-Object).Count -gt 0) { + if (($MissingOrgMgmtRoles | Measure-Object).Count -ge 5) { $Results.OrgManagementRolesMissing = $MissingOrgMgmtRoles Write-Warning "Found $($MissingRoles.Count) missing Organization Management roles in Exchange" $ExchangeStatus = $false From 2b4d5553fcef6270b5c548a521bc7d1b99d94ceb Mon Sep 17 00:00:00 2001 From: John Duprey Date: Sat, 14 Feb 2026 18:28:35 -0500 Subject: [PATCH 23/97] Sort quarantine requests and log errors Order Get-QuarantineMessage results by ReceivedTime and replace Write-AlertMessage with Write-LogMessage (API='Alerts', sev=Error) in the catch block. This makes quarantine release requests deterministic by received time and routes errors to the centralized logging API. --- .../Public/Alerts/Get-CIPPAlertQuarantineReleaseRequests.ps1 | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertQuarantineReleaseRequests.ps1 b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertQuarantineReleaseRequests.ps1 index f0ca6d528e40..81a1fecada71 100644 --- a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertQuarantineReleaseRequests.ps1 +++ b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertQuarantineReleaseRequests.ps1 @@ -29,7 +29,7 @@ } try { - $RequestedReleases = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-QuarantineMessage' -cmdParams @{ PageSize = 1000; ReleaseStatus = 'Requested'; StartReceivedDate = (Get-Date).AddHours(-6) } -ErrorAction Stop | Select-Object -ExcludeProperty *data.type* + $RequestedReleases = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-QuarantineMessage' -cmdParams @{ PageSize = 1000; ReleaseStatus = 'Requested'; StartReceivedDate = (Get-Date).AddHours(-6) } -ErrorAction Stop | Select-Object -ExcludeProperty *data.type* | Sort-Object -Property ReceivedTime if ($RequestedReleases) { # Get the CIPP URL for the Quarantine link @@ -56,6 +56,6 @@ Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData } } catch { - Write-AlertMessage -tenant $TenantFilter -message "QuarantineReleaseRequests: $(Get-NormalizedError -message $_.Exception.Message)" + Write-LogMessage -API 'Alerts' -tenant $TenantFilter -message "QuarantineReleaseRequests: $(Get-NormalizedError -message $_.Exception.Message)" -sev Error } } From ccd902366149b652572a6fcdcafe63e630acbf26 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Sat, 14 Feb 2026 18:30:59 -0500 Subject: [PATCH 24/97] Use Write-LogMessage for scripted alert errors Replace Write-AlertMessage calls with Write-LogMessage across multiple Get-CIPPAlert*.ps1 cmdlets. Adds consistent -API 'Alerts' context and appropriate -sev (Error/Warning) values; preserves additional log data where present (e.g. -LogData). This centralizes and standardizes error logging for alert modules and cleans up some ad-hoc Write-Information usage. --- Modules/CIPPCore/Public/Alerts/Get-CIPPAlertAdminPassword.ps1 | 2 +- .../CIPPCore/Public/Alerts/Get-CIPPAlertDefenderMalware.ps1 | 2 +- .../CIPPCore/Public/Alerts/Get-CIPPAlertDefenderStatus.ps1 | 2 +- .../CIPPCore/Public/Alerts/Get-CIPPAlertDepTokenExpiry.ps1 | 2 +- .../CIPPCore/Public/Alerts/Get-CIPPAlertDeviceCompliance.ps1 | 2 +- .../Public/Alerts/Get-CIPPAlertEntraConnectSyncStatus.ps1 | 4 +--- .../Public/Alerts/Get-CIPPAlertGlobalAdminAllowList.ps1 | 2 +- .../Public/Alerts/Get-CIPPAlertInactiveGuestUsers.ps1 | 2 +- .../Public/Alerts/Get-CIPPAlertInactiveLicensedUsers.ps1 | 2 +- Modules/CIPPCore/Public/Alerts/Get-CIPPAlertInactiveUsers.ps1 | 2 +- .../Public/Alerts/Get-CIPPAlertIntunePolicyConflicts.ps1 | 4 ++-- .../Public/Alerts/Get-CIPPAlertLowTenantAlignment.ps1 | 4 ++-- Modules/CIPPCore/Public/Alerts/Get-CIPPAlertNewMFADevice.ps1 | 2 +- Modules/CIPPCore/Public/Alerts/Get-CIPPAlertNewRiskyUsers.ps1 | 4 ++-- Modules/CIPPCore/Public/Alerts/Get-CIPPAlertNewRole.ps1 | 2 +- Modules/CIPPCore/Public/Alerts/Get-CIPPAlertNoCAConfig.ps1 | 2 +- Modules/CIPPCore/Public/Alerts/Get-CIPPAlertOnedriveQuota.ps1 | 2 +- .../CIPPCore/Public/Alerts/Get-CIPPAlertOverusedLicenses.ps1 | 2 +- Modules/CIPPCore/Public/Alerts/Get-CIPPAlertReportOnlyCA.ps1 | 2 +- .../Public/Alerts/Get-CIPPAlertSecDefaultsDisabled.ps1 | 2 +- Modules/CIPPCore/Public/Alerts/Get-CIPPAlertSecureScore.ps1 | 2 +- .../Public/Alerts/Get-CIPPAlertSoftDeletedMailboxes.ps1 | 2 +- .../CIPPCore/Public/Alerts/Get-CIPPAlertStaleEntraDevices.ps1 | 2 +- .../CIPPCore/Public/Alerts/Get-CIPPAlertUnusedLicenses.ps1 | 2 +- 24 files changed, 27 insertions(+), 29 deletions(-) diff --git a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertAdminPassword.ps1 b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertAdminPassword.ps1 index 27e911fe98b0..a0b6888e2046 100644 --- a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertAdminPassword.ps1 +++ b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertAdminPassword.ps1 @@ -21,6 +21,6 @@ function Get-CIPPAlertAdminPassword { } Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData } catch { - Write-AlertMessage -tenant $($TenantFilter) -message "Could not get admin password changes for $($TenantFilter): $(Get-NormalizedError -message $_.Exception.message)" + Write-LogMessage -API 'Alerts' -tenant $($TenantFilter) -message "Could not get admin password changes for $($TenantFilter): $(Get-NormalizedError -message $_.Exception.message)" -sev Error } } diff --git a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertDefenderMalware.ps1 b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertDefenderMalware.ps1 index 252b5abe1b78..8ac96f34286f 100644 --- a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertDefenderMalware.ps1 +++ b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertDefenderMalware.ps1 @@ -30,6 +30,6 @@ function Get-CIPPAlertDefenderMalware { Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData } catch { - Write-AlertMessage -tenant $($TenantFilter) -message "Could not get malware data for $($TenantFilter): $(Get-NormalizedError -message $_.Exception.message)" + Write-LogMessage -API 'Alerts' -tenant $($TenantFilter) -message "Could not get malware data for $($TenantFilter): $(Get-NormalizedError -message $_.Exception.message)" -sev Error } } diff --git a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertDefenderStatus.ps1 b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertDefenderStatus.ps1 index defef38677a1..44ae39bfc7f1 100644 --- a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertDefenderStatus.ps1 +++ b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertDefenderStatus.ps1 @@ -29,6 +29,6 @@ function Get-CIPPAlertDefenderStatus { Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData } catch { - Write-AlertMessage -tenant $($TenantFilter) -message "Could not get defender status for $($TenantFilter): $(Get-NormalizedError -message $_.Exception.message)" + Write-LogMessage -API 'Alerts' -tenant $($TenantFilter) -message "Could not get defender status for $($TenantFilter): $(Get-NormalizedError -message $_.Exception.message)" -sev Error } } diff --git a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertDepTokenExpiry.ps1 b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertDepTokenExpiry.ps1 index 23004aa5c307..afc731eefb89 100644 --- a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertDepTokenExpiry.ps1 +++ b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertDepTokenExpiry.ps1 @@ -26,6 +26,6 @@ function Get-CIPPAlertDepTokenExpiry { } catch { - Write-AlertMessage -tenant $($TenantFilter) -message "Failed to check Apple Device Enrollment Program token expiry for $($TenantFilter): $(Get-NormalizedError -message $_.Exception.message)" + Write-LogMessage -API 'Alerts' -tenant $($TenantFilter) -message "Failed to check Apple Device Enrollment Program token expiry for $($TenantFilter): $(Get-NormalizedError -message $_.Exception.message)" -sev Error } } diff --git a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertDeviceCompliance.ps1 b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertDeviceCompliance.ps1 index ebdf7ee55be8..0ea7bd38fe5c 100644 --- a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertDeviceCompliance.ps1 +++ b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertDeviceCompliance.ps1 @@ -15,6 +15,6 @@ function Get-CIPPAlertDeviceCompliance { $AlertData = New-GraphGETRequest -uri "https://graph.microsoft.com/v1.0/deviceManagement/managedDevices?`$filter=complianceState eq 'noncompliant'&`$select=id,deviceName,managedDeviceOwnerType,complianceState,lastSyncDateTime&`$top=999" -tenantid $TenantFilter Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData } catch { - Write-AlertMessage -tenant $($TenantFilter) -message "Could not get compliance state for $($TenantFilter): $(Get-NormalizedError -message $_.Exception.message)" + Write-LogMessage -API 'Alerts' -tenant $($TenantFilter) -message "Could not get compliance state for $($TenantFilter): $(Get-NormalizedError -message $_.Exception.message)" -sev Error } } diff --git a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertEntraConnectSyncStatus.ps1 b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertEntraConnectSyncStatus.ps1 index 31ebb125ad83..6dd99ceeee8e 100644 --- a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertEntraConnectSyncStatus.ps1 +++ b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertEntraConnectSyncStatus.ps1 @@ -36,8 +36,6 @@ function Get-CIPPAlertEntraConnectSyncStatus { } } } catch { - Write-AlertMessage -tenant $($TenantFilter) -message "Could not get Entra Connect Sync Status for $($TenantFilter): $(Get-NormalizedError -message $_.Exception.message)" -LogData (Get-CippException -Exception $_) - Write-Information "Could not get Entra Connect Sync Status for $($TenantFilter): $(Get-NormalizedError -message $_.Exception.message)" - Write-Information $_.PositionMessage + Write-LogMessage -API 'Alerts' -tenant $($TenantFilter) -message "Could not get Entra Connect Sync Status for $($TenantFilter): $(Get-NormalizedError -message $_.Exception.message)" -sev Error -LogData (Get-CippException -Exception $_) } } diff --git a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertGlobalAdminAllowList.ps1 b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertGlobalAdminAllowList.ps1 index 82ae1ebc1cfb..31a082b0e714 100644 --- a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertGlobalAdminAllowList.ps1 +++ b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertGlobalAdminAllowList.ps1 @@ -79,6 +79,6 @@ function Get-CIPPAlertGlobalAdminAllowList { Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData } } catch { - Write-AlertMessage -tenant $TenantFilter -message "Failed to check approved Global Admins: $(Get-NormalizedError -message $_.Exception.Message)" + Write-LogMessage -API 'Alerts' -tenant $TenantFilter -message "Failed to check approved Global Admins: $(Get-NormalizedError -message $_.Exception.Message)" -sev Error } } diff --git a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertInactiveGuestUsers.ps1 b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertInactiveGuestUsers.ps1 index 839a0af97e37..195b5d51c31c 100644 --- a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertInactiveGuestUsers.ps1 +++ b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertInactiveGuestUsers.ps1 @@ -84,6 +84,6 @@ function Get-CIPPAlertInactiveGuestUsers { Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData } catch {} } catch { - Write-AlertMessage -tenant $($TenantFilter) -message "Failed to check inactive guest users for $($TenantFilter): $(Get-NormalizedError -message $_.Exception.message)" + Write-LogMessage -API 'Alerts' -tenant $($TenantFilter) -message "Failed to check inactive guest users for $($TenantFilter): $(Get-NormalizedError -message $_.Exception.message)" -sev Error } } diff --git a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertInactiveLicensedUsers.ps1 b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertInactiveLicensedUsers.ps1 index 09288d3fee13..d7cdd091a2b2 100644 --- a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertInactiveLicensedUsers.ps1 +++ b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertInactiveLicensedUsers.ps1 @@ -85,6 +85,6 @@ function Get-CIPPAlertInactiveLicensedUsers { Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData } catch {} } catch { - Write-AlertMessage -tenant $($TenantFilter) -message "Failed to check inactive users with licenses for $($TenantFilter): $(Get-NormalizedError -message $_.Exception.message)" + Write-LogMessage -API 'Alerts' -tenant $($TenantFilter) -message "Failed to check inactive users with licenses for $($TenantFilter): $(Get-NormalizedError -message $_.Exception.message)" -sev Error } } diff --git a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertInactiveUsers.ps1 b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertInactiveUsers.ps1 index 037f37e501d5..0eef10e4991e 100644 --- a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertInactiveUsers.ps1 +++ b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertInactiveUsers.ps1 @@ -80,6 +80,6 @@ function Get-CIPPAlertInactiveUsers { Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData } catch {} } catch { - Write-AlertMessage -tenant $($TenantFilter) -message "Failed to check inactive users with licenses for $($TenantFilter): $(Get-NormalizedError -message $_.Exception.message)" + Write-LogMessage -API 'Alerts' -tenant $($TenantFilter) -message "Failed to check inactive users with licenses for $($TenantFilter): $(Get-NormalizedError -message $_.Exception.message)" -sev Error } } diff --git a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertIntunePolicyConflicts.ps1 b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertIntunePolicyConflicts.ps1 index 684f6bd0fc87..965170516e28 100644 --- a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertIntunePolicyConflicts.ps1 +++ b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertIntunePolicyConflicts.ps1 @@ -90,7 +90,7 @@ function Get-CIPPAlertIntunePolicyConflicts { } } } catch { - Write-AlertMessage -tenant $TenantFilter -message "Failed to query Intune policy states: $(Get-NormalizedError -message $_.Exception.Message)" + Write-LogMessage -API 'Alerts' -tenant $TenantFilter -message "Failed to query Intune policy states: $(Get-NormalizedError -message $_.Exception.Message)" -sev Error } } @@ -117,7 +117,7 @@ function Get-CIPPAlertIntunePolicyConflicts { } } } catch { - Write-AlertMessage -tenant $TenantFilter -message "Failed to query Intune application states: $(Get-NormalizedError -message $_.Exception.Message)" + Write-LogMessage -API 'Alerts' -tenant $TenantFilter -message "Failed to query Intune application states: $(Get-NormalizedError -message $_.Exception.Message)" -sev Error } } diff --git a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertLowTenantAlignment.ps1 b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertLowTenantAlignment.ps1 index bc8c5a04672d..52de70f3b0a5 100644 --- a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertLowTenantAlignment.ps1 +++ b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertLowTenantAlignment.ps1 @@ -27,7 +27,7 @@ function Get-CIPPAlertLowTenantAlignment { $AlignmentData = Get-CIPPTenantAlignment -TenantFilter $TenantFilter if (-not $AlignmentData) { - Write-AlertMessage -tenant $TenantFilter -message "No alignment data found for tenant $TenantFilter. This may indicate no standards templates are configured or applied to this tenant." + Write-LogMessage -API 'Alerts' -tenant $TenantFilter -message "No alignment data found for tenant $TenantFilter. This may indicate no standards templates are configured or applied to this tenant." -sev Warning return } @@ -47,6 +47,6 @@ function Get-CIPPAlertLowTenantAlignment { } } catch { - Write-AlertMessage -tenant $TenantFilter -message "Could not get tenant alignment data for $TenantFilter`: $(Get-NormalizedError -message $_.Exception.message)" + Write-LogMessage -API 'Alerts' -tenant $TenantFilter -message "Could not get tenant alignment data for $TenantFilter`: $(Get-NormalizedError -message $_.Exception.message)" -sev Error } } diff --git a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertNewMFADevice.ps1 b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertNewMFADevice.ps1 index 10254cbb65bc..9208d700d4dc 100644 --- a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertNewMFADevice.ps1 +++ b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertNewMFADevice.ps1 @@ -36,6 +36,6 @@ function Get-CIPPAlertNewMFADevice { } } catch { - Write-AlertMessage -tenant $($TenantFilter) -message "Could not check for new MFA devices for $($TenantFilter): $(Get-NormalizedError -message $_.Exception.message)" + Write-LogMessage -API 'Alerts' -tenant $($TenantFilter) -message "Could not check for new MFA devices for $($TenantFilter): $(Get-NormalizedError -message $_.Exception.message)" -sev Error } } diff --git a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertNewRiskyUsers.ps1 b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertNewRiskyUsers.ps1 index c0691da7fe0d..9686bd134f87 100644 --- a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertNewRiskyUsers.ps1 +++ b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertNewRiskyUsers.ps1 @@ -14,7 +14,7 @@ function Get-CIPPAlertNewRiskyUsers { # Check if tenant has P2 capabilities $Capabilities = Get-CIPPTenantCapabilities -TenantFilter $TenantFilter if (-not ($Capabilities.AAD_PREMIUM_P2 -eq $true)) { - Write-AlertMessage -tenant $($TenantFilter) -message 'Tenant does not have Azure AD Premium P2 licensing required for risky users detection' + Write-LogMessage -API 'Alerts' -tenant $($TenantFilter) -message 'Tenant does not have Azure AD Premium P2 licensing required for risky users detection' -sev Warning return } @@ -69,6 +69,6 @@ function Get-CIPPAlertNewRiskyUsers { } } } catch { - Write-AlertMessage -tenant $($TenantFilter) -message "Could not get risky users for $($TenantFilter): $(Get-NormalizedError -message $_.Exception.message)" + Write-LogMessage -API 'Alerts' -tenant $($TenantFilter) -message "Could not get risky users for $($TenantFilter): $(Get-NormalizedError -message $_.Exception.message)" -sev Error } } diff --git a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertNewRole.ps1 b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertNewRole.ps1 index 68167632b5b3..c8cae3616484 100644 --- a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertNewRole.ps1 +++ b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertNewRole.ps1 @@ -43,6 +43,6 @@ function Get-CIPPAlertNewRole { Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData } } catch { - Write-AlertMessage -tenant $($TenantFilter) -message "Could not get get role changes for $($TenantFilter): $(Get-NormalizedError -message $_.Exception.message)" + Write-LogMessage -API 'Alerts' -tenant $($TenantFilter) -message "Could not get get role changes for $($TenantFilter): $(Get-NormalizedError -message $_.Exception.message)" -sev Error } } diff --git a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertNoCAConfig.ps1 b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertNoCAConfig.ps1 index 48ae1dabff57..24055d1b606e 100644 --- a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertNoCAConfig.ps1 +++ b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertNoCAConfig.ps1 @@ -30,7 +30,7 @@ function Get-CIPPAlertNoCAConfig { } } } catch { - Write-AlertMessage -tenant $($TenantFilter) -message "Conditional Access Config Alert: Error occurred: $(Get-NormalizedError -message $_.Exception.message)" + Write-LogMessage -API 'Alerts' -tenant $($TenantFilter) -message "Conditional Access Config Alert: Error occurred: $(Get-NormalizedError -message $_.Exception.message)" -sev Error } } diff --git a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertOnedriveQuota.ps1 b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertOnedriveQuota.ps1 index b39ea94d9a48..b2e786bc9286 100644 --- a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertOnedriveQuota.ps1 +++ b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertOnedriveQuota.ps1 @@ -19,7 +19,7 @@ function Get-CIPPAlertOneDriveQuota { } } catch { $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message - Write-AlertMessage -tenant $($TenantFilter) -message "OneDrive quota Alert: Unable to get OneDrive usage: Error occurred: $ErrorMessage" + Write-LogMessage -API 'Alerts' -tenant $($TenantFilter) -message "OneDrive quota Alert: Unable to get OneDrive usage: Error occurred: $ErrorMessage" -sev Error return } diff --git a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertOverusedLicenses.ps1 b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertOverusedLicenses.ps1 index 69e4f0254a84..f0b87d48ac7c 100644 --- a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertOverusedLicenses.ps1 +++ b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertOverusedLicenses.ps1 @@ -39,6 +39,6 @@ function Get-CIPPAlertOverusedLicenses { } } catch { - Write-AlertMessage -tenant $($TenantFilter) -message "Overused Licenses Alert Error occurred: $(Get-NormalizedError -message $_.Exception.message)" + Write-LogMessage -API 'Alerts' -tenant $($TenantFilter) -message "Overused Licenses Alert Error occurred: $(Get-NormalizedError -message $_.Exception.message)" -sev Error } } diff --git a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertReportOnlyCA.ps1 b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertReportOnlyCA.ps1 index 6205c9b6ac40..2ce381def049 100644 --- a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertReportOnlyCA.ps1 +++ b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertReportOnlyCA.ps1 @@ -36,7 +36,7 @@ function Get-CIPPAlertReportOnlyCA { } } } catch { - Write-AlertMessage -tenant $($TenantFilter) -message "Report-Only CA Alert: Error occurred: $(Get-NormalizedError -message $_.Exception.message)" + Write-LogMessage -API 'Alerts' -tenant $($TenantFilter) -message "Report-Only CA Alert: Error occurred: $(Get-NormalizedError -message $_.Exception.message)" -sev Error } } diff --git a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertSecDefaultsDisabled.ps1 b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertSecDefaultsDisabled.ps1 index 104cf17e03fa..a8055e2c6261 100644 --- a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertSecDefaultsDisabled.ps1 +++ b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertSecDefaultsDisabled.ps1 @@ -30,6 +30,6 @@ function Get-CIPPAlertSecDefaultsDisabled { } } } catch { - Write-AlertMessage -tenant $($TenantFilter) -message "Security Defaults Disabled Alert: Error occurred: $(Get-NormalizedError -message $_.Exception.message)" + Write-LogMessage -API 'Alerts' -tenant $($TenantFilter) -message "Security Defaults Disabled Alert: Error occurred: $(Get-NormalizedError -message $_.Exception.message)" -sev Error } } diff --git a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertSecureScore.ps1 b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertSecureScore.ps1 index 4bf49b56bbc7..a48a163d8532 100644 --- a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertSecureScore.ps1 +++ b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertSecureScore.ps1 @@ -41,6 +41,6 @@ function Get-CippAlertSecureScore { } Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $SecureScoreResult -PartitionKey SecureScore } catch { - Write-AlertMessage -tenant $($TenantFilter) -message "Could not get Secure Score for $($TenantFilter): $(Get-NormalizedError -message $_.Exception.message)" + Write-LogMessage -API 'Alerts' -tenant $($TenantFilter) -message "Could not get Secure Score for $($TenantFilter): $(Get-NormalizedError -message $_.Exception.message)" -sev Error } } diff --git a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertSoftDeletedMailboxes.ps1 b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertSoftDeletedMailboxes.ps1 index f446ec2e9161..b8ea70697c90 100644 --- a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertSoftDeletedMailboxes.ps1 +++ b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertSoftDeletedMailboxes.ps1 @@ -24,6 +24,6 @@ function Get-CIPPAlertSoftDeletedMailboxes { Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData } catch { - Write-AlertMessage -tenant $($TenantFilter) -message "Failed to check for soft deleted mailboxes in $($TenantFilter): $(Get-NormalizedError -message $_.Exception.message)" + Write-LogMessage -API 'Alerts' -tenant $($TenantFilter) -message "Failed to check for soft deleted mailboxes in $($TenantFilter): $(Get-NormalizedError -message $_.Exception.message)" -sev Error } } diff --git a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertStaleEntraDevices.ps1 b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertStaleEntraDevices.ps1 index 29043c3288fd..f44bcd016905 100644 --- a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertStaleEntraDevices.ps1 +++ b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertStaleEntraDevices.ps1 @@ -78,6 +78,6 @@ function Get-CIPPAlertStaleEntraDevices { Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData } catch {} } catch { - Write-AlertMessage -tenant $($TenantFilter) -message "Failed to check inactive guest users for $($TenantFilter): $(Get-NormalizedError -message $_.Exception.message)" + Write-LogMessage -API 'Alerts' -tenant $($TenantFilter) -message "Failed to check inactive guest users for $($TenantFilter): $(Get-NormalizedError -message $_.Exception.message)" -sev Error } } diff --git a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertUnusedLicenses.ps1 b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertUnusedLicenses.ps1 index 4db4e400eb1e..7f470e07d9a1 100644 --- a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertUnusedLicenses.ps1 +++ b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertUnusedLicenses.ps1 @@ -35,6 +35,6 @@ function Get-CIPPAlertUnusedLicenses { } Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData } catch { - Write-AlertMessage -tenant $($TenantFilter) -message "Unused Licenses Alert Error occurred: $(Get-NormalizedError -message $_.Exception.message)" + Write-LogMessage -API 'Alerts' -tenant $($TenantFilter) -message "Unused Licenses Alert Error occurred: $(Get-NormalizedError -message $_.Exception.message)" -sev Error } } From fd029ddd53938ab793d22aeefce2351700186891 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Sat, 14 Feb 2026 18:47:22 -0500 Subject: [PATCH 25/97] Use Graph bulk requests for admin password checks Avoid per-assignment GETs (which caused rate limiting) by fetching role assignments without expanding principal, building a bulk GET request for each principalId, and calling New-GraphBulkRequest to retrieve id, UserPrincipalName, and lastPasswordChangeDateTime for users. Filter results for password changes within the last 24 hours, sort by UserPrincipalName to prevent duplicate alerts, and fall back to an empty array when there are no user requests. Trace and error logging behavior is preserved. --- .../Alerts/Get-CIPPAlertAdminPassword.ps1 | 27 ++++++++++++++++--- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertAdminPassword.ps1 b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertAdminPassword.ps1 index a0b6888e2046..b401c18b83b2 100644 --- a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertAdminPassword.ps1 +++ b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertAdminPassword.ps1 @@ -13,12 +13,31 @@ function Get-CIPPAlertAdminPassword { ) try { $TenantId = (Get-Tenants | Where-Object -Property defaultDomainName -EQ $TenantFilter).customerId - $AlertData = New-GraphGETRequest -uri "https://graph.microsoft.com/beta/roleManagement/directory/roleAssignments?`$filter=roleDefinitionId eq '62e90394-69f5-4237-9190-012177145e10'&`$expand=principal" -tenantid $($TenantFilter) | Where-Object { ($_.principalOrganizationId -EQ $TenantId) -and ($_.principal.'@odata.type' -eq '#microsoft.graph.user') } | ForEach-Object { - $LastChanges = New-GraphGETRequest -uri "https://graph.microsoft.com/beta/users/$($_.principalId)?`$select=UserPrincipalName,lastPasswordChangeDateTime" -tenant $($TenantFilter) - if ($LastChanges.LastPasswordChangeDateTime -gt (Get-Date).AddDays(-1)) { - $LastChanges | Select-Object -Property UserPrincipalName, lastPasswordChangeDateTime + + # Get role assignments without expanding principal to avoid rate limiting + $RoleAssignments = New-GraphGETRequest -uri "https://graph.microsoft.com/beta/roleManagement/directory/roleAssignments?`$filter=roleDefinitionId eq '62e90394-69f5-4237-9190-012177145e10'" -tenantid $($TenantFilter) | Where-Object { $_.principalOrganizationId -EQ $TenantId } + + # Build bulk requests for each principalId + $UserRequests = $RoleAssignments | ForEach-Object { + [PSCustomObject]@{ + id = $_.principalId + method = 'GET' + url = "users/$($_.principalId)?`$select=id,UserPrincipalName,lastPasswordChangeDateTime" } } + + # Make bulk call to get user information + if ($UserRequests) { + $BulkResults = New-GraphBulkRequest -Requests @($UserRequests) -tenantid $TenantFilter + + # Filter users with recent password changes and sort to prevent duplicate alerts + $AlertData = $BulkResults | Where-Object { $_.status -eq 200 -and $_.body.lastPasswordChangeDateTime -gt (Get-Date).AddDays(-1) } | ForEach-Object { + $_.body | Select-Object -Property UserPrincipalName, lastPasswordChangeDateTime + } | Sort-Object UserPrincipalName + } else { + $AlertData = @() + } + Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData } catch { Write-LogMessage -API 'Alerts' -tenant $($TenantFilter) -message "Could not get admin password changes for $($TenantFilter): $(Get-NormalizedError -message $_.Exception.message)" -sev Error From b81ecbb8cd1ba406ef8ce7742c4dc370eaaadcd8 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Sat, 14 Feb 2026 20:42:00 -0500 Subject: [PATCH 26/97] Add permission cache sync and Append support Introduce calendar/mailbox permission cache syncing and related utilities; keep reporting DB in sync when permissions are changed. - Add Sync-CIPPMailboxPermissionCache and Sync-CIPPCalendarPermissionCache to update cached MailboxPermissions/CalendarPermissions entries on Add/Remove. - Add Remove-CIPPCalendarPermissions helper to remove calendar permissions (supports cache-driven bulk removal and per-calendar removal). - Update Remove-CIPPMailboxPermissions to support -UseCache (bulk removal via cached report), and to call Sync-CIPPMailboxPermissionCache after permission changes; improved logging when permissions already absent. - Update Set-CIPPCalendarPermission and Invoke-ExecEditMailboxPermissions to call the cache sync functions after add/remove operations. - Enhance Add-CIPPDbItem with a new -Append switch to add items without clearing existing entries and to optionally increment stored counts when used with -AddCount. - Minor report/log tweaks: include FolderName in Get-CIPPCalendarPermissionReport output and reduce Get-CIPPMailboxPermissionReport startup log severity to Debug. - Simplify offboarding flow to remove mailbox/calendar permissions via the new cache-aware functions. These changes ensure permission changes performed by CIPP are reflected in the cached reporting DB and allow incremental appends for reporting data. --- Modules/CIPPCore/Public/Add-CIPPDbItem.ps1 | 42 ++++- .../Invoke-ExecEditMailboxPermissions.ps1 | 17 +- .../Users/Invoke-CIPPOffboardingJob.ps1 | 28 +-- .../Get-CIPPCalendarPermissionReport.ps1 | 3 + .../Get-CIPPMailboxPermissionReport.ps1 | 2 +- .../Public/Remove-CIPPCalendarPermissions.ps1 | 177 ++++++++++++++++++ .../Public/Remove-CIPPMailboxPermissions.ps1 | 59 +++++- .../Public/Set-CIPPCalendarPermission.ps1 | 6 + .../Sync-CIPPCalendarPermissionCache.ps1 | 173 +++++++++++++++++ .../Sync-CIPPMailboxPermissionCache.ps1 | 157 ++++++++++++++++ 10 files changed, 632 insertions(+), 32 deletions(-) create mode 100644 Modules/CIPPCore/Public/Remove-CIPPCalendarPermissions.ps1 create mode 100644 Modules/CIPPCore/Public/Sync-CIPPCalendarPermissionCache.ps1 create mode 100644 Modules/CIPPCore/Public/Sync-CIPPMailboxPermissionCache.ps1 diff --git a/Modules/CIPPCore/Public/Add-CIPPDbItem.ps1 b/Modules/CIPPCore/Public/Add-CIPPDbItem.ps1 index be862eabe375..9dd5a5af2abf 100644 --- a/Modules/CIPPCore/Public/Add-CIPPDbItem.ps1 +++ b/Modules/CIPPCore/Public/Add-CIPPDbItem.ps1 @@ -22,6 +22,10 @@ function Add-CIPPDbItem { .PARAMETER AddCount If specified, automatically records the total count after processing all items + .PARAMETER Append + If specified, adds items without clearing existing entries for this type/tenant and automatically + increments the count. Useful for accumulating report data over time. By default, existing entries are replaced. + .EXAMPLE Add-CIPPDbItem -TenantFilter 'contoso.onmicrosoft.com' -Type 'Groups' -Data $GroupsData @@ -30,6 +34,9 @@ function Add-CIPPDbItem { .EXAMPLE Add-CIPPDbItem -TenantFilter 'contoso.onmicrosoft.com' -Type 'Groups' -Data $GroupsData -Count + + .EXAMPLE + Add-CIPPDbItem -TenantFilter 'contoso.onmicrosoft.com' -Type 'AlertHistory' -Data $AlertData -Append -AddCount #> [CmdletBinding()] param( @@ -49,7 +56,10 @@ function Add-CIPPDbItem { [switch]$Count, [Parameter(Mandatory = $false)] - [switch]$AddCount + [switch]$AddCount, + + [Parameter(Mandatory = $false)] + [switch]$Append ) begin { @@ -104,7 +114,7 @@ function Add-CIPPDbItem { } } - if (-not $Count.IsPresent) { + if (-not $Count.IsPresent -and -not $Append.IsPresent) { # Delete existing entries for this type $Filter = "PartitionKey eq '{0}' and RowKey ge '{1}-' and RowKey lt '{1}0'" -f $TenantFilter, $Type $ExistingEntities = Get-CIPPAzDataTableEntity @Table -Filter $Filter -Property PartitionKey, RowKey, ETag @@ -168,18 +178,29 @@ function Add-CIPPDbItem { Invoke-FlushBatch -State $State } - if ($Count.IsPresent) { + if ($Count.IsPresent -or $Append.IsPresent) { # Store count record + if ($Append.IsPresent) { + # When appending, always increment the existing count + $Filter = "PartitionKey eq '{0}' and RowKey eq '{1}-Count'" -f $TenantFilter, $Type + $ExistingCount = Get-CIPPAzDataTableEntity @Table -Filter $Filter + $PreviousCount = if ($ExistingCount -and $ExistingCount.DataCount) { [int]$ExistingCount.DataCount } else { 0 } + $NewCount = $PreviousCount + $State.TotalProcessed + } else { + # Normal mode - replace count + $NewCount = $State.TotalProcessed + } + $Entity = @{ PartitionKey = $TenantFilter RowKey = Format-RowKey "$Type-Count" - DataCount = [int]$State.TotalProcessed + DataCount = [int]$NewCount } Add-CIPPAzDataTableEntity @Table -Entity $Entity -Force | Out-Null } Write-LogMessage -API 'CIPPDbItem' -tenant $TenantFilter ` - -message "Added $($State.TotalProcessed) items of type $Type$(if ($Count.IsPresent) { ' (count mode)' })" -sev Debug + -message "Added $($State.TotalProcessed) items of type $Type$(if ($Count.IsPresent) { ' (count mode)' })$(if ($Append.IsPresent) { ' (append mode)' })" -sev Debug } catch { Write-LogMessage -API 'CIPPDbItem' -tenant $TenantFilter ` @@ -191,7 +212,16 @@ function Add-CIPPDbItem { # Record count if AddCount was specified if ($AddCount.IsPresent -and $State.TotalProcessed -gt 0) { try { - Add-CIPPDbItem -TenantFilter $TenantFilter -Type $Type -InputObject $State.TotalProcessed -Count + $countParams = @{ + TenantFilter = $TenantFilter + Type = $Type + InputObject = $State.TotalProcessed + Count = $true + } + if ($Append.IsPresent) { + $countParams.Append = $true + } + Add-CIPPDbItem @countParams } catch { Write-LogMessage -API 'CIPPDbItem' -tenant $TenantFilter ` -message "Failed to record count for $Type : $($_.Exception.Message)" -sev Warning diff --git a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ExecEditMailboxPermissions.ps1 b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ExecEditMailboxPermissions.ps1 index ee36d68eb6b2..00d696892b7c 100644 --- a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ExecEditMailboxPermissions.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ExecEditMailboxPermissions.ps1 @@ -1,4 +1,4 @@ -Function Invoke-ExecEditMailboxPermissions { +function Invoke-ExecEditMailboxPermissions { <# .FUNCTIONALITY Entrypoint @@ -23,6 +23,9 @@ Function Invoke-ExecEditMailboxPermissions { $MailboxPerms = New-ExoRequest -Anchor $username -tenantid $Tenantfilter -cmdlet 'Remove-mailboxpermission' -cmdParams @{Identity = $userid; user = $RemoveUser; accessRights = @('FullAccess'); } $results.add("Removed $($removeuser) from $($username) Shared Mailbox permissions") Write-LogMessage -headers $Request.Headers -API $APINAME-message "Removed $($RemoveUser) from $($username) Shared Mailbox permission" -Sev 'Info' -tenant $TenantFilter + + # Sync cache + Sync-CIPPMailboxPermissionCache -TenantFilter $TenantFilter -MailboxIdentity $username -User $RemoveUser -PermissionType 'FullAccess' -Action 'Remove' } catch { Write-LogMessage -headers $Request.Headers -API $APINAME-message "Could not remove mailbox permissions for $($removeuser) on $($username)" -Sev 'Error' -tenant $TenantFilter $results.add("Could not remove $($removeuser) shared mailbox permissions for $($username). Error: $($_.Exception.Message)") @@ -36,6 +39,9 @@ Function Invoke-ExecEditMailboxPermissions { $results.add( "Granted $($UserAutomap) access to $($username) Mailbox with automapping") Write-LogMessage -headers $Request.Headers -API $APINAME-message "Granted $($UserAutomap) access to $($username) Mailbox with automapping" -Sev 'Info' -tenant $TenantFilter + # Sync cache + Sync-CIPPMailboxPermissionCache -TenantFilter $TenantFilter -MailboxIdentity $username -User $UserAutomap -PermissionType 'FullAccess' -Action 'Add' + } catch { Write-LogMessage -headers $Request.Headers -API $APINAME-message "Could not add mailbox permissions for $($UserAutomap) on $($username)" -Sev 'Error' -tenant $TenantFilter $results.add( "Could not add $($UserAutomap) shared mailbox permissions for $($username). Error: $($_.Exception.Message)") @@ -48,6 +54,9 @@ Function Invoke-ExecEditMailboxPermissions { $MailboxPerms = New-ExoRequest -Anchor $username -tenantid $Tenantfilter -cmdlet 'Add-MailboxPermission' -cmdParams @{Identity = $userid; user = $UserNoAutomap; accessRights = @('FullAccess'); automapping = $false } $results.add( "Granted $UserNoAutomap access to $($username) Mailbox without automapping") Write-LogMessage -headers $Request.Headers -API $APINAME-message "Granted $UserNoAutomap access to $($username) Mailbox without automapping" -Sev 'Info' -tenant $TenantFilter + + # Sync cache + Sync-CIPPMailboxPermissionCache -TenantFilter $TenantFilter -MailboxIdentity $username -User $UserNoAutomap -PermissionType 'FullAccess' -Action 'Add' } catch { Write-LogMessage -headers $Request.Headers -API $APINAME-message "Could not add mailbox permissions for $($UserNoAutomap) on $($username)" -Sev 'Error' -tenant $TenantFilter $results.add("Could not add $($UserNoAutomap) shared mailbox permissions for $($username). Error: $($_.Exception.Message)") @@ -61,6 +70,9 @@ Function Invoke-ExecEditMailboxPermissions { $MailboxPerms = New-ExoRequest -Anchor $username -tenantid $Tenantfilter -cmdlet 'Add-RecipientPermission' -cmdParams @{Identity = $userid; Trustee = $UserSendAs; accessRights = @('SendAs') } $results.add( "Granted $UserSendAs access to $($username) with Send As permissions") Write-LogMessage -headers $Request.Headers -API $APINAME-message "Granted $UserSendAs access to $($username) with Send As permissions" -Sev 'Info' -tenant $TenantFilter + + # Sync cache + Sync-CIPPMailboxPermissionCache -TenantFilter $TenantFilter -MailboxIdentity $username -User $UserSendAs -PermissionType 'SendAs' -Action 'Add' } catch { Write-LogMessage -headers $Request.Headers -API $APINAME-message "Could not add mailbox permissions for $($UserSendAs) on $($username)" -Sev 'Error' -tenant $TenantFilter $results.add("Could not add $($UserSendAs) send-as permissions for $($username). Error: $($_.Exception.Message)") @@ -74,6 +86,9 @@ Function Invoke-ExecEditMailboxPermissions { $MailboxPerms = New-ExoRequest -Anchor $username -tenantid $Tenantfilter -cmdlet 'Remove-RecipientPermission' -cmdParams @{Identity = $userid; Trustee = $UserSendAs; accessRights = @('SendAs') } $results.add( "Removed $UserSendAs from $($username) with Send As permissions") Write-LogMessage -headers $Request.Headers -API $APINAME-message "Removed $UserSendAs from $($username) with Send As permissions" -Sev 'Info' -tenant $TenantFilter + + # Sync cache + Sync-CIPPMailboxPermissionCache -TenantFilter $TenantFilter -MailboxIdentity $username -User $UserSendAs -PermissionType 'SendAs' -Action 'Remove' } catch { Write-LogMessage -headers $Request.Headers -API $APINAME-message "Could not remove mailbox permissions for $($UserSendAs) on $($username)" -Sev 'Error' -tenant $TenantFilter $results.add("Could not remove $($UserSendAs) send-as permissions for $($username). Error: $($_.Exception.Message)") 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 8a9a94c0cbd3..94d7e806ccd8 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 @@ -42,7 +42,7 @@ function Invoke-CIPPOffboardingJob { } { $_.HideFromGAL -eq $true } { try { - Set-CIPPHideFromGAL -tenantFilter $TenantFilter -UserID $username -HideFromGAL $true -Headers $Headers -APIName $APIName + Set-CIPPHideFromGAL -tenantFilter $TenantFilter -UserID $username -hidefromgal $true -Headers $Headers -APIName $APIName } catch { $_.Exception.Message } @@ -151,28 +151,10 @@ function Invoke-CIPPOffboardingJob { } } { $_.removePermissions } { - if ($RunScheduled) { - Remove-CIPPMailboxPermissions -PermissionsLevel @('FullAccess', 'SendAs', 'SendOnBehalf') -userid 'AllUsers' -AccessUser $UserName -TenantFilter $TenantFilter -APIName $APINAME -Headers $Headers - - } else { - $Queue = New-CippQueueEntry -Name "Offboarding - Mailbox Permissions: $Username" -TotalTasks 1 - $InputObject = [PSCustomObject]@{ - Batch = @( - [PSCustomObject]@{ - 'FunctionName' = 'ExecOffboardingMailboxPermissions' - 'TenantFilter' = $TenantFilter - 'User' = $Username - 'Headers' = $Headers - 'APINAME' = $APINAME - 'QueueId' = $Queue.RowKey - } - ) - OrchestratorName = "OffboardingMailboxPermissions_$Username" - SkipLog = $true - } - $null = Start-NewOrchestration -FunctionName CIPPOrchestrator -InputObject ($InputObject | ConvertTo-Json -Depth 10) - "Removal of permissions queued. This task will run in the background and send it's results to the logbook." - } + Remove-CIPPMailboxPermissions -AccessUser $Username -TenantFilter $TenantFilter -UseCache -APIName $APIName -Headers $Headers + } + { $_.removeCalendarPermissions } { + Remove-CIPPCalendarPermissions -UserToRemove $Username -TenantFilter $TenantFilter -UseCache -APIName $APIName -Headers $Headers } { $_.RemoveMFADevices -eq $true } { try { diff --git a/Modules/CIPPCore/Public/Get-CIPPCalendarPermissionReport.ps1 b/Modules/CIPPCore/Public/Get-CIPPCalendarPermissionReport.ps1 index 4f7bd038fea4..e6c66c284a25 100644 --- a/Modules/CIPPCore/Public/Get-CIPPCalendarPermissionReport.ps1 +++ b/Modules/CIPPCore/Public/Get-CIPPCalendarPermissionReport.ps1 @@ -185,6 +185,7 @@ function Get-CIPPCalendarPermissionReport { Calendar = $_.MailboxDisplayName CalendarUPN = $_.MailboxUPN AccessRights = $_.AccessRights + FolderName = $_.FolderName } }) @@ -209,6 +210,7 @@ function Get-CIPPCalendarPermissionReport { [PSCustomObject]@{ User = $_.User AccessRights = $_.AccessRights + FolderName = $_.FolderName } }) @@ -216,6 +218,7 @@ function Get-CIPPCalendarPermissionReport { CalendarUPN = $CalendarUPN CalendarDisplayName = $CalendarInfo.MailboxDisplayName CalendarType = $CalendarInfo.MailboxType + FolderName = $CalendarInfo.FolderName PermissionCount = $_.Count Permissions = $PermissionDetails Tenant = $TenantFilter diff --git a/Modules/CIPPCore/Public/Get-CIPPMailboxPermissionReport.ps1 b/Modules/CIPPCore/Public/Get-CIPPMailboxPermissionReport.ps1 index 389dad4ddd1d..c27b00f532fb 100644 --- a/Modules/CIPPCore/Public/Get-CIPPMailboxPermissionReport.ps1 +++ b/Modules/CIPPCore/Public/Get-CIPPMailboxPermissionReport.ps1 @@ -31,7 +31,7 @@ function Get-CIPPMailboxPermissionReport { ) try { - Write-LogMessage -API 'MailboxPermissionReport' -tenant $TenantFilter -message 'Generating mailbox permission report' -sev Info + Write-LogMessage -API 'MailboxPermissionReport' -tenant $TenantFilter -message 'Generating mailbox permission report' -sev Debug # Handle AllTenants if ($TenantFilter -eq 'AllTenants') { diff --git a/Modules/CIPPCore/Public/Remove-CIPPCalendarPermissions.ps1 b/Modules/CIPPCore/Public/Remove-CIPPCalendarPermissions.ps1 new file mode 100644 index 000000000000..402eca0145b9 --- /dev/null +++ b/Modules/CIPPCore/Public/Remove-CIPPCalendarPermissions.ps1 @@ -0,0 +1,177 @@ +function Remove-CIPPCalendarPermissions { + <# + .SYNOPSIS + Remove calendar permissions for a specific user + + .DESCRIPTION + Removes calendar folder permissions for a user from specified calendars or all calendars they have access to + + .PARAMETER UserToRemove + The user whose calendar access should be removed + + .PARAMETER CalendarIdentity + Optional. Specific calendar identity (e.g., "mailbox@domain.com:\Calendar"). If not provided, will query from cache. + + .PARAMETER FolderName + Optional. Folder name (defaults to "Calendar"). Used with CalendarIdentity or when querying from cache. + + .PARAMETER TenantFilter + The tenant to operate on + + .PARAMETER UseCache + If specified, will query cached calendar permissions to find all calendars the user has access to + + .PARAMETER APIName + API name for logging (defaults to 'Remove Calendar Permissions') + + .PARAMETER Headers + Headers for logging + + .EXAMPLE + Remove-CIPPCalendarPermissions -UserToRemove 'user@domain.com' -CalendarIdentity 'mailbox@domain.com:\Calendar' -TenantFilter 'contoso.com' + + .EXAMPLE + Remove-CIPPCalendarPermissions -UserToRemove 'user@domain.com' -TenantFilter 'contoso.com' -UseCache + #> + [CmdletBinding()] + param ( + [Parameter(Mandatory = $true)] + [string]$UserToRemove, + + [Parameter(Mandatory = $false)] + [string]$CalendarIdentity, + + [Parameter(Mandatory = $false)] + [string]$FolderName = 'Calendar', + + [Parameter(Mandatory = $true)] + [string]$TenantFilter, + + [Parameter(Mandatory = $false)] + [switch]$UseCache, + + [Parameter(Mandatory = $false)] + [string]$APIName = 'Remove Calendar Permissions', + + [Parameter(Mandatory = $false)] + $Headers + ) + + try { + $Results = [System.Collections.Generic.List[string]]::new() + + if ($UseCache) { + # Get all calendars this user has access to from cache + try { + # Resolve user to display name if a UPN was provided + # Calendar permissions use display names, not UPNs + $UserToMatch = $UserToRemove + if ($UserToRemove -match '@') { + # Try to get display name from mailbox cache + $MailboxItems = Get-CIPPDbItem -TenantFilter $TenantFilter -Type 'Mailboxes' | Where-Object { $_.RowKey -ne 'Mailboxes-Count' } + foreach ($Item in $MailboxItems) { + $Mailbox = $Item.Data | ConvertFrom-Json + if ($Mailbox.UPN -eq $UserToRemove -or $Mailbox.primarySmtpAddress -eq $UserToRemove) { + $UserToMatch = $Mailbox.displayName + Write-Information "Resolved $UserToRemove to display name: $UserToMatch" -InformationAction Continue + break + } + } + } + + $CalendarPermissions = Get-CIPPCalendarPermissionReport -TenantFilter $TenantFilter -ByUser | Where-Object { $_.User -eq $UserToMatch } + + if (-not $CalendarPermissions -or $CalendarPermissions.Permissions.Count -eq 0) { + $Message = "No calendar permissions found for $UserToRemove in cached data" + Write-LogMessage -headers $Headers -API $APIName -message $Message -Sev 'Info' -tenant $TenantFilter + return $Message + } + + # Remove from each calendar + foreach ($CalPermEntry in $CalendarPermissions.Permissions) { + try { + $Folder = if ($CalPermEntry.FolderName) { $CalPermEntry.FolderName } else { 'Calendar' } + $CalIdentity = "$($CalPermEntry.CalendarUPN):\$Folder" + + $RemovalResult = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Remove-MailboxFolderPermission' -cmdParams @{ + Identity = $CalIdentity + User = $UserToMatch + } -UseSystemMailbox $true + + # Sync cache regardless of whether permission existed in Exchange + # Cache sync uses flexible matching so it will find and remove the entry + Sync-CIPPCalendarPermissionCache -TenantFilter $TenantFilter -MailboxIdentity $CalPermEntry.CalendarUPN -FolderName $Folder -User $UserToMatch -Action 'Remove' + + $SuccessMsg = "Removed $UserToRemove from calendar $CalIdentity" + Write-LogMessage -headers $Headers -API $APIName -message $SuccessMsg -Sev 'Info' -tenant $TenantFilter + $Results.Add($SuccessMsg) + } catch { + # Sync cache even on error (permission might not exist) + try { + Sync-CIPPCalendarPermissionCache -TenantFilter $TenantFilter -MailboxIdentity $CalPermEntry.CalendarUPN -FolderName $Folder -User $UserToMatch -Action 'Remove' + } catch { + Write-Verbose "Failed to sync cache: $_" + } + + $ErrorMsg = "Failed to remove $UserToRemove from calendar $($CalPermEntry.CalendarUPN): $($_.Exception.Message)" + Write-LogMessage -headers $Headers -API $APIName -message $ErrorMsg -Sev 'Warning' -tenant $TenantFilter + $Results.Add($ErrorMsg) + } + } + + $SummaryMsg = "Processed $($CalendarPermissions.CalendarCount) calendar(s) - removed $($Results.Count) permission(s)" + Write-LogMessage -headers $Headers -API $APIName -message $SummaryMsg -Sev 'Info' -tenant $TenantFilter + return $Results + + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -headers $Headers -API $APIName -message "Failed to query calendar permissions from cache: $($ErrorMessage.NormalizedError)" -Sev 'Error' -tenant $TenantFilter -LogData $ErrorMessage + throw "Failed to query calendar permissions from cache: $($ErrorMessage.NormalizedError)" + } + } else { + # Remove from specific calendar + if ([string]::IsNullOrEmpty($CalendarIdentity)) { + throw 'CalendarIdentity is required when not using cache' + } + + try { + $RemovalResult = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Remove-MailboxFolderPermission' -cmdParams @{ + Identity = $CalendarIdentity + User = $UserToRemove + } -UseSystemMailbox $true + + # Sync cache - extract mailbox UPN from identity + $MailboxUPN = if ($CalendarIdentity -match '^([^:]+):') { $Matches[1] } else { $CalendarIdentity } + $Folder = if ($CalendarIdentity -match ':\\(.+)$') { $Matches[1] } else { $FolderName } + + # Sync cache regardless of whether permission existed in Exchange + # Cache sync uses flexible matching so it will find and remove the entry + Sync-CIPPCalendarPermissionCache -TenantFilter $TenantFilter -MailboxIdentity $MailboxUPN -FolderName $Folder -User $UserToRemove -Action 'Remove' + + $SuccessMsg = "Removed $UserToRemove from calendar $CalendarIdentity" + Write-LogMessage -headers $Headers -API $APIName -message $SuccessMsg -Sev 'Info' -tenant $TenantFilter + return $SuccessMsg + + } catch { + # Sync cache even on error (permission might not exist) + $MailboxUPN = if ($CalendarIdentity -match '^([^:]+):') { $Matches[1] } else { $CalendarIdentity } + $Folder = if ($CalendarIdentity -match ':\\(.+)$') { $Matches[1] } else { $FolderName } + + try { + Sync-CIPPCalendarPermissionCache -TenantFilter $TenantFilter -MailboxIdentity $MailboxUPN -FolderName $Folder -User $UserToRemove -Action 'Remove' + } catch { + Write-Verbose "Failed to sync cache: $_" + } + + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -headers $Headers -API $APIName -message "Failed to remove calendar permission: $($ErrorMessage.NormalizedError)" -Sev 'Error' -tenant $TenantFilter -LogData $ErrorMessage + throw "Failed to remove calendar permission: $($ErrorMessage.NormalizedError)" + } + } + + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -headers $Headers -API $APIName -message "Could not remove calendar permissions for $UserToRemove. Error: $($ErrorMessage.NormalizedError)" -Sev 'Error' -tenant $TenantFilter -LogData $ErrorMessage + return "Could not remove calendar permissions for $UserToRemove. Error: $($ErrorMessage.NormalizedError)" + } +} diff --git a/Modules/CIPPCore/Public/Remove-CIPPMailboxPermissions.ps1 b/Modules/CIPPCore/Public/Remove-CIPPMailboxPermissions.ps1 index dc934b29088d..0b8927896be9 100644 --- a/Modules/CIPPCore/Public/Remove-CIPPMailboxPermissions.ps1 +++ b/Modules/CIPPCore/Public/Remove-CIPPMailboxPermissions.ps1 @@ -5,12 +5,54 @@ function Remove-CIPPMailboxPermissions { $AccessUser, $TenantFilter, $PermissionsLevel, + + [Parameter(Mandatory = $false)] + [switch]$UseCache, + $APIName = 'Manage Shared Mailbox Access', $Headers ) try { - if ($userid -eq 'AllUsers') { + if ($UseCache.IsPresent) { + # Use cached permission report to find all mailboxes the user has access to + + Write-Information "Accessing cached mailbox permissions for $AccessUser in tenant $TenantFilter" -InformationAction Continue + Write-LogMessage -headers $Headers -API $APIName -message "Removing mailbox permissions for $AccessUser using cached permission report" -Sev 'Info' -tenant $TenantFilter + + $UserPermissions = Get-CIPPMailboxPermissionReport -TenantFilter $TenantFilter -ByUser | Where-Object { $_.User -eq $AccessUser } + + if (-not $UserPermissions -or $UserPermissions.Permissions.Count -eq 0) { + $Message = "No mailbox permissions found for $AccessUser in cached data" + Write-LogMessage -headers $Headers -API $APIName -message $Message -Sev 'Info' -tenant $TenantFilter + return $Message + } + + $Results = [System.Collections.Generic.List[string]]::new() + + # Loop through each mailbox and remove permissions + foreach ($PermissionEntry in $UserPermissions.Permissions) { + $MailboxUPN = $PermissionEntry.MailboxUPN + $AccessRights = $PermissionEntry.AccessRights -split ', ' + + try { + # Recursively call this function without UseCache + $Result = Remove-CIPPMailboxPermissions -userid $MailboxUPN -AccessUser $AccessUser -TenantFilter $TenantFilter -PermissionsLevel $AccessRights -APIName $APIName -Headers $Headers + if ($Result) { + $Results.Add($Result) + } + } catch { + $ErrorMsg = "Failed to remove permissions from $MailboxUPN for $AccessUser : $($_.Exception.Message)" + Write-LogMessage -headers $Headers -API $APIName -message $ErrorMsg -Sev 'Warning' -tenant $TenantFilter + $Results.Add($ErrorMsg) + } + } + + $SummaryMsg = "Processed $($UserPermissions.MailboxCount) mailbox(es) - removed $($Results.Count) permission(s)" + Write-LogMessage -headers $Headers -API $APIName -message $SummaryMsg -Sev 'Info' -tenant $TenantFilter + return $Results + + } elseif ($userid -eq 'AllUsers') { $Mailboxes = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-Mailbox' -Select UserPrincipalName $Mailboxes | ForEach-Object -Parallel { Import-Module '.\Modules\AzBobbyTables' @@ -20,19 +62,28 @@ function Remove-CIPPMailboxPermissions { } -ThrottleLimit 10 } else { $Results = $PermissionsLevel | ForEach-Object { + Write-Information "Removing $($_) permissions for $AccessUser on mailbox $userid" -InformationAction Continue switch ($_) { 'SendOnBehalf' { $MailboxPerms = New-ExoRequest -Anchor $UserId -tenantid $Tenantfilter -cmdlet 'Set-Mailbox' -cmdParams @{Identity = $userid; GrantSendonBehalfTo = @{'@odata.type' = '#Exchange.GenericHashTable'; remove = $AccessUser }; } if ($MailboxPerms -notlike '*completed successfully but no settings of*') { Write-LogMessage -headers $Headers -API $APIName -message "Removed SendOnBehalf permissions for $($AccessUser) from $($userid)'s mailbox." -Sev 'Info' -tenant $TenantFilter + # Note: SendOnBehalf not cached as separate permission "Removed SendOnBehalf permissions for $($AccessUser) from $($userid)'s mailbox." } } 'SendAS' { $MailboxPerms = New-ExoRequest -Anchor $userId -tenantid $Tenantfilter -cmdlet 'Remove-RecipientPermission' -cmdParams @{Identity = $userid; Trustee = $AccessUser; accessRights = @('SendAs') } + + # Sync cache regardless of whether permission existed + Sync-CIPPMailboxPermissionCache -TenantFilter $TenantFilter -MailboxIdentity $userid -User $AccessUser -PermissionType 'SendAs' -Action 'Remove' + if ($MailboxPerms -notlike "*because the ACE isn't present*") { Write-LogMessage -headers $Headers -API $APIName -message "Removed SendAs permissions for $($AccessUser) from $($userid)'s mailbox." -Sev 'Info' -tenant $TenantFilter "Removed SendAs permissions for $($AccessUser) from $($userid)'s mailbox." + } else { + Write-LogMessage -headers $Headers -API $APIName -message "SendAs permissions for $($AccessUser) on $($userid)'s mailbox were already removed or don't exist." -Sev 'Info' -tenant $TenantFilter + "SendAs permissions for $($AccessUser) on $($userid)'s mailbox were already removed or don't exist." } } 'FullAccess' { @@ -49,9 +100,15 @@ function Remove-CIPPMailboxPermissions { } $permissions = New-ExoRequest @ExoRequest + # Sync cache regardless of whether permission existed + Sync-CIPPMailboxPermissionCache -TenantFilter $TenantFilter -MailboxIdentity $userid -User $AccessUser -PermissionType 'FullAccess' -Action 'Remove' + if ($permissions -notlike "*because the ACE doesn't exist on the object.*") { Write-LogMessage -headers $Headers -API $APIName -message "Removed FullAccess permissions for $($AccessUser) from $($userid)'s mailbox." -Sev 'Info' -tenant $TenantFilter "Removed FullAccess permissions for $($AccessUser) from $($userid)'s mailbox." + } else { + Write-LogMessage -headers $Headers -API $APIName -message "FullAccess permissions for $($AccessUser) on $($userid)'s mailbox were already removed or don't exist." -Sev 'Info' -tenant $TenantFilter + "FullAccess permissions for $($AccessUser) on $($userid)'s mailbox were already removed or don't exist." } } } diff --git a/Modules/CIPPCore/Public/Set-CIPPCalendarPermission.ps1 b/Modules/CIPPCore/Public/Set-CIPPCalendarPermission.ps1 index 57d2c4695680..adba643d3f2b 100644 --- a/Modules/CIPPCore/Public/Set-CIPPCalendarPermission.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPCalendarPermission.ps1 @@ -38,6 +38,9 @@ function Set-CIPPCalendarPermission { $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Remove-MailboxFolderPermission' -cmdParams @{Identity = "$($UserID):\$FolderName"; User = $RemoveAccess } $Result = "Successfully removed access for $LoggingName from calendar $($CalParam.Identity)" Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message $Result -sev Info + + # Sync cache + Sync-CIPPCalendarPermissionCache -TenantFilter $TenantFilter -MailboxIdentity $UserID -FolderName $FolderName -User $RemoveAccess -Action 'Remove' } } else { if ($PSCmdlet.ShouldProcess("$UserID\$FolderName", "Set permissions for $LoggingName to $Permissions")) { @@ -54,6 +57,9 @@ function Set-CIPPCalendarPermission { $Result += ' A notification has been sent to the user.' } Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message $Result -sev Info + + # Sync cache + Sync-CIPPCalendarPermissionCache -TenantFilter $TenantFilter -MailboxIdentity $UserID -FolderName $FolderName -User $UserToGetPermissions -Permissions $Permissions -Action 'Add' } } } catch { diff --git a/Modules/CIPPCore/Public/Sync-CIPPCalendarPermissionCache.ps1 b/Modules/CIPPCore/Public/Sync-CIPPCalendarPermissionCache.ps1 new file mode 100644 index 000000000000..fc59916b8fcc --- /dev/null +++ b/Modules/CIPPCore/Public/Sync-CIPPCalendarPermissionCache.ps1 @@ -0,0 +1,173 @@ +function Sync-CIPPCalendarPermissionCache { + <# + .SYNOPSIS + Synchronize calendar permission changes to the cached reporting database + + .DESCRIPTION + Updates the cached calendar permissions in the reporting database when permissions are + added or removed via CIPP, keeping the cache in sync with actual permissions. + + .PARAMETER TenantFilter + The tenant domain or GUID + + .PARAMETER MailboxIdentity + The mailbox identity (UPN or email) + + .PARAMETER FolderName + The calendar folder name + + .PARAMETER User + The user being granted or removed permissions + + .PARAMETER Permissions + The permission level being granted + + .PARAMETER Action + Whether to 'Add' or 'Remove' the permission + + .EXAMPLE + Sync-CIPPCalendarPermissionCache -TenantFilter 'contoso.com' -MailboxIdentity 'user@contoso.com' -FolderName 'Calendar' -User 'guest@contoso.com' -Permissions 'Editor' -Action 'Add' + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter, + + [Parameter(Mandatory = $true)] + [string]$MailboxIdentity, + + [Parameter(Mandatory = $true)] + [string]$FolderName, + + [Parameter(Mandatory = $true)] + [string]$User, + + [Parameter(Mandatory = $false)] + [string]$Permissions, + + [Parameter(Mandatory = $true)] + [ValidateSet('Add', 'Remove')] + [string]$Action + ) + + try { + $CalendarIdentity = "$MailboxIdentity`:\$FolderName" + + # Resolve user to display name if a UPN was provided + # Calendar permissions use display names, not UPNs + $UserToCache = $User + if ($User -match '@') { + # Try to get display name from mailbox cache + $MailboxItems = Get-CIPPDbItem -TenantFilter $TenantFilter -Type 'Mailboxes' | Where-Object { $_.RowKey -ne 'Mailboxes-Count' } + foreach ($Item in $MailboxItems) { + $Mailbox = $Item.Data | ConvertFrom-Json + if ($Mailbox.UPN -eq $User -or $Mailbox.primarySmtpAddress -eq $User) { + $UserToCache = $Mailbox.displayName + Write-Information "Resolved $User to display name: $UserToCache" -InformationAction Continue + break + } + } + } + + if ($Action -eq 'Add') { + # Create calendar permission object in the same format as cached permissions + $PermissionObject = [PSCustomObject]@{ + id = [guid]::NewGuid().ToString() + Identity = $CalendarIdentity + User = $UserToCache + AccessRights = $Permissions + FolderName = $FolderName + } + + # Add to cache using Append to not clear existing entries + $PermissionObject | Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'CalendarPermissions' -Append + + Write-LogMessage -API 'CalendarPermissionCache' -tenant $TenantFilter ` + -message "Added calendar permission cache entry: $UserToCache on $CalendarIdentity with $Permissions" -sev Debug + + } else { + # Remove from cache - need to find the item by Identity and User combination + try { + $Table = Get-CippTable -tablename 'CippReportingDB' + + # Build mailbox lookup for flexible Identity matching (same as report function) + $MailboxItems = Get-CIPPDbItem -TenantFilter $TenantFilter -Type 'Mailboxes' | Where-Object { $_.RowKey -ne 'Mailboxes-Count' } + $MailboxLookup = @{} + $MailboxByIdLookup = @{} + $MailboxByExternalIdLookup = @{} + + foreach ($Item in $MailboxItems) { + $Mailbox = $Item.Data | ConvertFrom-Json + if ($Mailbox.UPN) { + $MailboxLookup[$Mailbox.UPN.ToLower()] = @{ + UPN = $Mailbox.UPN + Id = $Mailbox.Id + ExternalDirectoryObjectId = $Mailbox.ExternalDirectoryObjectId + } + } + if ($Mailbox.primarySmtpAddress) { + $MailboxLookup[$Mailbox.primarySmtpAddress.ToLower()] = @{ + UPN = if ($Mailbox.UPN) { $Mailbox.UPN } else { $Mailbox.primarySmtpAddress } + Id = $Mailbox.Id + ExternalDirectoryObjectId = $Mailbox.ExternalDirectoryObjectId + } + } + if ($Mailbox.Id) { + $MailboxByIdLookup[$Mailbox.Id] = $Mailbox.UPN + } + if ($Mailbox.ExternalDirectoryObjectId) { + $MailboxByExternalIdLookup[$Mailbox.ExternalDirectoryObjectId] = $Mailbox.UPN + } + } + + # Get all possible identifiers for the target mailbox + $TargetMailboxInfo = $MailboxLookup[$MailboxIdentity.ToLower()] + $PossibleIdentities = @($MailboxIdentity) + if ($TargetMailboxInfo) { + if ($TargetMailboxInfo.Id) { $PossibleIdentities += $TargetMailboxInfo.Id } + if ($TargetMailboxInfo.ExternalDirectoryObjectId) { $PossibleIdentities += $TargetMailboxInfo.ExternalDirectoryObjectId } + if ($TargetMailboxInfo.UPN) { $PossibleIdentities += $TargetMailboxInfo.UPN } + } + + # Build all possible calendar identities (combining each mailbox identifier with folder name) + $PossibleCalendarIdentities = $PossibleIdentities | ForEach-Object { "$_`:\$FolderName" } + + # Query for all CalendarPermissions for this tenant + $Filter = "PartitionKey eq '{0}' and RowKey ge 'CalendarPermissions-' and RowKey lt 'CalendarPermissions0'" -f $TenantFilter + $AllPermissions = Get-CIPPAzDataTableEntity @Table -Filter $Filter | Where-Object { $_.RowKey -ne 'CalendarPermissions-Count' } + + # Find the specific permission entry that matches + foreach ($CachedPerm in $AllPermissions) { + # Skip entries with null or empty Data + if ([string]::IsNullOrEmpty($CachedPerm.Data)) { + continue + } + + $PermData = $CachedPerm.Data | ConvertFrom-Json + + # Match on Identity (flexible) and User + if ($PossibleCalendarIdentities -contains $PermData.Identity -and $PermData.User -eq $User) { + + # Extract ItemId from RowKey (format: "Type-ItemId") + Write-Information "Removing calendar permission cache entry: $User on $CalendarIdentity (matched via $($PermData.Identity))" -InformationAction Continue + $ItemId = $CachedPerm.RowKey -replace '^CalendarPermissions-', '' + Remove-CIPPDbItem -TenantFilter $TenantFilter -Type 'CalendarPermissions' -ItemId $ItemId + + Write-Information "Removed calendar permission cache entry: $User on $CalendarIdentity" -InformationAction Continue + Write-LogMessage -API 'CalendarPermissionCache' -tenant $TenantFilter ` + -message "Removed calendar permission cache entry: $User on $CalendarIdentity" -sev Debug + break + } + } + } catch { + Write-LogMessage -API 'CalendarPermissionCache' -tenant $TenantFilter ` + -message "Failed to remove calendar permission cache entry: $($_.Exception.Message)" -sev Warning + Write-Information "Failed to remove calendar permission cache entry: $($_.Exception.Message)" -InformationAction Continue + } + } + } catch { + Write-LogMessage -API 'CalendarPermissionCache' -tenant $TenantFilter ` + -message "Failed to sync calendar permission cache: $($_.Exception.Message)" -sev Warning + # Don't throw - cache sync failures shouldn't break the main operation + } +} diff --git a/Modules/CIPPCore/Public/Sync-CIPPMailboxPermissionCache.ps1 b/Modules/CIPPCore/Public/Sync-CIPPMailboxPermissionCache.ps1 new file mode 100644 index 000000000000..59e0fa504ee3 --- /dev/null +++ b/Modules/CIPPCore/Public/Sync-CIPPMailboxPermissionCache.ps1 @@ -0,0 +1,157 @@ +function Sync-CIPPMailboxPermissionCache { + <# + .SYNOPSIS + Synchronize mailbox permission changes to the cached reporting database + + .DESCRIPTION + Updates the cached mailbox permissions in the reporting database when permissions are + added or removed via CIPP, keeping the cache in sync with actual permissions. + + .PARAMETER TenantFilter + The tenant domain or GUID + + .PARAMETER MailboxIdentity + The mailbox identity (UPN or email) + + .PARAMETER User + The user/trustee being granted or removed permissions + + .PARAMETER PermissionType + The type of permission: 'FullAccess', 'SendAs', or 'SendOnBehalf' + + .PARAMETER Action + Whether to 'Add' or 'Remove' the permission + + .EXAMPLE + Sync-CIPPMailboxPermissionCache -TenantFilter 'contoso.com' -MailboxIdentity 'mailbox@contoso.com' -User 'user@contoso.com' -PermissionType 'FullAccess' -Action 'Add' + + .EXAMPLE + Sync-CIPPMailboxPermissionCache -TenantFilter 'contoso.com' -MailboxIdentity 'mailbox@contoso.com' -User 'user@contoso.com' -PermissionType 'SendAs' -Action 'Remove' + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter, + + [Parameter(Mandatory = $true)] + [string]$MailboxIdentity, + + [Parameter(Mandatory = $true)] + [string]$User, + + [Parameter(Mandatory = $true)] + [ValidateSet('FullAccess', 'SendAs', 'SendOnBehalf')] + [string]$PermissionType, + + [Parameter(Mandatory = $true)] + [ValidateSet('Add', 'Remove')] + [string]$Action + ) + + try { + if ($Action -eq 'Add') { + # Create permission object in the same format as cached permissions + $PermissionObject = [PSCustomObject]@{ + id = [guid]::NewGuid().ToString() + Identity = $MailboxIdentity + User = $User + AccessRights = @($PermissionType) + IsInherited = $false + Deny = $false + } + + # Determine which type to use based on permission + $Type = if ($PermissionType -eq 'SendAs') { 'MailboxPermissions' } else { 'MailboxPermissions' } + + # Add to cache using Append to not clear existing entries + $PermissionObject | Add-CIPPDbItem -TenantFilter $TenantFilter -Type $Type -Append + + Write-LogMessage -API 'MailboxPermissionCache' -tenant $TenantFilter ` + -message "Added $PermissionType permission cache entry: $User on $MailboxIdentity" -sev Debug + + } else { + # Remove from cache - need to find the item by Identity and User combination + try { + $Table = Get-CippTable -tablename 'CippReportingDB' + + # Build mailbox lookup for flexible Identity matching (same as report function) + $MailboxItems = Get-CIPPDbItem -TenantFilter $TenantFilter -Type 'Mailboxes' | Where-Object { $_.RowKey -ne 'Mailboxes-Count' } + $MailboxLookup = @{} + $MailboxByIdLookup = @{} + $MailboxByExternalIdLookup = @{} + + foreach ($Item in $MailboxItems) { + $Mailbox = $Item.Data | ConvertFrom-Json + if ($Mailbox.UPN) { + $MailboxLookup[$Mailbox.UPN.ToLower()] = @{ + UPN = $Mailbox.UPN + Id = $Mailbox.Id + ExternalDirectoryObjectId = $Mailbox.ExternalDirectoryObjectId + } + } + if ($Mailbox.primarySmtpAddress) { + $MailboxLookup[$Mailbox.primarySmtpAddress.ToLower()] = @{ + UPN = if ($Mailbox.UPN) { $Mailbox.UPN } else { $Mailbox.primarySmtpAddress } + Id = $Mailbox.Id + ExternalDirectoryObjectId = $Mailbox.ExternalDirectoryObjectId + } + } + if ($Mailbox.Id) { + $MailboxByIdLookup[$Mailbox.Id] = $Mailbox.UPN + } + if ($Mailbox.ExternalDirectoryObjectId) { + $MailboxByExternalIdLookup[$Mailbox.ExternalDirectoryObjectId] = $Mailbox.UPN + } + } + + # Get all possible identifiers for the target mailbox + $TargetMailboxInfo = $MailboxLookup[$MailboxIdentity.ToLower()] + $PossibleIdentities = @($MailboxIdentity) + if ($TargetMailboxInfo) { + if ($TargetMailboxInfo.Id) { $PossibleIdentities += $TargetMailboxInfo.Id } + if ($TargetMailboxInfo.ExternalDirectoryObjectId) { $PossibleIdentities += $TargetMailboxInfo.ExternalDirectoryObjectId } + if ($TargetMailboxInfo.UPN) { $PossibleIdentities += $TargetMailboxInfo.UPN } + } + + # Query for all MailboxPermissions for this tenant + $Filter = "PartitionKey eq '{0}' and RowKey ge 'MailboxPermissions-' and RowKey lt 'MailboxPermissions0'" -f $TenantFilter + $AllPermissions = Get-CIPPAzDataTableEntity @Table -Filter $Filter | Where-Object { $_.RowKey -ne 'MailboxPermissions-Count' } + + # Find the specific permission entry that matches + foreach ($CachedPerm in $AllPermissions) { + # Skip entries with null or empty Data + if ([string]::IsNullOrEmpty($CachedPerm.Data)) { + continue + } + + $PermData = $CachedPerm.Data | ConvertFrom-Json + + # Match on Identity (flexible), User, and AccessRights + if ($PossibleIdentities -contains $PermData.Identity -and + $PermData.User -eq $User -and + $PermData.AccessRights -contains $PermissionType) { + + # Extract ItemId from RowKey (format: "Type-ItemId") + Write-Information "Removing $PermissionType permission cache entry: $User on $MailboxIdentity (matched via $($PermData.Identity))" -InformationAction Continue + $ItemId = $CachedPerm.RowKey -replace '^MailboxPermissions-', '' + Remove-CIPPDbItem -TenantFilter $TenantFilter -Type 'MailboxPermissions' -ItemId $ItemId + + Write-Information "Removed $PermissionType permission cache entry: $User on $MailboxIdentity" -InformationAction Continue + Write-LogMessage -API 'MailboxPermissionCache' -tenant $TenantFilter ` + -message "Removed $PermissionType permission cache entry: $User on $MailboxIdentity" -sev Debug + break + } + } + } catch { + Write-LogMessage -API 'MailboxPermissionCache' -tenant $TenantFilter ` + -message "Failed to remove permission cache entry: $($_.Exception.Message)" -sev Warning + Write-Information "Failed to remove permission cache entry: $($_.Exception.Message)" -InformationAction Continue + } + } + } catch { + Write-LogMessage -API 'MailboxPermissionCache' -tenant $TenantFilter ` + -message "Failed to sync permission cache: $($_.Exception.Message)" -sev Warning + # Don't throw - cache sync failures shouldn't break the main operation + Write-Information "Failed to sync permission cache: $($_.Exception.Message)" -InformationAction Continue + } +} From 7dfd70dbeb8b2d73edabfc8eccf56ef10236cac5 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Sat, 14 Feb 2026 23:50:43 -0500 Subject: [PATCH 27/97] Use cached domains and adjust orchestrator schedule Replace live Graph API domain queries with cached DB reads in Push-DomainAnalyserTenant: use Get-Tenants -TenantFilter, fetch domains via New-CIPPDbRequest, log and return when no cached data, and filter/clean domains as before. Also update CIPPTimers.json for Start-DomainOrchestrator to run at 03:30 daily and increase its priority from 10 to 22. These changes reduce Graph API calls, rely on cached data for domain analysis, and shift/or reprioritize the orchestrator run time. --- CIPPTimers.json | 4 ++-- .../Domain Analyser/Push-DomainAnalyserTenant.ps1 | 12 ++++++++++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/CIPPTimers.json b/CIPPTimers.json index f76dba8941e2..35586b72833e 100644 --- a/CIPPTimers.json +++ b/CIPPTimers.json @@ -135,8 +135,8 @@ "Id": "c2ebde3f-fa35-45aa-8a6b-91c835050b79", "Command": "Start-DomainOrchestrator", "Description": "Orchestrator to process domains", - "Cron": "0 0 0 * * *", - "Priority": 10, + "Cron": "0 30 3 * * *", + "Priority": 22, "RunOnProcessor": true }, { diff --git a/Modules/CIPPCore/Public/Entrypoints/Activity Triggers/Domain Analyser/Push-DomainAnalyserTenant.ps1 b/Modules/CIPPCore/Public/Entrypoints/Activity Triggers/Domain Analyser/Push-DomainAnalyserTenant.ps1 index 43444b4a4101..f3ac78cf719a 100644 --- a/Modules/CIPPCore/Public/Entrypoints/Activity Triggers/Domain Analyser/Push-DomainAnalyserTenant.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/Activity Triggers/Domain Analyser/Push-DomainAnalyserTenant.ps1 @@ -5,7 +5,7 @@ function Push-DomainAnalyserTenant { #> param($Item) - $Tenant = Get-Tenants -IncludeAll | Where-Object { $_.customerId -eq $Item.customerId } | Select-Object -First 1 + $Tenant = Get-Tenants -TenantFilter $Item.customerId $DomainTable = Get-CippTable -tablename 'Domains' if ($Tenant.Excluded -eq $true) { @@ -20,6 +20,14 @@ function Push-DomainAnalyserTenant { return } else { try { + # Get domains from cached database instead of making Graph API calls + $Domains = New-CIPPDbRequest -TenantFilter $Tenant.defaultDomainName -Type 'Domains' + + if (-not $Domains) { + Write-LogMessage -API 'DomainAnalyser' -tenant $Tenant.defaultDomainName -tenantid $Tenant.customerId -message 'No cached domain data found. Domain analysis will be skipped until data collection completes.' -sev Info + return + } + # Remove domains that are not wanted, and used for cloud signature services. Same exclusions also found in Invoke-CIPPStandardAddDKIM $ExclusionDomains = @( '*.microsoftonline.com' @@ -35,7 +43,7 @@ function Push-DomainAnalyserTenant { '*.ucconnect.co.uk' '*.teams-sbc.dk' ) - $Domains = New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/domains' -tenantid $Tenant.customerId | Where-Object { $_.isVerified -eq $true } | ForEach-Object { + $Domains = $Domains | Where-Object { $_.isVerified -eq $true } | ForEach-Object { $Domain = $_ foreach ($ExclusionDomain in $ExclusionDomains) { if ($Domain.id -like $ExclusionDomain) { From a3b63a33ac660060b55860bb3213779f63e1af3b Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Sun, 15 Feb 2026 10:29:41 +0100 Subject: [PATCH 28/97] fixed #5275 --- Modules/CIPPCore/Public/New-CIPPRestoreTask.ps1 | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Modules/CIPPCore/Public/New-CIPPRestoreTask.ps1 b/Modules/CIPPCore/Public/New-CIPPRestoreTask.ps1 index aec6db68de03..d750ce6cdea9 100644 --- a/Modules/CIPPCore/Public/New-CIPPRestoreTask.ps1 +++ b/Modules/CIPPCore/Public/New-CIPPRestoreTask.ps1 @@ -200,8 +200,10 @@ function New-CIPPRestoreTask { $backupGroups = if ($BackupData.groups -is [string]) { $BackupData.groups | ConvertFrom-Json } else { $BackupData.groups } $Groups = New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/groups?$top=999' -tenantid $TenantFilter $BackupGroups | ForEach-Object { + try { - $JSON = $_ | ConvertTo-Json -Depth 100 -Compress + $CleanObj = Clean-GraphObject $_ + $JSON = $CleanObj | ConvertTo-Json -Depth 100 -Compress $DisplayName = $_.displayName if ($overwrite) { if ($_.id -in $Groups.id) { From 63316dd77d1a04010a26ad06608d50f0b49ae56a Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Sun, 15 Feb 2026 11:09:33 +0100 Subject: [PATCH 29/97] Add group membership change alert --- .../Get-CIPPAlertGroupMembershipChange.ps1 | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 Modules/CIPPCore/Public/Alerts/Get-CIPPAlertGroupMembershipChange.ps1 diff --git a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertGroupMembershipChange.ps1 b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertGroupMembershipChange.ps1 new file mode 100644 index 000000000000..79336a3eed63 --- /dev/null +++ b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertGroupMembershipChange.ps1 @@ -0,0 +1,47 @@ +function Get-CIPPAlertGroupMembershipChange { + <# + .FUNCTIONALITY + Entrypoint + #> + [CmdletBinding()] + param ( + [Parameter(Mandatory = $false)] + [Alias('input')] + $InputValue, + $TenantFilter + ) + + try { + $MonitoredGroups = $InputValue -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ } + if (!$MonitoredGroups) { return $true } + + $OneHourAgo = (Get-Date).AddHours(-3).ToString('yyyy-MM-ddTHH:mm:ssZ') + $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] + $GroupProp = ($Member.modifiedProperties | Where-Object { $_.displayName -eq 'Group.DisplayName' }) + $GroupDisplayName = (($GroupProp.newValue ?? $GroupProp.oldValue) -replace '"', '') + if (!$GroupDisplayName -or !($MonitoredGroups | Where-Object { $GroupDisplayName -like $_ })) { continue } + + $InitiatedBy = if ($Log.initiatedBy.user) { $Log.initiatedBy.user.userPrincipalName } else { $Log.initiatedBy.app.displayName } + $Action = if ($Log.activityDisplayName -eq 'Add member to group') { 'added to' } else { 'removed from' } + + [PSCustomObject]@{ + Message = "$($Member.userPrincipalName ?? $Member.displayName) was $Action group '$GroupDisplayName' by $InitiatedBy" + GroupName = $GroupDisplayName + MemberName = $Member.userPrincipalName ?? $Member.displayName + Action = $Log.activityDisplayName + InitiatedBy = $InitiatedBy + ActivityTime = $Log.activityDateTime + Tenant = $TenantFilter + } + } + + if ($AlertData) { + Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData + } + } catch { + Write-LogMessage -API 'Alerts' -tenant $TenantFilter -message "Could not check group membership changes for $($TenantFilter): $(Get-NormalizedError -message $_.Exception.message)" -sev Error + } +} From 31f17730ddc0c20decb514e18a3843d1ff03eb58 Mon Sep 17 00:00:00 2001 From: rvdwegen Date: Sun, 15 Feb 2026 20:14:53 +0100 Subject: [PATCH 30/97] DetectedApps --- .../Push-CIPPDBCacheData.ps1 | 1 + .../Invoke-ListDetectedAppDevices.ps1 | 40 ++++++++++ .../Entrypoints/Invoke-ListDetectedApps.ps1 | 78 +++++++++++++++++++ .../Public/Set-CIPPDBCacheDetectedApps.ps1 | 75 ++++++++++++++++++ 4 files changed, 194 insertions(+) create mode 100644 Modules/CIPPCore/Public/Entrypoints/Invoke-ListDetectedAppDevices.ps1 create mode 100644 Modules/CIPPCore/Public/Entrypoints/Invoke-ListDetectedApps.ps1 create mode 100644 Modules/CIPPCore/Public/Set-CIPPDBCacheDetectedApps.ps1 diff --git a/Modules/CIPPCore/Public/Entrypoints/Activity Triggers/Push-CIPPDBCacheData.ps1 b/Modules/CIPPCore/Public/Entrypoints/Activity Triggers/Push-CIPPDBCacheData.ps1 index 6bdf8f889ddf..9351980ef740 100644 --- a/Modules/CIPPCore/Public/Entrypoints/Activity Triggers/Push-CIPPDBCacheData.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/Activity Triggers/Push-CIPPDBCacheData.ps1 @@ -158,6 +158,7 @@ function Push-CIPPDBCacheData { 'IntunePolicies' 'ManagedDeviceEncryptionStates' 'IntuneAppProtectionPolicies' + 'DetectedApps' ) foreach ($CacheFunction in $IntuneCacheFunctions) { $Batch.Add(@{ diff --git a/Modules/CIPPCore/Public/Entrypoints/Invoke-ListDetectedAppDevices.ps1 b/Modules/CIPPCore/Public/Entrypoints/Invoke-ListDetectedAppDevices.ps1 new file mode 100644 index 000000000000..e394db309281 --- /dev/null +++ b/Modules/CIPPCore/Public/Entrypoints/Invoke-ListDetectedAppDevices.ps1 @@ -0,0 +1,40 @@ +function Invoke-ListDetectedAppDevices { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Identity.Device.Read + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $TenantFilter = $Request.Query.tenantFilter + $AppID = $Request.Query.AppID + + # Get managed devices where a specific detected app is installed + # Uses deviceManagement/detectedApps/{id}/managedDevices endpoint + + try { + if (-not $AppID) { + throw "AppID parameter is required" + } + + $GraphRequest = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/deviceManagement/detectedApps/$AppID/managedDevices" -Tenantid $TenantFilter + + # Ensure we return an array even if null + if ($null -eq $GraphRequest) { + $GraphRequest = @() + } + + $StatusCode = [HttpStatusCode]::OK + } catch { + $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message + $StatusCode = [HttpStatusCode]::OK + $GraphRequest = $ErrorMessage + } + + return [HttpResponseContext]@{ + StatusCode = $StatusCode + Body = @($GraphRequest) + } +} diff --git a/Modules/CIPPCore/Public/Entrypoints/Invoke-ListDetectedApps.ps1 b/Modules/CIPPCore/Public/Entrypoints/Invoke-ListDetectedApps.ps1 new file mode 100644 index 000000000000..0a8a754c271a --- /dev/null +++ b/Modules/CIPPCore/Public/Entrypoints/Invoke-ListDetectedApps.ps1 @@ -0,0 +1,78 @@ +function Invoke-ListDetectedApps { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Identity.Device.Read + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $TenantFilter = $Request.Query.tenantFilter + $DeviceID = $Request.Query.DeviceID + $IncludeDevices = $Request.Query.includeDevices + + # This is all about the deviceManagement/detectedApps endpoint + # We need to get the detected apps for a given device or the entire tenant + # If no device ID is provided, we need to get the detected apps for the entire tenant + # If a device ID is provided, we need to get the detected apps for the device + # deviceManagement/detectedApps for the entire tenant, or deviceManagement/managedDevices/$DeviceID/detectedApps for the device + # If includeDevices is true, we can use deviceManagement/detectedApps/{id}/managedDevices to get devices where each app is installed + + try { + # If DeviceID is provided, get detected apps for that device + if ($DeviceID) { + $GraphRequest = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/deviceManagement/managedDevices/$DeviceID/detectedApps" -Tenantid $TenantFilter + } + # If no device ID is provided, get detected apps for the entire tenant + else { + $GraphRequest = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/deviceManagement/detectedApps" -Tenantid $TenantFilter + } + + # Ensure we return an array even if null + if ($null -eq $GraphRequest) { + $GraphRequest = @() + } + + # If includeDevices is requested and we have detected apps, fetch devices for each app + if ($IncludeDevices -and $GraphRequest -and ($GraphRequest | Measure-Object).Count -gt 0) { + # Build bulk requests to get devices for each detected app + $BulkRequests = [System.Collections.Generic.List[object]]::new() + foreach ($App in $GraphRequest) { + if ($App.id) { + $BulkRequests.Add(@{ + id = $App.id + method = 'GET' + url = "deviceManagement/detectedApps('$($App.id)')/managedDevices" + }) + } + } + + if ($BulkRequests.Count -gt 0) { + $BulkResults = New-GraphBulkRequest -Requests $BulkRequests -tenantid $TenantFilter + + # Merge device information back into each detected app + $GraphRequest = foreach ($App in $GraphRequest) { + $Devices = Get-GraphBulkResultByID -Results $BulkResults -ID $App.id -Value + if ($Devices) { + $App | Add-Member -NotePropertyName 'managedDevices' -NotePropertyValue $Devices -Force + } else { + $App | Add-Member -NotePropertyName 'managedDevices' -NotePropertyValue @() -Force + } + $App + } + } + } + + $StatusCode = [HttpStatusCode]::OK + } catch { + $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message + $StatusCode = [HttpStatusCode]::OK + $GraphRequest = $ErrorMessage + } + + return [HttpResponseContext]@{ + StatusCode = $StatusCode + Body = @($GraphRequest) + } +} diff --git a/Modules/CIPPCore/Public/Set-CIPPDBCacheDetectedApps.ps1 b/Modules/CIPPCore/Public/Set-CIPPDBCacheDetectedApps.ps1 new file mode 100644 index 000000000000..2f209898efed --- /dev/null +++ b/Modules/CIPPCore/Public/Set-CIPPDBCacheDetectedApps.ps1 @@ -0,0 +1,75 @@ +function Set-CIPPDBCacheDetectedApps { + <# + .SYNOPSIS + Caches all detected apps for a tenant, including devices that have each app + + .PARAMETER TenantFilter + The tenant to cache detected apps for + + .PARAMETER QueueId + The queue ID to update with total tasks (optional) + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter, + [string]$QueueId + ) + + try { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching detected apps' -sev Debug + + # Fetch all detected apps for the tenant + $DetectedApps = New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/deviceManagement/detectedApps' -tenantid $TenantFilter + if (!$DetectedApps) { $DetectedApps = @() } + + if (($DetectedApps | Measure-Object).Count -eq 0) { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'No detected apps found' -sev Debug + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'DetectedApps' -Data @() + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'DetectedApps' -Data @() -Count + return + } + + # Build bulk request for devices that have each detected app + $DeviceRequests = $DetectedApps | ForEach-Object { + if ($_.id) { + [PSCustomObject]@{ + id = $_.id + method = 'GET' + url = "deviceManagement/detectedApps('$($_.id)')/managedDevices" + } + } + } + + if ($DeviceRequests) { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Fetching devices for $($DetectedApps.Count) detected apps" -sev Debug + $DeviceResults = New-GraphBulkRequest -Requests @($DeviceRequests) -tenantid $TenantFilter + + # Add devices to each detected app object + $DetectedAppsWithDevices = foreach ($App in $DetectedApps) { + $Devices = Get-GraphBulkResultByID -Results $DeviceResults -ID $App.id -Value + if ($Devices) { + $App | Add-Member -NotePropertyName 'managedDevices' -NotePropertyValue $Devices -Force + } else { + $App | Add-Member -NotePropertyName 'managedDevices' -NotePropertyValue @() -Force + } + $App + } + + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'DetectedApps' -Data $DetectedAppsWithDevices + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'DetectedApps' -Data $DetectedAppsWithDevices -Count + $DetectedApps = $null + $DetectedAppsWithDevices = $null + } else { + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'DetectedApps' -Data $DetectedApps + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'DetectedApps' -Data $DetectedApps -Count + $DetectedApps = $null + } + + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached detected apps with devices successfully' -sev Debug + + } catch { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter ` + -message "Failed to cache detected apps: $($_.Exception.Message)" -sev Error + } +} From 70a2ebafea8664df169ee870b9591b3d2581a33d Mon Sep 17 00:00:00 2001 From: rvdwegen Date: Sun, 15 Feb 2026 22:27:19 +0100 Subject: [PATCH 31/97] add db cache types --- CIPPDBCacheTypes.json | 332 ++++++++++++++++++ .../Endpoint/MEM/Invoke-ListDefenderState.ps1 | 21 +- 2 files changed, 349 insertions(+), 4 deletions(-) create mode 100644 CIPPDBCacheTypes.json diff --git a/CIPPDBCacheTypes.json b/CIPPDBCacheTypes.json new file mode 100644 index 000000000000..28fac3a6fde8 --- /dev/null +++ b/CIPPDBCacheTypes.json @@ -0,0 +1,332 @@ +[ + { + "type": "Users", + "friendlyName": "Users", + "description": "All Azure AD users with sign-in activity" + }, + { + "type": "Groups", + "friendlyName": "Groups", + "description": "All Azure AD groups with members" + }, + { + "type": "Guests", + "friendlyName": "Guest Users", + "description": "All guest users in the tenant" + }, + { + "type": "ServicePrincipals", + "friendlyName": "Service Principals", + "description": "All service principals (applications)" + }, + { + "type": "Apps", + "friendlyName": "Application Registrations", + "description": "All application registrations with owners" + }, + { + "type": "Devices", + "friendlyName": "Azure AD Devices", + "description": "All Azure AD registered devices" + }, + { + "type": "Organization", + "friendlyName": "Organization", + "description": "Tenant organization information" + }, + { + "type": "Roles", + "friendlyName": "Directory Roles", + "description": "All Azure AD directory roles with members" + }, + { + "type": "AdminConsentRequestPolicy", + "friendlyName": "Admin Consent Request Policy", + "description": "Admin consent request policy settings" + }, + { + "type": "AuthorizationPolicy", + "friendlyName": "Authorization Policy", + "description": "Tenant authorization policy" + }, + { + "type": "AuthenticationMethodsPolicy", + "friendlyName": "Authentication Methods Policy", + "description": "Authentication methods policy configuration" + }, + { + "type": "DeviceSettings", + "friendlyName": "Device Settings", + "description": "Device management settings" + }, + { + "type": "DirectoryRecommendations", + "friendlyName": "Directory Recommendations", + "description": "Azure AD directory recommendations" + }, + { + "type": "CrossTenantAccessPolicy", + "friendlyName": "Cross-Tenant Access Policy", + "description": "Cross-tenant access policy configuration" + }, + { + "type": "DefaultAppManagementPolicy", + "friendlyName": "Default App Management Policy", + "description": "Default application management policy" + }, + { + "type": "Settings", + "friendlyName": "Directory Settings", + "description": "Directory settings configuration" + }, + { + "type": "SecureScore", + "friendlyName": "Secure Score", + "description": "Microsoft Secure Score and control profiles" + }, + { + "type": "PIMSettings", + "friendlyName": "PIM Settings", + "description": "Privileged Identity Management settings and assignments" + }, + { + "type": "Domains", + "friendlyName": "Domains", + "description": "All verified and unverified domains" + }, + { + "type": "RoleEligibilitySchedules", + "friendlyName": "Role Eligibility Schedules", + "description": "PIM role eligibility schedules" + }, + { + "type": "RoleManagementPolicies", + "friendlyName": "Role Management Policies", + "description": "Role management policies" + }, + { + "type": "RoleAssignmentScheduleInstances", + "friendlyName": "Role Assignment Schedule Instances", + "description": "Active role assignment instances" + }, + { + "type": "B2BManagementPolicy", + "friendlyName": "B2B Management Policy", + "description": "B2B collaboration policy settings" + }, + { + "type": "AuthenticationFlowsPolicy", + "friendlyName": "Authentication Flows Policy", + "description": "Authentication flows policy configuration" + }, + { + "type": "DeviceRegistrationPolicy", + "friendlyName": "Device Registration Policy", + "description": "Device registration policy settings" + }, + { + "type": "CredentialUserRegistrationDetails", + "friendlyName": "Credential User Registration Details", + "description": "User credential registration details" + }, + { + "type": "UserRegistrationDetails", + "friendlyName": "User Registration Details", + "description": "MFA registration details for users" + }, + { + "type": "OAuth2PermissionGrants", + "friendlyName": "OAuth2 Permission Grants", + "description": "OAuth2 permission grants" + }, + { + "type": "AppRoleAssignments", + "friendlyName": "App Role Assignments", + "description": "Application role assignments" + }, + { + "type": "LicenseOverview", + "friendlyName": "License Overview", + "description": "License usage overview" + }, + { + "type": "MFAState", + "friendlyName": "MFA State", + "description": "Multi-factor authentication state" + }, + { + "type": "ExoAntiPhishPolicies", + "friendlyName": "Exchange Anti-Phish Policies", + "description": "Exchange Online anti-phishing policies" + }, + { + "type": "ExoMalwareFilterPolicies", + "friendlyName": "Exchange Malware Filter Policies", + "description": "Exchange Online malware filter policies" + }, + { + "type": "ExoSafeLinksPolicies", + "friendlyName": "Exchange Safe Links Policies", + "description": "Exchange Online Safe Links policies" + }, + { + "type": "ExoSafeAttachmentPolicies", + "friendlyName": "Exchange Safe Attachment Policies", + "description": "Exchange Online Safe Attachment policies" + }, + { + "type": "ExoTransportRules", + "friendlyName": "Exchange Transport Rules", + "description": "Exchange Online transport rules" + }, + { + "type": "ExoDkimSigningConfig", + "friendlyName": "Exchange DKIM Signing Config", + "description": "Exchange Online DKIM signing configuration" + }, + { + "type": "ExoOrganizationConfig", + "friendlyName": "Exchange Organization Config", + "description": "Exchange Online organization configuration" + }, + { + "type": "ExoAcceptedDomains", + "friendlyName": "Exchange Accepted Domains", + "description": "Exchange Online accepted domains" + }, + { + "type": "ExoHostedContentFilterPolicy", + "friendlyName": "Exchange Hosted Content Filter Policy", + "description": "Exchange Online hosted content filter policy" + }, + { + "type": "ExoHostedOutboundSpamFilterPolicy", + "friendlyName": "Exchange Hosted Outbound Spam Filter Policy", + "description": "Exchange Online hosted outbound spam filter policy" + }, + { + "type": "ExoAntiPhishPolicy", + "friendlyName": "Exchange Anti-Phish Policy", + "description": "Exchange Online anti-phishing policy" + }, + { + "type": "ExoSafeLinksPolicy", + "friendlyName": "Exchange Safe Links Policy", + "description": "Exchange Online Safe Links policy" + }, + { + "type": "ExoSafeAttachmentPolicy", + "friendlyName": "Exchange Safe Attachment Policy", + "description": "Exchange Online Safe Attachment policy" + }, + { + "type": "ExoMalwareFilterPolicy", + "friendlyName": "Exchange Malware Filter Policy", + "description": "Exchange Online malware filter policy" + }, + { + "type": "ExoAtpPolicyForO365", + "friendlyName": "Exchange ATP Policy for O365", + "description": "Exchange Online Advanced Threat Protection policy" + }, + { + "type": "ExoQuarantinePolicy", + "friendlyName": "Exchange Quarantine Policy", + "description": "Exchange Online quarantine policy" + }, + { + "type": "ExoRemoteDomain", + "friendlyName": "Exchange Remote Domain", + "description": "Exchange Online remote domain configuration" + }, + { + "type": "ExoSharingPolicy", + "friendlyName": "Exchange Sharing Policy", + "description": "Exchange Online sharing policies" + }, + { + "type": "ExoAdminAuditLogConfig", + "friendlyName": "Exchange Admin Audit Log Config", + "description": "Exchange Online admin audit log configuration" + }, + { + "type": "ExoPresetSecurityPolicy", + "friendlyName": "Exchange Preset Security Policy", + "description": "Exchange Online preset security policy" + }, + { + "type": "ExoTenantAllowBlockList", + "friendlyName": "Exchange Tenant Allow/Block List", + "description": "Exchange Online tenant allow/block list" + }, + { + "type": "Mailboxes", + "friendlyName": "Mailboxes", + "description": "All Exchange Online mailboxes" + }, + { + "type": "CASMailboxes", + "friendlyName": "CAS Mailboxes", + "description": "Client Access Server mailbox settings" + }, + { + "type": "MailboxUsage", + "friendlyName": "Mailbox Usage", + "description": "Exchange Online mailbox usage statistics" + }, + { + "type": "OneDriveUsage", + "friendlyName": "OneDrive Usage", + "description": "OneDrive usage statistics" + }, + { + "type": "ConditionalAccessPolicies", + "friendlyName": "Conditional Access Policies", + "description": "Azure AD Conditional Access policies" + }, + { + "type": "RiskyUsers", + "friendlyName": "Risky Users", + "description": "Users flagged as risky by Identity Protection" + }, + { + "type": "RiskyServicePrincipals", + "friendlyName": "Risky Service Principals", + "description": "Service principals flagged as risky by Identity Protection" + }, + { + "type": "ServicePrincipalRiskDetections", + "friendlyName": "Service Principal Risk Detections", + "description": "Risk detections for service principals" + }, + { + "type": "RiskDetections", + "friendlyName": "Risk Detections", + "description": "Identity Protection risk detections" + }, + { + "type": "ManagedDevices", + "friendlyName": "Managed Devices", + "description": "Intune managed devices" + }, + { + "type": "IntunePolicies", + "friendlyName": "Intune Policies", + "description": "All Intune policies including compliance, configuration, and app protection" + }, + { + "type": "ManagedDeviceEncryptionStates", + "friendlyName": "Managed Device Encryption States", + "description": "BitLocker encryption states for managed devices" + }, + { + "type": "IntuneAppProtectionPolicies", + "friendlyName": "Intune App Protection Policies", + "description": "Intune app protection policies for iOS and Android" + }, + { + "type": "DetectedApps", + "friendlyName": "Detected Apps", + "description": "All detected applications with devices where each app is installed" + } +] diff --git a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ListDefenderState.ps1 b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ListDefenderState.ps1 index e03874e4bac6..4ca3690502b5 100644 --- a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ListDefenderState.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ListDefenderState.ps1 @@ -9,16 +9,29 @@ Function Invoke-ListDefenderState { param($Request, $TriggerMetadata) $StatusCode = [HttpStatusCode]::OK - - # Interact with query parameters or the body of the request. $TenantFilter = $Request.Query.TenantFilter + $DeviceID = $Request.Query.DeviceID + try { - $GraphRequest = New-GraphGetRequest -tenantid $TenantFilter -uri "https://graph.microsoft.com/beta/deviceManagement/managedDevices?`$expand=windowsProtectionState&`$select=id,deviceName,deviceType,operatingSystem,windowsProtectionState" + # If DeviceID is provided, get Defender state for that specific device + if ($DeviceID) { + $GraphRequest = New-GraphGetRequest -tenantid $TenantFilter -uri "https://graph.microsoft.com/beta/deviceManagement/managedDevices/$($DeviceID)?`$expand=windowsProtectionState&`$select=id,deviceName,deviceType,operatingSystem,windowsProtectionState" + } + # If no DeviceID is provided, get Defender state for all devices + else { + $GraphRequest = New-GraphGetRequest -tenantid $TenantFilter -uri "https://graph.microsoft.com/beta/deviceManagement/managedDevices?`$expand=windowsProtectionState&`$select=id,deviceName,deviceType,operatingSystem,windowsProtectionState" + } + + # Ensure we return an array even if single device + if ($GraphRequest -and -not ($GraphRequest -is [array])) { + $GraphRequest = @($GraphRequest) + } + $StatusCode = [HttpStatusCode]::OK } catch { $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message - $StatusCode = [HttpStatusCode]::Forbidden + $StatusCode = [HttpStatusCode]::OK $GraphRequest = "$($ErrorMessage)" } return ([HttpResponseContext]@{ From 12ec2d57c0d1114586e3abe41bfc1be8709a8892 Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Mon, 16 Feb 2026 10:12:33 +0100 Subject: [PATCH 32/97] Add retries for CA policies. --- .../Public/GraphHelper/New-GraphPOSTRequest.ps1 | 2 +- Modules/CIPPCore/Public/New-CIPPCAPolicy.ps1 | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Modules/CIPPCore/Public/GraphHelper/New-GraphPOSTRequest.ps1 b/Modules/CIPPCore/Public/GraphHelper/New-GraphPOSTRequest.ps1 index 2bfdd9028f56..5d4d1fd3f950 100644 --- a/Modules/CIPPCore/Public/GraphHelper/New-GraphPOSTRequest.ps1 +++ b/Modules/CIPPCore/Public/GraphHelper/New-GraphPOSTRequest.ps1 @@ -96,7 +96,7 @@ function New-GraphPOSTRequest { # Add the scheduled task (hidden = system task) $null = Add-CIPPScheduledTask -Task $TaskObject -Hidden $true - return @{Result = "Scheduled job with id $TaskId as Graph API was too busy to respond" } + return @{Result = "Scheduled job with id $TaskId as Graph API was too busy to respond. Check the job status in the scheduler." } } catch { Write-Warning "Failed to schedule retry task: $($_.Exception.Message)" } diff --git a/Modules/CIPPCore/Public/New-CIPPCAPolicy.ps1 b/Modules/CIPPCore/Public/New-CIPPCAPolicy.ps1 index 4f61f708abd7..1409a54e962e 100644 --- a/Modules/CIPPCore/Public/New-CIPPCAPolicy.ps1 +++ b/Modules/CIPPCore/Public/New-CIPPCAPolicy.ps1 @@ -133,7 +133,7 @@ function New-CIPPCAPolicy { } else { $Body = ConvertTo-Json -InputObject $JSONobj.GrantControls.authenticationStrength - $GraphRequest = New-GraphPOSTRequest -uri 'https://graph.microsoft.com/beta/identity/conditionalAccess/authenticationStrength/policies' -body $body -Type POST -tenantid $TenantFilter -asApp $true + $GraphRequest = New-GraphPOSTRequest -uri 'https://graph.microsoft.com/beta/identity/conditionalAccess/authenticationStrength/policies' -body $body -Type POST -tenantid $TenantFilter -asApp $true -ScheduleRetry $true $JSONobj.GrantControls.authenticationStrength = @{ id = $ExistingStrength.id } Write-LogMessage -Headers $Headers -API $APIName -message "Created new Authentication Strength Policy: $($JSONobj.GrantControls.authenticationStrength.displayName)" -Sev 'Info' } @@ -178,7 +178,7 @@ function New-CIPPCAPolicy { Remove-ODataProperties -Object $LocationUpdate $Body = ConvertTo-Json -InputObject $LocationUpdate -Depth 10 try { - $null = New-GraphPOSTRequest -uri "https://graph.microsoft.com/beta/identity/conditionalAccess/namedLocations/$($ExistingLocation.id)" -body $body -Type PATCH -tenantid $TenantFilter -asApp $true + $null = New-GraphPOSTRequest -uri "https://graph.microsoft.com/beta/identity/conditionalAccess/namedLocations/$($ExistingLocation.id)" -body $body -Type PATCH -tenantid $TenantFilter -asApp $true -ScheduleRetry $true Write-LogMessage -Tenant $TenantFilter -Headers $Headers -API $APIName -message "Updated existing Named Location: $($location.displayName)" -Sev 'Info' } catch { Write-Warning "Failed to update location $($location.displayName): $_" @@ -347,7 +347,7 @@ function New-CIPPCAPolicy { # Preserve any exclusion groups named "Vacation Exclusion - " from existing policy try { $ExistingVacationGroup = New-GraphGETRequest -uri "https://graph.microsoft.com/beta/groups?`$filter=startsWith(displayName,'Vacation Exclusion')&`$select=id,displayName&`$top=999&`$count=true" -ComplexFilter -tenantid $TenantFilter -asApp $true | - Where-Object { $CheckExisting.conditions.users.excludeGroups -contains $_.id } + Where-Object { $CheckExisting.conditions.users.excludeGroups -contains $_.id } if ($ExistingVacationGroup) { if (-not ($JSONobj.conditions.users.PSObject.Properties.Name -contains 'excludeGroups')) { $JSONobj.conditions.users | Add-Member -NotePropertyName 'excludeGroups' -NotePropertyValue @() -Force @@ -369,7 +369,7 @@ function New-CIPPCAPolicy { Write-Information "Failed to preserve vacation exclusion group: $($_.Exception.Message)" } Write-Information "overwriting $($CheckExisting.id)" - $null = New-GraphPOSTRequest -uri "https://graph.microsoft.com/beta/identity/conditionalAccess/policies/$($CheckExisting.id)" -tenantid $TenantFilter -type PATCH -body $RawJSON -asApp $true + $null = New-GraphPOSTRequest -uri "https://graph.microsoft.com/beta/identity/conditionalAccess/policies/$($CheckExisting.id)" -tenantid $TenantFilter -type PATCH -body $RawJSON -asApp $true -ScheduleRetry $true Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter -message "Updated Conditional Access Policy $($JSONobj.displayName) to the template standard." -Sev 'Info' return "Updated policy $($JSONobj.displayName) for $TenantFilter" } @@ -378,7 +378,7 @@ function New-CIPPCAPolicy { if ($JSOObj.GrantControls.authenticationStrength.policyType -or $JSONobj.$JSONobj.LocationInfo) { Start-Sleep 3 } - $null = New-GraphPOSTRequest -uri 'https://graph.microsoft.com/beta/identity/conditionalAccess/policies' -tenantid $TenantFilter -type POST -body $RawJSON -asApp $true + $null = New-GraphPOSTRequest -uri 'https://graph.microsoft.com/beta/identity/conditionalAccess/policies' -tenantid $TenantFilter -type POST -body $RawJSON -asApp $true -ScheduleRetry $true Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter -message "Added Conditional Access Policy $($JSONobj.displayName)" -Sev 'Info' return "Created policy $($JSONobj.displayName) for $TenantFilter" } From 1767fa46d32380347e003f35e82f30dd5cb70cf3 Mon Sep 17 00:00:00 2001 From: rvdwegen Date: Mon, 16 Feb 2026 10:55:37 +0100 Subject: [PATCH 33/97] add groups support for universal search --- .../Invoke-ExecUniversalSearchV2.ps1 | 14 ++++++- Modules/CIPPCore/Public/Search-CIPPDbData.ps1 | 40 ++++++++++++------- 2 files changed, 37 insertions(+), 17 deletions(-) diff --git a/Modules/CIPPCore/Public/Entrypoints/Invoke-ExecUniversalSearchV2.ps1 b/Modules/CIPPCore/Public/Entrypoints/Invoke-ExecUniversalSearchV2.ps1 index f110b9a4cab6..df21429f9605 100644 --- a/Modules/CIPPCore/Public/Entrypoints/Invoke-ExecUniversalSearchV2.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/Invoke-ExecUniversalSearchV2.ps1 @@ -10,10 +10,20 @@ function Invoke-ExecUniversalSearchV2 { $SearchTerms = $Request.Query.searchTerms $Limit = if ($Request.Query.limit) { [int]$Request.Query.limit } else { 10 } + $Type = if ($Request.Query.type) { $Request.Query.type } else { 'Users' } # Always search all tenants - do not pass TenantFilter parameter - $Results = Search-CIPPDbData -SearchTerms $SearchTerms -Types 'Users' -Limit $Limit -UserProperties 'id', 'userPrincipalName', 'displayName' - + switch ($Type) { + 'Users' { + $Results = Search-CIPPDbData -SearchTerms $SearchTerms -Types 'Users' -Limit $Limit -Properties 'id', 'userPrincipalName', 'displayName' + } + 'Groups' { + $Results = Search-CIPPDbData -SearchTerms $SearchTerms -Types 'Groups' -Limit $Limit -Properties 'id', 'displayName', 'mail', 'mailEnabled', 'securityEnabled', 'groupTypes', 'description' + } + default { + $Results = Search-CIPPDbData -SearchTerms $SearchTerms -Types 'Users' -Limit $Limit -Properties 'id', 'userPrincipalName', 'displayName' + } + } Write-Information "Results: $($Results | ConvertTo-Json -Depth 10)" diff --git a/Modules/CIPPCore/Public/Search-CIPPDbData.ps1 b/Modules/CIPPCore/Public/Search-CIPPDbData.ps1 index b5aca603896f..1f60a59a4f2c 100644 --- a/Modules/CIPPCore/Public/Search-CIPPDbData.ps1 +++ b/Modules/CIPPCore/Public/Search-CIPPDbData.ps1 @@ -30,14 +30,12 @@ function Search-CIPPDbData { .PARAMETER Limit Maximum total number of results to return across all types. Default is unlimited (0) + .PARAMETER Properties + Array of property names to return for the searched types. If not specified, all properties are returned. + Applies to all types in the Types parameter. Only properties that exist in the data will be included. + .PARAMETER UserProperties - Array of property names to return for Users type. If not specified, all properties are returned. - Only applies when Types includes 'Users'. Valid properties include: id, accountEnabled, businessPhones, - city, createdDateTime, companyName, country, department, displayName, faxNumber, givenName, - isResourceAccount, jobTitle, mail, mailNickname, mobilePhone, onPremisesDistinguishedName, - officeLocation, onPremisesLastSyncDateTime, otherMails, postalCode, preferredDataLocation, - preferredLanguage, proxyAddresses, showInAddressList, state, streetAddress, surname, - usageLocation, userPrincipalName, userType, assignedLicenses, onPremisesSyncEnabled, signInActivity + [DEPRECATED] Use Properties parameter instead. Array of property names to return for Users type. .EXAMPLE Search-CIPPDbData -TenantFilter 'contoso.onmicrosoft.com' -SearchTerms 'john.doe' -Types 'Users', 'Groups' @@ -83,6 +81,9 @@ function Search-CIPPDbData { [Parameter(Mandatory = $false)] [int]$Limit = 0, + [Parameter(Mandatory = $false)] + [string[]]$Properties, + [Parameter(Mandatory = $false)] [string[]]$UserProperties ) @@ -159,9 +160,18 @@ function Search-CIPPDbData { try { $Data = $Item.Data | ConvertFrom-Json - # For Users type with UserProperties, verify match is in target properties + # Determine which properties to use (Properties parameter takes precedence, fallback to UserProperties for backward compatibility) + $PropertiesToUse = if ($Properties -and $Properties.Count -gt 0) { + $Properties + } elseif ($Type -eq 'Users' -and $UserProperties -and $UserProperties.Count -gt 0) { + $UserProperties + } else { + $null + } + + # If properties are specified, verify match is in target properties $IsVerifiedMatch = $true - if ($Type -eq 'Users' -and $UserProperties -and $UserProperties.Count -gt 0) { + if ($PropertiesToUse -and $PropertiesToUse.Count -gt 0) { $IsVerifiedMatch = $false if ($MatchAll) { @@ -170,7 +180,7 @@ function Search-CIPPDbData { foreach ($SearchTerm in $SearchTerms) { $SearchPattern = [regex]::Escape($SearchTerm) $TermMatches = $false - foreach ($Property in $UserProperties) { + foreach ($Property in $PropertiesToUse) { if ($Data.PSObject.Properties.Name -contains $Property -and $null -ne $Data.$Property -and $Data.$Property.ToString() -match $SearchPattern) { @@ -187,7 +197,7 @@ function Search-CIPPDbData { # Any search term can match in target properties foreach ($SearchTerm in $SearchTerms) { $SearchPattern = [regex]::Escape($SearchTerm) - foreach ($Property in $UserProperties) { + foreach ($Property in $PropertiesToUse) { if ($Data.PSObject.Properties.Name -contains $Property -and $null -ne $Data.$Property -and $Data.$Property.ToString() -match $SearchPattern) { @@ -200,12 +210,12 @@ function Search-CIPPDbData { } } - # Only add to results if verified (or not Users/UserProperties) + # Only add to results if verified (or no property filtering) if ($IsVerifiedMatch) { - # Filter user properties if specified and type is Users - if ($Type -eq 'Users' -and $UserProperties -and $UserProperties.Count -gt 0) { + # Filter properties if specified + if ($PropertiesToUse -and $PropertiesToUse.Count -gt 0) { $FilteredData = [PSCustomObject]@{} - foreach ($Property in $UserProperties) { + foreach ($Property in $PropertiesToUse) { if ($Data.PSObject.Properties.Name -contains $Property) { $FilteredData | Add-Member -MemberType NoteProperty -Name $Property -Value $Data.$Property -Force } From eab2261f6a89a1bc58ca9d6f386985d450fcc1d6 Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Mon, 16 Feb 2026 11:19:04 +0100 Subject: [PATCH 34/97] add top --- Modules/CIPPCore/Public/New-CIPPCAPolicy.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Modules/CIPPCore/Public/New-CIPPCAPolicy.ps1 b/Modules/CIPPCore/Public/New-CIPPCAPolicy.ps1 index 1409a54e962e..635949848451 100644 --- a/Modules/CIPPCore/Public/New-CIPPCAPolicy.ps1 +++ b/Modules/CIPPCore/Public/New-CIPPCAPolicy.ps1 @@ -141,7 +141,7 @@ function New-CIPPCAPolicy { #if we have excluded or included applications, we need to remove any appIds that do not have a service principal in the tenant if (($JSONobj.conditions.applications.includeApplications -and $JSONobj.conditions.applications.includeApplications -notcontains 'All') -or ($JSONobj.conditions.applications.excludeApplications -and $JSONobj.conditions.applications.excludeApplications -notcontains 'All')) { - $AllServicePrincipals = New-GraphGETRequest -uri 'https://graph.microsoft.com/v1.0/servicePrincipals?$select=appId' -tenantid $TenantFilter -asApp $true + $AllServicePrincipals = New-GraphGETRequest -uri 'https://graph.microsoft.com/v1.0/servicePrincipals?$select=appId&$top=999' -tenantid $TenantFilter -asApp $true $ReservedApplicationNames = @('none', 'All', 'Office365', 'MicrosoftAdminPortals') From 3d43ac19786e6466329df8379f510fc00d5624b5 Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Mon, 16 Feb 2026 11:23:56 +0100 Subject: [PATCH 35/97] add too many requests for GET logic. --- Modules/CIPPCore/Public/GraphHelper/New-GraphGetRequest.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Modules/CIPPCore/Public/GraphHelper/New-GraphGetRequest.ps1 b/Modules/CIPPCore/Public/GraphHelper/New-GraphGetRequest.ps1 index ec43778f3748..f5dc35876afc 100644 --- a/Modules/CIPPCore/Public/GraphHelper/New-GraphGetRequest.ps1 +++ b/Modules/CIPPCore/Public/GraphHelper/New-GraphGetRequest.ps1 @@ -163,7 +163,7 @@ function New-GraphGetRequest { } } # Check for "Resource temporarily unavailable" - elseif ($Message -like '*Resource temporarily unavailable*') { + elseif ($Message -like '*Resource temporarily unavailable*' -or $Message -like '*Too many requests*') { if ($RetryCount -lt $MaxRetries) { $WaitTime = Get-Random -Minimum 1.1 -Maximum 3.1 # Random sleep between 1-2 seconds Write-Warning "Resource temporarily unavailable. Waiting $WaitTime seconds before retry. Attempt $($RetryCount + 1) of $MaxRetries" From e49d430decde5a9ea83b9c63955b9db2ca9b79bd Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Mon, 16 Feb 2026 11:29:14 +0100 Subject: [PATCH 36/97] remove old measure tasks --- .../Activity Triggers/Standards/Push-CIPPStandard.ps1 | 4 +--- .../Timer Functions/Start-CIPPProcessorQueue.ps1 | 10 +++++----- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/Modules/CIPPCore/Public/Entrypoints/Activity Triggers/Standards/Push-CIPPStandard.ps1 b/Modules/CIPPCore/Public/Entrypoints/Activity Triggers/Standards/Push-CIPPStandard.ps1 index e3944c21a4d2..0b70aa838235 100644 --- a/Modules/CIPPCore/Public/Entrypoints/Activity Triggers/Standards/Push-CIPPStandard.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/Activity Triggers/Standards/Push-CIPPStandard.ps1 @@ -95,9 +95,7 @@ function Push-CIPPStandard { $metadata['CATemplateId'] = $Item.Settings.TemplateList.value } - Measure-CippTask -TaskName $Standard -EventName 'CIPP.StandardCompleted' -Metadata $metadata -Script { - & $FunctionName -Tenant $Item.Tenant -Settings $Settings -ErrorAction Stop - } + & $FunctionName -Tenant $Item.Tenant -Settings $Settings -ErrorAction Stop $result = 'Success' Write-Information "Standard $($Standard) completed for tenant $($Tenant)" diff --git a/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-CIPPProcessorQueue.ps1 b/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-CIPPProcessorQueue.ps1 index 4e34dc09f102..cbc065c4f15c 100644 --- a/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-CIPPProcessorQueue.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-CIPPProcessorQueue.ps1 @@ -30,7 +30,7 @@ function Start-CIPPProcessorQueue { TriggerType = 'ProcessorQueue' QueueRowKey = $QueueItem.RowKey } - + # Add parameters info if available if ($Parameters.Count -gt 0) { $metadata['ParameterCount'] = $Parameters.Count @@ -42,11 +42,11 @@ function Start-CIPPProcessorQueue { $metadata['Tenant'] = $Parameters.TenantFilter } } - + # Wrap function execution with telemetry - Measure-CippTask -TaskName $FunctionName -Metadata $metadata -Script { - Invoke-Command -ScriptBlock { & $FunctionName @Parameters } - } + + Invoke-Command -ScriptBlock { & $FunctionName @Parameters } + } catch { Write-Warning "Failed to run function $($FunctionName). Error: $($_.Exception.Message)" } From ab34d8defa24aee2b2deee70e027179e5f258bf1 Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Mon, 16 Feb 2026 11:59:20 +0100 Subject: [PATCH 37/97] fixes to CA for timeouts and better handling of standards --- Modules/CIPPCore/Public/New-CIPPCAPolicy.ps1 | 146 ++++++++++++++---- ...-CIPPStandardConditionalAccessTemplate.ps1 | 19 +-- 2 files changed, 129 insertions(+), 36 deletions(-) diff --git a/Modules/CIPPCore/Public/New-CIPPCAPolicy.ps1 b/Modules/CIPPCore/Public/New-CIPPCAPolicy.ps1 index 635949848451..6589daade37f 100644 --- a/Modules/CIPPCore/Public/New-CIPPCAPolicy.ps1 +++ b/Modules/CIPPCore/Public/New-CIPPCAPolicy.ps1 @@ -10,7 +10,8 @@ function New-CIPPCAPolicy { $DisableSD = $false, $CreateGroups = $false, $APIName = 'Create CA Policy', - $Headers + $Headers, + $PreloadedCAPolicies = $null ) function Remove-EmptyArrays ($Object) { @@ -122,12 +123,77 @@ function New-CIPPCAPolicy { $JSONobj.state = $State } } catch { - # no issues here. + $ErrorMessage = Get-CippException -Exception $_ + Write-Information "Error cleaning JSON properties: $($ErrorMessage | ConvertTo-Json -Depth 10 -Compress)" + } + + # Execute all required GET requests ONCE at the beginning to avoid rate limiting + Write-Information 'Fetching required resources from Graph API...' + + # Get existing CA policies once (or use preloaded ones) + if ($PreloadedCAPolicies) { + Write-Information 'Using preloaded CA policies' + $AllExistingPolicies = $PreloadedCAPolicies + Write-Information "Found $($AllExistingPolicies.Count) preloaded CA policies" + } else { + try { + Write-Information 'Fetching existing CA policies...' + $AllExistingPolicies = New-GraphGETRequest -uri 'https://graph.microsoft.com/beta/identity/conditionalAccess/policies?$top=999' -tenantid $TenantFilter -asApp $true + Write-Information "Found $($AllExistingPolicies.Count) existing CA policies" + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-Information "Error fetching existing policies: $($ErrorMessage | ConvertTo-Json -Depth 10 -Compress)" + throw "Failed to fetch existing CA policies: $($ErrorMessage.NormalizedError)" + } + } + + # Get named locations once if needed + $AllNamedLocations = $null + if ($JSONobj.LocationInfo) { + try { + Write-Information 'Fetching all named locations...' + $AllNamedLocations = New-GraphGETRequest -uri 'https://graph.microsoft.com/beta/identity/conditionalAccess/namedLocations?$top=999' -tenantid $TenantFilter -asApp $true + Write-Information "Found $($AllNamedLocations.Count) existing named locations" + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-Information "Error fetching named locations: $($ErrorMessage | ConvertTo-Json -Depth 10 -Compress)" + throw "Failed to fetch named locations: $($ErrorMessage.NormalizedError)" + } + } + + # Get authentication strength policies once if needed + $AllAuthStrengthPolicies = $null + if ($JSONobj.GrantControls.authenticationStrength.policyType -eq 'custom' -or $JSONobj.GrantControls.authenticationStrength.policyType -eq 'BuiltIn') { + try { + Write-Information 'Fetching authentication strength policies...' + $AllAuthStrengthPolicies = New-GraphGETRequest -uri 'https://graph.microsoft.com/beta/identity/conditionalAccess/authenticationStrength/policies/' -tenantid $TenantFilter -asApp $true + Write-Information "Found $($AllAuthStrengthPolicies.Count) authentication strength policies" + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-Information "Error fetching authentication strength policies: $($ErrorMessage | ConvertTo-Json -Depth 10 -Compress)" + throw "Failed to fetch authentication strength policies: $($ErrorMessage.NormalizedError)" + } + } + + # Get service principals once if needed + $AllServicePrincipals = $null + if (($JSONobj.conditions.applications.includeApplications -and $JSONobj.conditions.applications.includeApplications -notcontains 'All') -or ($JSONobj.conditions.applications.excludeApplications -and $JSONobj.conditions.applications.excludeApplications -notcontains 'All')) { + try { + Write-Information 'Fetching all service principals...' + $AllServicePrincipals = New-GraphGETRequest -uri 'https://graph.microsoft.com/v1.0/servicePrincipals?$select=appId&$top=999' -tenantid $TenantFilter -asApp $true + Write-Information "Found $($AllServicePrincipals.Count) service principals" + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-Information "Error fetching service principals: $($ErrorMessage | ConvertTo-Json -Depth 10 -Compress)" + throw "Failed to fetch service principals: $($ErrorMessage.NormalizedError)" + } } + Write-Information 'All required resources fetched successfully' + #If Grant Controls contains authenticationStrength, create these and then replace the id if ($JSONobj.GrantControls.authenticationStrength.policyType -eq 'custom' -or $JSONobj.GrantControls.authenticationStrength.policyType -eq 'BuiltIn') { - $ExistingStrength = New-GraphGETRequest -uri 'https://graph.microsoft.com/beta/identity/conditionalAccess/authenticationStrength/policies/' -tenantid $TenantFilter -asApp $true | Where-Object -Property displayName -EQ $JSONobj.GrantControls.authenticationStrength.displayName + $ExistingStrength = $AllAuthStrengthPolicies | Where-Object -Property displayName -EQ $JSONobj.GrantControls.authenticationStrength.displayName if ($ExistingStrength) { $JSONobj.GrantControls.authenticationStrength = @{ id = $ExistingStrength.id } @@ -140,9 +206,7 @@ function New-CIPPCAPolicy { } #if we have excluded or included applications, we need to remove any appIds that do not have a service principal in the tenant - if (($JSONobj.conditions.applications.includeApplications -and $JSONobj.conditions.applications.includeApplications -notcontains 'All') -or ($JSONobj.conditions.applications.excludeApplications -and $JSONobj.conditions.applications.excludeApplications -notcontains 'All')) { - $AllServicePrincipals = New-GraphGETRequest -uri 'https://graph.microsoft.com/v1.0/servicePrincipals?$select=appId&$top=999' -tenantid $TenantFilter -asApp $true - + if ($AllServicePrincipals) { $ReservedApplicationNames = @('none', 'All', 'Office365', 'MicrosoftAdminPortals') if ($JSONobj.conditions.applications.excludeApplications -and $JSONobj.conditions.applications.excludeApplications -notcontains 'All') { @@ -170,9 +234,9 @@ function New-CIPPCAPolicy { if (!$locations) { continue } foreach ($location in $locations) { if (!$location.displayName) { continue } - $CheckExisting = New-GraphGETRequest -uri 'https://graph.microsoft.com/beta/identity/conditionalAccess/namedLocations' -tenantid $TenantFilter -asApp $true - if ($Location.displayName -in $CheckExisting.displayName) { - $ExistingLocation = $CheckExisting | Where-Object -Property displayName -EQ $Location.displayName + # Use cached named locations instead of fetching each time + if ($Location.displayName -in $AllNamedLocations.displayName) { + $ExistingLocation = $AllNamedLocations | Where-Object -Property displayName -EQ $Location.displayName if ($Overwrite) { $LocationUpdate = $location | Select-Object * -ExcludeProperty id Remove-ODataProperties -Object $LocationUpdate @@ -181,8 +245,10 @@ function New-CIPPCAPolicy { $null = New-GraphPOSTRequest -uri "https://graph.microsoft.com/beta/identity/conditionalAccess/namedLocations/$($ExistingLocation.id)" -body $body -Type PATCH -tenantid $TenantFilter -asApp $true -ScheduleRetry $true Write-LogMessage -Tenant $TenantFilter -Headers $Headers -API $APIName -message "Updated existing Named Location: $($location.displayName)" -Sev 'Info' } catch { - Write-Warning "Failed to update location $($location.displayName): $_" - Write-LogMessage -Tenant $TenantFilter -Headers $Headers -API $APIName -message "Failed to update existing Named Location: $($location.displayName). Error: $_" -Sev 'Error' + $ErrorMessage = Get-CippException -Exception $_ + Write-Information "Error updating named location: $($ErrorMessage | ConvertTo-Json -Depth 10 -Compress)" + Write-Warning "Failed to update location $($location.displayName): $($ErrorMessage.NormalizedError)" + Write-LogMessage -Tenant $TenantFilter -Headers $Headers -API $APIName -message "Failed to update existing Named Location: $($location.displayName). Error: $($ErrorMessage.NormalizedError)" -Sev 'Error' -LogData $ErrorMessage } } else { Write-LogMessage -Tenant $TenantFilter -Headers $Headers -API $APIName -message "Matched a CA policy with the existing Named Location: $($location.displayName)" -Sev 'Info' @@ -197,17 +263,35 @@ function New-CIPPCAPolicy { $LocationBody = $location | Select-Object * -ExcludeProperty id Remove-ODataProperties -Object $LocationBody $Body = ConvertTo-Json -InputObject $LocationBody - $GraphRequest = New-GraphPOSTRequest -uri 'https://graph.microsoft.com/beta/identity/conditionalAccess/namedLocations' -body $body -Type POST -tenantid $TenantFilter -asApp $true - $retryCount = 0 - $MaxRetryCount = 10 - do { - Write-Host "Checking for location $($GraphRequest.id) attempt $retryCount. $TenantFilter" - $LocationRequest = New-GraphGETRequest -uri 'https://graph.microsoft.com/beta/identity/conditionalAccess/namedLocations' -tenantid $TenantFilter -asApp $true | Where-Object -Property id -EQ $GraphRequest.id - Write-Host "LocationRequest: $($LocationRequest.id)" - Start-Sleep -Seconds 2 - $retryCount++ - } while ((!$LocationRequest -or !$LocationRequest.id) -and ($retryCount -lt $MaxRetryCount)) - Write-LogMessage -Tenant $TenantFilter -Headers $Headers -API $APIName -message "Created new Named Location: $($location.displayName)" -Sev 'Info' + try { + $GraphRequest = New-GraphPOSTRequest -uri 'https://graph.microsoft.com/beta/identity/conditionalAccess/namedLocations' -body $body -Type POST -tenantid $TenantFilter -asApp $true + Write-Information "Created named location with ID: $($GraphRequest.id)" + # Wait for location to be available - reduced retry count and increased delay + $retryCount = 0 + $MaxRetryCount = 5 + $LocationRequest = $null + do { + Write-Information "Verifying location $($GraphRequest.id) exists, attempt $($retryCount + 1)/$MaxRetryCount" + Start-Sleep -Seconds 3 + try { + # Get specific location by ID instead of all locations + $LocationRequest = New-GraphGETRequest -uri "https://graph.microsoft.com/beta/identity/conditionalAccess/namedLocations/$($GraphRequest.id)" -tenantid $TenantFilter -asApp $true -ErrorAction Stop + Write-Information "Location verified: $($LocationRequest.id)" + } catch { + Write-Information 'Location not yet available, will retry...' + } + $retryCount++ + } while ((!$LocationRequest -or !$LocationRequest.id) -and ($retryCount -lt $MaxRetryCount)) + + if (!$LocationRequest -or !$LocationRequest.id) { + Write-Warning "Location created but could not verify availability after $MaxRetryCount attempts. Proceeding anyway." + } + Write-LogMessage -Tenant $TenantFilter -Headers $Headers -API $APIName -message "Created new Named Location: $($location.displayName)" -Sev 'Info' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-Information "Error creating named location: $($ErrorMessage | ConvertTo-Json -Depth 10 -Compress)" + throw "Failed to create named location $($location.displayName): $($ErrorMessage.NormalizedError)" + } [pscustomobject]@{ id = $GraphRequest.id name = $GraphRequest.displayName @@ -292,6 +376,7 @@ function New-CIPPCAPolicy { } } catch { $ErrorMessage = Get-CippException -Exception $_ + Write-Information "Error replacing displayNames: $($ErrorMessage | ConvertTo-Json -Depth 10 -Compress)" Write-LogMessage -API 'Standards' -tenant $TenantFilter -message "Failed to replace displayNames for conditional access rule $($JSONobj.displayName). Error: $($ErrorMessage.NormalizedError)" -sev 'Error' -LogData $ErrorMessage throw "Failed to replace displayNames for conditional access rule $($JSONobj.displayName): $($ErrorMessage.NormalizedError)" } @@ -327,6 +412,7 @@ function New-CIPPCAPolicy { Start-Sleep 3 } catch { $ErrorMessage = Get-CippException -Exception $_ + Write-Information "Error disabling security defaults: $($ErrorMessage | ConvertTo-Json -Depth 10 -Compress)" Write-Information "Failed to disable security defaults for tenant $($TenantFilter): $($ErrorMessage.NormalizedError)" } } @@ -334,7 +420,8 @@ function New-CIPPCAPolicy { Write-Information $RawJSON try { Write-Information 'Checking for existing policies' - $CheckExisting = New-GraphGETRequest -uri 'https://graph.microsoft.com/beta/identity/conditionalAccess/policies' -tenantid $TenantFilter -asApp $true | Where-Object -Property displayName -EQ $displayName + # Use cached policies instead of fetching again + $CheckExisting = $AllExistingPolicies | Where-Object -Property displayName -EQ $displayName if ($CheckExisting) { if ($Overwrite -ne $true) { throw "Conditional Access Policy with Display Name $($displayName) Already exists" @@ -366,7 +453,9 @@ function New-CIPPCAPolicy { $RawJSON = ConvertTo-Json -InputObject $JSONobj -Depth 10 -Compress } } catch { - Write-Information "Failed to preserve vacation exclusion group: $($_.Exception.Message)" + $ErrorMessage = Get-CippException -Exception $_ + Write-Information "Error preserving vacation exclusion group: $($ErrorMessage | ConvertTo-Json -Depth 10 -Compress)" + Write-Information "Failed to preserve vacation exclusion group: $($ErrorMessage.NormalizedError)" } Write-Information "overwriting $($CheckExisting.id)" $null = New-GraphPOSTRequest -uri "https://graph.microsoft.com/beta/identity/conditionalAccess/policies/$($CheckExisting.id)" -tenantid $TenantFilter -type PATCH -body $RawJSON -asApp $true -ScheduleRetry $true @@ -385,11 +474,14 @@ function New-CIPPCAPolicy { } catch { $ErrorMessage = Get-CippException -Exception $_ $Result = "Failed to create or update conditional access rule $($JSONobj.displayName): $($ErrorMessage.NormalizedError)" - Write-LogMessage -API $APIName -tenant $TenantFilter -message $Result -sev 'Error' -LogData $ErrorMessage + # Full error details for debugging + Write-Information "Full error details: $($ErrorMessage | ConvertTo-Json -Depth 10 -Compress)" + Write-Information "Position: $($_.InvocationInfo.PositionMessage)" + Write-Information "Policy JSON: $($JSONobj | ConvertTo-Json -Depth 10 -Compress)" + + Write-LogMessage -API $APIName -tenant $TenantFilter -message $Result -sev 'Error' -LogData $ErrorMessage Write-Warning $Result - Write-Information $_.InvocationInfo.PositionMessage - Write-Information ($JSONobj | ConvertTo-Json -Depth 10) throw $Result } } diff --git a/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardConditionalAccessTemplate.ps1 b/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardConditionalAccessTemplate.ps1 index 07da79a1fc70..a41018355017 100644 --- a/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardConditionalAccessTemplate.ps1 +++ b/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardConditionalAccessTemplate.ps1 @@ -67,15 +67,16 @@ function Invoke-CIPPStandardConditionalAccessTemplate { } } $NewCAPolicy = @{ - replacePattern = 'displayName' - TenantFilter = $Tenant - state = $Setting.state - RawJSON = $JSONObj - Overwrite = $true - APIName = 'Standards' - Headers = $Request.Headers - DisableSD = $Setting.DisableSD - CreateGroups = $Setting.CreateGroups ?? $false + replacePattern = 'displayName' + TenantFilter = $Tenant + state = $Setting.state + RawJSON = $JSONObj + Overwrite = $true + APIName = 'Standards' + Headers = $Request.Headers + DisableSD = $Setting.DisableSD + CreateGroups = $Setting.CreateGroups ?? $false + PreloadedCAPolicies = $AllCAPolicies } $null = New-CIPPCAPolicy @NewCAPolicy From 14ece32d5a9707f423fa922fa43615e1850a438c Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Mon, 16 Feb 2026 12:09:37 +0100 Subject: [PATCH 38/97] locationdependancy --- Modules/CIPPCore/Public/New-CIPPCAPolicy.ps1 | 36 +++++++++++--------- 1 file changed, 19 insertions(+), 17 deletions(-) diff --git a/Modules/CIPPCore/Public/New-CIPPCAPolicy.ps1 b/Modules/CIPPCore/Public/New-CIPPCAPolicy.ps1 index 6589daade37f..306814ce68af 100644 --- a/Modules/CIPPCore/Public/New-CIPPCAPolicy.ps1 +++ b/Modules/CIPPCore/Public/New-CIPPCAPolicy.ps1 @@ -302,25 +302,27 @@ function New-CIPPCAPolicy { Write-Information 'Location Lookup Table:' Write-Information ($LocationLookupTable | ConvertTo-Json -Depth 10) - foreach ($location in $JSONobj.conditions.locations.includeLocations) { - if ($null -eq $location) { continue } - $lookup = $LocationLookupTable | Where-Object { $_.name -eq $location -or $_.displayName -eq $location -or $_.templateId -eq $location } - if (!$lookup) { continue } - Write-Information "Replacing named location - $location" - $index = [array]::IndexOf($JSONobj.conditions.locations.includeLocations, $location) - if ($lookup.id) { - $JSONobj.conditions.locations.includeLocations[$index] = $lookup.id + if ($LocationLookupTable -and $JSONobj.conditions.locations) { + foreach ($location in $JSONobj.conditions.locations.includeLocations) { + if ($null -eq $location) { continue } + $lookup = $LocationLookupTable | Where-Object { $_.name -eq $location -or $_.displayName -eq $location -or $_.templateId -eq $location } + if (!$lookup) { continue } + Write-Information "Replacing named location - $location" + $index = [array]::IndexOf($JSONobj.conditions.locations.includeLocations, $location) + if ($lookup.id) { + $JSONobj.conditions.locations.includeLocations[$index] = $lookup.id + } } - } - foreach ($location in $JSONobj.conditions.locations.excludeLocations) { - if ($null -eq $location) { continue } - $lookup = $LocationLookupTable | Where-Object { $_.name -eq $location -or $_.displayName -eq $location -or $_.templateId -eq $location } - if (!$lookup) { continue } - Write-Information "Replacing named location - $location" - $index = [array]::IndexOf($JSONobj.conditions.locations.excludeLocations, $location) - if ($lookup.id) { - $JSONobj.conditions.locations.excludeLocations[$index] = $lookup.id + foreach ($location in $JSONobj.conditions.locations.excludeLocations) { + if ($null -eq $location) { continue } + $lookup = $LocationLookupTable | Where-Object { $_.name -eq $location -or $_.displayName -eq $location -or $_.templateId -eq $location } + if (!$lookup) { continue } + Write-Information "Replacing named location - $location" + $index = [array]::IndexOf($JSONobj.conditions.locations.excludeLocations, $location) + if ($lookup.id) { + $JSONobj.conditions.locations.excludeLocations[$index] = $lookup.id + } } } switch ($ReplacePattern) { From f2367f9e6df4e7faca43ac05fb50e65b851e598e Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Mon, 16 Feb 2026 12:48:18 +0100 Subject: [PATCH 39/97] removes troubleshooting lines --- Modules/CIPPCore/Public/New-CIPPCAPolicy.ps1 | 23 +++++++++++++------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/Modules/CIPPCore/Public/New-CIPPCAPolicy.ps1 b/Modules/CIPPCore/Public/New-CIPPCAPolicy.ps1 index 306814ce68af..5abdc29c44a6 100644 --- a/Modules/CIPPCore/Public/New-CIPPCAPolicy.ps1 +++ b/Modules/CIPPCore/Public/New-CIPPCAPolicy.ps1 @@ -134,12 +134,10 @@ function New-CIPPCAPolicy { if ($PreloadedCAPolicies) { Write-Information 'Using preloaded CA policies' $AllExistingPolicies = $PreloadedCAPolicies - Write-Information "Found $($AllExistingPolicies.Count) preloaded CA policies" } else { try { Write-Information 'Fetching existing CA policies...' $AllExistingPolicies = New-GraphGETRequest -uri 'https://graph.microsoft.com/beta/identity/conditionalAccess/policies?$top=999' -tenantid $TenantFilter -asApp $true - Write-Information "Found $($AllExistingPolicies.Count) existing CA policies" } catch { $ErrorMessage = Get-CippException -Exception $_ Write-Information "Error fetching existing policies: $($ErrorMessage | ConvertTo-Json -Depth 10 -Compress)" @@ -153,7 +151,6 @@ function New-CIPPCAPolicy { try { Write-Information 'Fetching all named locations...' $AllNamedLocations = New-GraphGETRequest -uri 'https://graph.microsoft.com/beta/identity/conditionalAccess/namedLocations?$top=999' -tenantid $TenantFilter -asApp $true - Write-Information "Found $($AllNamedLocations.Count) existing named locations" } catch { $ErrorMessage = Get-CippException -Exception $_ Write-Information "Error fetching named locations: $($ErrorMessage | ConvertTo-Json -Depth 10 -Compress)" @@ -167,7 +164,6 @@ function New-CIPPCAPolicy { try { Write-Information 'Fetching authentication strength policies...' $AllAuthStrengthPolicies = New-GraphGETRequest -uri 'https://graph.microsoft.com/beta/identity/conditionalAccess/authenticationStrength/policies/' -tenantid $TenantFilter -asApp $true - Write-Information "Found $($AllAuthStrengthPolicies.Count) authentication strength policies" } catch { $ErrorMessage = Get-CippException -Exception $_ Write-Information "Error fetching authentication strength policies: $($ErrorMessage | ConvertTo-Json -Depth 10 -Compress)" @@ -181,7 +177,6 @@ function New-CIPPCAPolicy { try { Write-Information 'Fetching all service principals...' $AllServicePrincipals = New-GraphGETRequest -uri 'https://graph.microsoft.com/v1.0/servicePrincipals?$select=appId&$top=999' -tenantid $TenantFilter -asApp $true - Write-Information "Found $($AllServicePrincipals.Count) service principals" } catch { $ErrorMessage = Get-CippException -Exception $_ Write-Information "Error fetching service principals: $($ErrorMessage | ConvertTo-Json -Depth 10 -Compress)" @@ -189,8 +184,6 @@ function New-CIPPCAPolicy { } } - Write-Information 'All required resources fetched successfully' - #If Grant Controls contains authenticationStrength, create these and then replace the id if ($JSONobj.GrantControls.authenticationStrength.policyType -eq 'custom' -or $JSONobj.GrantControls.authenticationStrength.policyType -eq 'BuiltIn') { $ExistingStrength = $AllAuthStrengthPolicies | Where-Object -Property displayName -EQ $JSONobj.GrantControls.authenticationStrength.displayName @@ -422,9 +415,23 @@ function New-CIPPCAPolicy { Write-Information $RawJSON try { Write-Information 'Checking for existing policies' - # Use cached policies instead of fetching again + # Use cached policies from the beginning $CheckExisting = $AllExistingPolicies | Where-Object -Property displayName -EQ $displayName + + # Handle multiple policies with the same name (should not happen but does) + if ($CheckExisting -is [Array] -and $CheckExisting.Count -gt 1) { + Write-Warning "Found $($CheckExisting.Count) policies with display name '$displayName'. IDs: $($CheckExisting.id -join ', '). Using the first one." + $CheckExisting = $CheckExisting[0] + } + if ($CheckExisting) { + Write-Information "Found existing policy: displayName=$($CheckExisting.displayName), id=$($CheckExisting.id)" + + # Validate the ID before proceeding + if ([string]::IsNullOrWhiteSpace($CheckExisting.id)) { + Write-Information "ERROR: Policy found but ID is null/empty. Full object: $($CheckExisting | ConvertTo-Json -Depth 5 -Compress)" + throw "Found existing policy '$displayName' but ID is null or empty. This may indicate an API issue." + } if ($Overwrite -ne $true) { throw "Conditional Access Policy with Display Name $($displayName) Already exists" return $false From 24ac8f883ec511e8de11df9ec4e946b7af6475e4 Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Mon, 16 Feb 2026 12:57:23 +0100 Subject: [PATCH 40/97] POSt request retry logic improvements --- .../GraphHelper/New-GraphPOSTRequest.ps1 | 46 +++++++++++++++---- 1 file changed, 36 insertions(+), 10 deletions(-) diff --git a/Modules/CIPPCore/Public/GraphHelper/New-GraphPOSTRequest.ps1 b/Modules/CIPPCore/Public/GraphHelper/New-GraphPOSTRequest.ps1 index 5d4d1fd3f950..d2ac97fb8839 100644 --- a/Modules/CIPPCore/Public/GraphHelper/New-GraphPOSTRequest.ps1 +++ b/Modules/CIPPCore/Public/GraphHelper/New-GraphPOSTRequest.ps1 @@ -39,25 +39,51 @@ function New-GraphPOSTRequest { $body = Get-CIPPTextReplacement -TenantFilter $tenantid -Text $body -EscapeForJson - $x = 0 + $RetryCount = 0 + $RequestSuccessful = $false do { try { - Write-Information "$($type.ToUpper()) [ $uri ] | tenant: $tenantid | attempt: $($x + 1) of $maxRetries" - $success = $false + Write-Information "$($type.ToUpper()) [ $uri ] | tenant: $tenantid | attempt: $($RetryCount + 1) of $maxRetries" $ReturnedData = (Invoke-RestMethod -Uri $($uri) -Method $TYPE -Body $body -Headers $headers -ContentType $contentType -SkipHttpErrorCheck:$IgnoreErrors -ResponseHeadersVariable responseHeaders) - $success = $true + $RequestSuccessful = $true } catch { - + $ShouldRetry = $false + $WaitTime = 0 $Message = if ($_.ErrorDetails.Message) { Get-NormalizedError -Message $_.ErrorDetails.Message } else { $_.Exception.message } - $x++ - Start-Sleep -Seconds (2 * $x) + + # Check for 429 Too Many Requests + if ($_.Exception.Response.StatusCode -eq 429) { + $RetryAfterHeader = $_.Exception.Response.Headers['Retry-After'] + if ($RetryAfterHeader) { + $WaitTime = [int]$RetryAfterHeader + Write-Warning "Rate limited (429). Waiting $WaitTime seconds before retry. Attempt $($RetryCount + 1) of $maxRetries" + $ShouldRetry = $true + } + } + # Check for "Resource temporarily unavailable" + elseif ($Message -like '*Resource temporarily unavailable*' -or $Message -like '*Too many requests*') { + $WaitTime = Get-Random -Minimum 1.1 -Maximum 3.1 + Write-Warning "Resource temporarily unavailable. Waiting $WaitTime seconds before retry. Attempt $($RetryCount + 1) of $maxRetries" + $ShouldRetry = $true + } + + if ($ShouldRetry) { + $RetryCount++ + if ($RetryCount -lt $maxRetries) { + Start-Sleep -Seconds $WaitTime + } + } else { + # Not a retryable error, exit immediately + break + } } - } while (($x -lt $maxRetries) -and ($success -eq $false)) - if (($maxRetries -and $success -eq $false) -and $ScheduleRetry -eq $true) { + } while (-not $RequestSuccessful -and $RetryCount -lt $maxRetries) + + if (($RequestSuccessful -eq $false) -and $ScheduleRetry -eq $true -and $ShouldRetry -eq $true) { #Create a scheduled task to retry the task later, when there is less pressure on the system, but only if ScheduledRetry is true. try { $TaskId = (New-Guid).Guid.ToString() @@ -102,7 +128,7 @@ function New-GraphPOSTRequest { } } - if ($success -eq $false) { + if ($RequestSuccessful -eq $false) { throw $Message } From c2448ffc1a5999025afac6174f841d2701e98b06 Mon Sep 17 00:00:00 2001 From: rvdwegen Date: Mon, 16 Feb 2026 13:00:27 +0100 Subject: [PATCH 41/97] add log retention logic --- CIPPTimers.json | 13 ++- .../Invoke-ExecLogRetentionConfig.ps1 | 61 ++++++++++++ .../Start-LogRetentionCleanup.ps1 | 96 +++++++++++++++++++ 3 files changed, 168 insertions(+), 2 deletions(-) create mode 100644 Modules/CIPPCore/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecLogRetentionConfig.ps1 create mode 100644 Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-LogRetentionCleanup.ps1 diff --git a/CIPPTimers.json b/CIPPTimers.json index 35586b72833e..ed7cd4c31819 100644 --- a/CIPPTimers.json +++ b/CIPPTimers.json @@ -223,12 +223,21 @@ "RunOnProcessor": true, "IsSystem": true }, + { + "Id": "a9e8d7c6-b5a4-3f2e-1d0c-9b8a7f6e5d4c", + "Command": "Start-LogRetentionCleanup", + "Description": "Timer to cleanup old logs based on retention policy", + "Cron": "0 30 2 * * *", + "Priority": 22, + "RunOnProcessor": true, + "IsSystem": true + }, { "Id": "9a7f8e6d-5c4b-3a2d-1e0f-9b8c7d6e5f4a", "Command": "Start-CIPPDBCacheOrchestrator", "Description": "Timer to collect and cache Microsoft Graph data for all tenants", "Cron": "0 0 3 * * *", - "Priority": 22, + "Priority": 23, "RunOnProcessor": true, "IsSystem": true }, @@ -237,7 +246,7 @@ "Command": "Start-TestsOrchestrator", "Description": "Timer to run security and compliance tests against cached data", "Cron": "0 0 4 * * *", - "Priority": 23, + "Priority": 24, "RunOnProcessor": true, "IsSystem": true } diff --git a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecLogRetentionConfig.ps1 b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecLogRetentionConfig.ps1 new file mode 100644 index 000000000000..2f8cb0168ec7 --- /dev/null +++ b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecLogRetentionConfig.ps1 @@ -0,0 +1,61 @@ +function Invoke-ExecLogRetentionConfig { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + CIPP.AppSettings.ReadWrite + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + $Table = Get-CIPPTable -TableName Config + $Filter = "PartitionKey eq 'LogRetention' and RowKey eq 'Settings'" + + $results = try { + if ($Request.Query.List) { + $RetentionSettings = Get-CIPPAzDataTableEntity @Table -Filter $Filter + if (!$RetentionSettings) { + # Return default values if not set + @{ + RetentionDays = 90 + } + } else { + @{ + RetentionDays = [int]$RetentionSettings.RetentionDays + } + } + } else { + $RetentionDays = [int]$Request.Body.RetentionDays + + # Validate minimum value + if ($RetentionDays -lt 7) { + throw 'Retention days must be at least 7 days' + } + + # Validate maximum value + if ($RetentionDays -gt 365) { + throw 'Retention days must be at most 365 days' + } + + $RetentionConfig = @{ + 'RetentionDays' = $RetentionDays + 'PartitionKey' = 'LogRetention' + 'RowKey' = 'Settings' + } + + Add-CIPPAzDataTableEntity @Table -Entity $RetentionConfig -Force | Out-Null + Write-LogMessage -headers $Request.Headers -API $Request.Params.CIPPEndpoint -message "Set log retention to $RetentionDays days" -Sev 'Info' + "Successfully set log retention to $RetentionDays days" + } + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -headers $Request.Headers -API $Request.Params.CIPPEndpoint -message "Failed to set log retention configuration: $($ErrorMessage.NormalizedError)" -Sev 'Error' -LogData $ErrorMessage + "Failed to set configuration: $($ErrorMessage.NormalizedError)" + } + + $body = [pscustomobject]@{'Results' = $Results } + + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::OK + Body = $body + }) +} diff --git a/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-LogRetentionCleanup.ps1 b/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-LogRetentionCleanup.ps1 new file mode 100644 index 000000000000..d1e32b85fcfa --- /dev/null +++ b/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-LogRetentionCleanup.ps1 @@ -0,0 +1,96 @@ +function Start-LogRetentionCleanup { + <# + .SYNOPSIS + Start the Log Retention Cleanup Timer + .DESCRIPTION + This function cleans up old CIPP logs based on the retention policy + #> + [CmdletBinding(SupportsShouldProcess = $true)] + param() + + try { + # Get retention settings + $ConfigTable = Get-CippTable -tablename Config + $Filter = "PartitionKey eq 'LogRetention' and RowKey eq 'Settings'" + $RetentionSettings = Get-CIPPAzDataTableEntity @ConfigTable -Filter $Filter + + # Default to 90 days if not set + $RetentionDays = if ($RetentionSettings.RetentionDays) { + [int]$RetentionSettings.RetentionDays + } else { + 90 + } + + # Ensure minimum retention of 7 days + if ($RetentionDays -lt 7) { + $RetentionDays = 7 + } + + # Ensure maximum retention of 365 days + if ($RetentionDays -gt 365) { + $RetentionDays = 365 + } + + Write-Host "Starting log cleanup with retention of $RetentionDays days" + + # Calculate cutoff date + $CutoffDate = (Get-Date).AddDays(-$RetentionDays).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') + + $DeletedCount = 0 + + # 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') + if ($OldLogs) { + # Delete logs in batches to avoid overwhelming the table service + $BatchSize = 100 + $LogBatches = @() + $CurrentBatch = @() + + foreach ($Log in $OldLogs) { + $CurrentBatch += $Log + if ($CurrentBatch.Count -ge $BatchSize) { + $LogBatches += , @($CurrentBatch) + $CurrentBatch = @() + } + } + + # Add remaining logs as final batch + if ($CurrentBatch.Count -gt 0) { + $LogBatches += , @($CurrentBatch) + } + + # Delete logs in batches + foreach ($Batch in $LogBatches) { + try { + Remove-AzDataTableEntity @CippLogsTable -Entity $Batch -Force + $DeletedCount += $Batch.Count + Write-Host "Deleted batch of $($Batch.Count) log entries" + } catch { + Write-LogMessage -API 'LogRetentionCleanup' -message "Failed to delete log batch: $($_.Exception.Message)" -Sev 'Warning' + } + } + + if ($DeletedCount -gt 0) { + Write-LogMessage -API 'LogRetentionCleanup' -message "Deleted $DeletedCount old log entries (retention: $RetentionDays days)" -Sev 'Info' + Write-Host "Deleted $DeletedCount old log entries" + } else { + Write-Host 'No old logs found' + } + } else { + Write-Host 'No old logs found' + } + } + + Write-LogMessage -API 'LogRetentionCleanup' -message "Log cleanup completed. Total logs deleted: $DeletedCount (retention: $RetentionDays days)" -Sev 'Info' + + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'LogRetentionCleanup' -message "Failed to run log cleanup: $($ErrorMessage.NormalizedError)" -Sev 'Error' -LogData $ErrorMessage + throw + } +} From 82558d2c75c78e8e72e8bb8863176295e21d49dd Mon Sep 17 00:00:00 2001 From: rvdwegen Date: Mon, 16 Feb 2026 13:02:49 +0100 Subject: [PATCH 42/97] No batching on old log cleanup --- .../Start-LogRetentionCleanup.ps1 | 40 +++---------------- 1 file changed, 5 insertions(+), 35 deletions(-) diff --git a/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-LogRetentionCleanup.ps1 b/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-LogRetentionCleanup.ps1 index d1e32b85fcfa..19ad42a6c5ad 100644 --- a/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-LogRetentionCleanup.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-LogRetentionCleanup.ps1 @@ -45,42 +45,12 @@ function Start-LogRetentionCleanup { # Fetch all old log entries $OldLogs = Get-AzDataTableEntity @CippLogsTable -Filter $CutoffFilter -Property @('PartitionKey', 'RowKey', 'ETag') - if ($OldLogs) { - # Delete logs in batches to avoid overwhelming the table service - $BatchSize = 100 - $LogBatches = @() - $CurrentBatch = @() - - foreach ($Log in $OldLogs) { - $CurrentBatch += $Log - if ($CurrentBatch.Count -ge $BatchSize) { - $LogBatches += , @($CurrentBatch) - $CurrentBatch = @() - } - } - - # Add remaining logs as final batch - if ($CurrentBatch.Count -gt 0) { - $LogBatches += , @($CurrentBatch) - } - # Delete logs in batches - foreach ($Batch in $LogBatches) { - try { - Remove-AzDataTableEntity @CippLogsTable -Entity $Batch -Force - $DeletedCount += $Batch.Count - Write-Host "Deleted batch of $($Batch.Count) log entries" - } catch { - Write-LogMessage -API 'LogRetentionCleanup' -message "Failed to delete log batch: $($_.Exception.Message)" -Sev 'Warning' - } - } - - if ($DeletedCount -gt 0) { - Write-LogMessage -API 'LogRetentionCleanup' -message "Deleted $DeletedCount old log entries (retention: $RetentionDays days)" -Sev 'Info' - Write-Host "Deleted $DeletedCount old log entries" - } else { - Write-Host 'No old logs found' - } + 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" } else { Write-Host 'No old logs found' } From 4252f26a5ce6346808292d3ff3753357543e2782 Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Mon, 16 Feb 2026 13:12:17 +0100 Subject: [PATCH 43/97] add member for template --- Modules/CIPPCore/Public/New-CIPPCATemplate.ps1 | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/Modules/CIPPCore/Public/New-CIPPCATemplate.ps1 b/Modules/CIPPCore/Public/New-CIPPCATemplate.ps1 index 060b91863208..2a3f4c43b3cc 100644 --- a/Modules/CIPPCore/Public/New-CIPPCATemplate.ps1 +++ b/Modules/CIPPCore/Public/New-CIPPCATemplate.ps1 @@ -46,7 +46,9 @@ function New-CIPPCATemplate { $null = if ($locationinfo) { $includelocations.add($locationinfo.displayName) } else { $includelocations.add($location) } $locationinfo } - if ($includelocations) { $JSON.conditions.locations.includeLocations = $includelocations } + if ($includelocations) { + $JSON.conditions.locations | Add-Member -NotePropertyName 'includeLocations' -NotePropertyValue $includelocations -Force + } $excludelocations = [system.collections.generic.list[object]]::new() $ExcludeJSON = foreach ($Location in $JSON.conditions.locations.excludeLocations) { @@ -55,7 +57,9 @@ function New-CIPPCATemplate { $locationinfo } - if ($excludelocations) { $JSON.conditions.locations.excludeLocations = $excludelocations } + if ($excludelocations) { + $JSON.conditions.locations | Add-Member -NotePropertyName 'excludeLocations' -NotePropertyValue $excludelocations -Force + } # Check if conditions.users exists and is a PSCustomObject (not an array) before accessing properties $hasConditionsUsers = $null -ne $JSON.conditions.users # Explicitly exclude array types - arrays have properties but we can't set custom properties on them From d1aa064bad0f4bc1f72bcb7760729940eb8e8f66 Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Mon, 16 Feb 2026 13:27:50 +0100 Subject: [PATCH 44/97] use cache to preload locations --- Modules/CIPPCore/Public/New-CIPPCATemplate.ps1 | 15 ++++++++++----- ...voke-CIPPStandardConditionalAccessTemplate.ps1 | 4 +++- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/Modules/CIPPCore/Public/New-CIPPCATemplate.ps1 b/Modules/CIPPCore/Public/New-CIPPCATemplate.ps1 index 2a3f4c43b3cc..d3b4fd97ad35 100644 --- a/Modules/CIPPCore/Public/New-CIPPCATemplate.ps1 +++ b/Modules/CIPPCore/Public/New-CIPPCATemplate.ps1 @@ -6,7 +6,8 @@ function New-CIPPCATemplate { $APIName = 'Add CIPP CA Template', $Headers, $preloadedUsers, - $preloadedGroups + $preloadedGroups, + $preloadedLocations ) $JSON = ([pscustomobject]$JSON) | ForEach-Object { @@ -34,8 +35,12 @@ function New-CIPPCATemplate { } $namedLocations = $null - if ($JSON.conditions.locations.includeLocations -or $JSON.conditions.locations.excludeLocations) { - $namedLocations = New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/identity/conditionalAccess/namedLocations' -tenantid $TenantFilter + if ($preloadedLocations) { + $namedLocations = $preloadedLocations + } else { + if ($JSON.conditions.locations.includeLocations -or $JSON.conditions.locations.excludeLocations) { + $namedLocations = New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/identity/conditionalAccess/namedLocations?$top=999' -tenantid $TenantFilter + } } $AllLocations = [system.collections.generic.list[object]]::new() @@ -46,7 +51,7 @@ function New-CIPPCATemplate { $null = if ($locationinfo) { $includelocations.add($locationinfo.displayName) } else { $includelocations.add($location) } $locationinfo } - if ($includelocations) { + if ($includelocations) { $JSON.conditions.locations | Add-Member -NotePropertyName 'includeLocations' -NotePropertyValue $includelocations -Force } @@ -57,7 +62,7 @@ function New-CIPPCATemplate { $locationinfo } - if ($excludelocations) { + if ($excludelocations) { $JSON.conditions.locations | Add-Member -NotePropertyName 'excludeLocations' -NotePropertyValue $excludelocations -Force } # Check if conditions.users exists and is a PSCustomObject (not an array) before accessing properties diff --git a/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardConditionalAccessTemplate.ps1 b/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardConditionalAccessTemplate.ps1 index a41018355017..e9f81a0323a5 100644 --- a/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardConditionalAccessTemplate.ps1 +++ b/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardConditionalAccessTemplate.ps1 @@ -90,6 +90,7 @@ function Invoke-CIPPStandardConditionalAccessTemplate { $Filter = "PartitionKey eq 'CATemplate'" $Policies = (Get-CippAzDataTableEntity @Table -Filter $Filter | Where-Object RowKey -In $Settings.TemplateList.value).JSON | ConvertFrom-Json -Depth 10 $AllCAPolicies = New-GraphGetRequest -Uri 'https://graph.microsoft.com/beta/identity/conditionalAccess/policies?$top=999' -tenantid $Tenant -asApp $true + #check if all groups.displayName are in the existingGroups, if not $fieldvalue should contain all missing groups, else it should be true. $MissingPolicies = foreach ($Setting in $Settings.TemplateList) { $policy = $Policies | Where-Object { $_.displayName -eq $Setting.label } @@ -105,7 +106,8 @@ function Invoke-CIPPStandardConditionalAccessTemplate { Set-CIPPStandardsCompareField -FieldName "standards.ConditionalAccessTemplate.$($Setting.value)" -FieldValue "Policy $($Setting.label) is missing from this tenant." -Tenant $Tenant } } else { - $templateResult = New-CIPPCATemplate -TenantFilter $tenant -JSON $CheckExististing + $preloadedLocations = New-CIPPDbRequest -TenantFilter $tenant -Type 'NamedLocations' + $templateResult = New-CIPPCATemplate -TenantFilter $tenant -JSON $CheckExististing -preloadedLocations $preloadedLocations $CompareObj = ConvertFrom-Json -ErrorAction SilentlyContinue -InputObject $templateResult try { $Compare = Compare-CIPPIntuneObject -ReferenceObject $policy -DifferenceObject $CompareObj -CompareType 'ca' From b4b9d6432d53bfa41bc6c34a2a24bca61c2f06ed Mon Sep 17 00:00:00 2001 From: rvdwegen Date: Mon, 16 Feb 2026 14:01:12 +0100 Subject: [PATCH 45/97] Minor standards optimization by moving license checks --- .../Invoke-CIPPStandardConditionalAccessTemplate.ps1 | 6 ++++-- .../Standards/Invoke-CIPPStandardGroupTemplate.ps1 | 12 +++++++----- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardConditionalAccessTemplate.ps1 b/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardConditionalAccessTemplate.ps1 index a41018355017..9c4497aa5752 100644 --- a/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardConditionalAccessTemplate.ps1 +++ b/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardConditionalAccessTemplate.ps1 @@ -34,9 +34,7 @@ function Invoke-CIPPStandardConditionalAccessTemplate { #> param($Tenant, $Settings) ##$Rerun -Type Standard -Tenant $Tenant -Settings $Settings 'ConditionalAccess' - $Table = Get-CippTable -tablename 'templates' $TestResult = Test-CIPPStandardLicense -StandardName 'ConditionalAccessTemplate_general' -TenantFilter $Tenant -RequiredCapabilities @('AAD_PREMIUM', 'AAD_PREMIUM_P2') - $TestP2 = Test-CIPPStandardLicense -StandardName 'ConditionalAccessTemplate_p2' -TenantFilter $Tenant -RequiredCapabilities @('AAD_PREMIUM_P2') -SkipLog if ($TestResult -eq $false) { #writing to each item that the license is not present. foreach ($Template in $settings.TemplateList) { @@ -45,6 +43,8 @@ function Invoke-CIPPStandardConditionalAccessTemplate { return $true } #we're done. + $Table = Get-CippTable -tablename 'templates' + try { $AllCAPolicies = New-GraphGetRequest -Uri 'https://graph.microsoft.com/beta/identity/conditionalAccess/policies?$top=999' -tenantid $Tenant -asApp $true } catch { @@ -60,6 +60,7 @@ function Invoke-CIPPStandardConditionalAccessTemplate { $JSONObj = (Get-CippAzDataTableEntity @Table -Filter $Filter).JSON $Policy = $JSONObj | ConvertFrom-Json if ($Policy.conditions.userRiskLevels.count -gt 0 -or $Policy.conditions.signInRiskLevels.count -gt 0) { + $TestP2 = Test-CIPPStandardLicense -StandardName 'ConditionalAccessTemplate_p2' -TenantFilter $Tenant -RequiredCapabilities @('AAD_PREMIUM_P2') -SkipLog if (!$TestP2) { Write-Information "Skipping policy $($Policy.displayName) as it requires AAD Premium P2 license." Set-CIPPStandardsCompareField -FieldName "standards.ConditionalAccessTemplate.$($Setting.value)" -FieldValue "Policy $($Policy.displayName) requires AAD Premium P2 license." -Tenant $Tenant @@ -96,6 +97,7 @@ function Invoke-CIPPStandardConditionalAccessTemplate { $CheckExististing = $AllCAPolicies | Where-Object -Property displayName -EQ $Setting.label if (!$CheckExististing) { if ($Setting.conditions.userRiskLevels.Count -gt 0 -or $Setting.conditions.signInRiskLevels.Count -gt 0) { + $TestP2 = Test-CIPPStandardLicense -StandardName 'ConditionalAccessTemplate_p2' -TenantFilter $Tenant -RequiredCapabilities @('AAD_PREMIUM_P2') -SkipLog if (!$TestP2) { Set-CIPPStandardsCompareField -FieldName "standards.ConditionalAccessTemplate.$($Setting.value)" -FieldValue "Policy $($Setting.label) requires AAD Premium P2 license." -Tenant $Tenant } else { diff --git a/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardGroupTemplate.ps1 b/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardGroupTemplate.ps1 index ed52d13b0b4b..9eddfc36d59b 100644 --- a/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardGroupTemplate.ps1 +++ b/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardGroupTemplate.ps1 @@ -33,8 +33,6 @@ function Invoke-CIPPStandardGroupTemplate { $existingGroups = New-GraphGETRequest -uri 'https://graph.microsoft.com/beta/groups?$top=999&$select=id,displayName,description,membershipRule' -tenantid $tenant - $TestResult = Test-CIPPStandardLicense -StandardName 'GroupTemplate' -TenantFilter $Tenant -RequiredCapabilities @('EXCHANGE_S_STANDARD', 'EXCHANGE_S_ENTERPRISE', 'EXCHANGE_LITE') -SkipLog - $Settings.groupTemplate ? ($Settings | Add-Member -NotePropertyName 'TemplateList' -NotePropertyValue $Settings.groupTemplate) : $null $Table = Get-CippTable -tablename 'templates' @@ -64,9 +62,12 @@ function Invoke-CIPPStandardGroupTemplate { $ActionType = 'create' # Check if Exchange license is required for distribution groups - if ($groupobj.groupType -in @('distribution', 'dynamicdistribution') -and !$TestResult) { - Write-LogMessage -API 'Standards' -tenant $tenant -message "Cannot create group $($groupobj.displayname) as the tenant is not licensed for Exchange." -Sev 'Error' - continue + if ($groupobj.groupType -in @('distribution', 'dynamicdistribution')) { + $TestResult = Test-CIPPStandardLicense -StandardName 'GroupTemplate' -TenantFilter $Tenant -RequiredCapabilities @('EXCHANGE_S_STANDARD', 'EXCHANGE_S_ENTERPRISE', 'EXCHANGE_LITE') -SkipLog + if (!$TestResult) { + Write-LogMessage -API 'Standards' -tenant $tenant -message "Cannot create group $($groupobj.displayname) as the tenant is not licensed for Exchange." -Sev 'Error' + continue + } } # Use the centralized New-CIPPGroup function @@ -127,6 +128,7 @@ function Invoke-CIPPStandardGroupTemplate { } else { # Handle Exchange Online groups (Distribution, DynamicDistribution) + $TestResult = Test-CIPPStandardLicense -StandardName 'GroupTemplate' -TenantFilter $Tenant -RequiredCapabilities @('EXCHANGE_S_STANDARD', 'EXCHANGE_S_ENTERPRISE', 'EXCHANGE_LITE') -SkipLog if (!$TestResult) { Write-LogMessage -API 'Standards' -tenant $tenant -message "Cannot update group $($groupobj.displayName) as the tenant is not licensed for Exchange." -Sev 'Error' continue From df29e37dde90b529b31acf312d1bf4cb03b41f99 Mon Sep 17 00:00:00 2001 From: rvdwegen Date: Mon, 16 Feb 2026 14:19:36 +0100 Subject: [PATCH 46/97] Duplicate API call in CIPPStandardSafeAttachmentPolicy --- .../Invoke-CIPPStandardSafeAttachmentPolicy.ps1 | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardSafeAttachmentPolicy.ps1 b/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardSafeAttachmentPolicy.ps1 index 79c6d309ee85..4a9d73a371d4 100644 --- a/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardSafeAttachmentPolicy.ps1 +++ b/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardSafeAttachmentPolicy.ps1 @@ -57,6 +57,14 @@ function Invoke-CIPPStandardSafeAttachmentPolicy { Write-LogMessage -API 'Standards' -Tenant $Tenant -Message "Could not get the SafeAttachmentPolicy state for $Tenant. Error: $ErrorMessage" -Sev Error return } + # Cache all Safe Attachment Rules to avoid duplicate API calls + try { + $AllSafeAttachmentRule = New-ExoRequest -tenantid $Tenant -cmdlet 'Get-SafeAttachmentRule' + } catch { + $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message + Write-LogMessage -API 'Standards' -Tenant $Tenant -Message "Could not get the SafeAttachmentRule state for $Tenant. Error: $ErrorMessage" -Sev Error + return + } # Use custom name if provided, otherwise use default for backward compatibility $PolicyName = if ($Settings.name) { $Settings.name } else { 'CIPP Default Safe Attachment Policy' } @@ -72,7 +80,7 @@ function Invoke-CIPPStandardSafeAttachmentPolicy { # Derive rule name from policy name, but check for old names for backward compatibility $DesiredRuleName = "$PolicyName Rule" $RuleList = @($DesiredRuleName, 'CIPP Default Safe Attachment Rule', 'CIPP Default Safe Attachment Policy') - $ExistingRule = New-ExoRequest -tenantid $Tenant -cmdlet 'Get-SafeAttachmentRule' | Where-Object -Property Name -In $RuleList | Select-Object -First 1 + $ExistingRule = $AllSafeAttachmentRule | Where-Object -Property Name -In $RuleList | Select-Object -First 1 if ($null -eq $ExistingRule.Name) { # No existing rule - use the derived name $RuleName = $DesiredRuleName @@ -94,7 +102,7 @@ function Invoke-CIPPStandardSafeAttachmentPolicy { $AcceptedDomains = New-ExoRequest -tenantid $Tenant -cmdlet 'Get-AcceptedDomain' - $RuleState = New-ExoRequest -tenantid $Tenant -cmdlet 'Get-SafeAttachmentRule' | + $RuleState = $AllSafeAttachmentRule | Where-Object -Property Name -EQ $RuleName | Select-Object Name, SafeAttachmentPolicy, Priority, RecipientDomainIs From 900aa1de0d6da035813c2053d1214263d767f200 Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Mon, 16 Feb 2026 14:20:35 +0100 Subject: [PATCH 47/97] added rate limit capture for environments without retry header --- Modules/CIPPCore/Public/GraphHelper/New-GraphGetRequest.ps1 | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Modules/CIPPCore/Public/GraphHelper/New-GraphGetRequest.ps1 b/Modules/CIPPCore/Public/GraphHelper/New-GraphGetRequest.ps1 index f5dc35876afc..23b0e59d3dc7 100644 --- a/Modules/CIPPCore/Public/GraphHelper/New-GraphGetRequest.ps1 +++ b/Modules/CIPPCore/Public/GraphHelper/New-GraphGetRequest.ps1 @@ -160,6 +160,11 @@ function New-GraphGetRequest { $WaitTime = [int]$RetryAfterHeader Write-Warning "Rate limited (429). Waiting $WaitTime seconds before retry. Attempt $($RetryCount + 1) of $MaxRetries" $ShouldRetry = $true + } else { + # If no Retry-After header, use exponential backoff with jitter + $WaitTime = Get-Random -Minimum 1.1 -Maximum 4.1 # Random sleep between 1-4 seconds + Write-Warning "Rate limited (429) with no Retry-After header. Waiting $WaitTime seconds before retry. Attempt $($RetryCount + 1) of $MaxRetries. Headers: $(($HttpResponseDetails.Headers | ConvertTo-Json -Compress))" + $ShouldRetry = $true } } # Check for "Resource temporarily unavailable" From 4afa2748d4f13b8da1f6075d6492be6955176145 Mon Sep 17 00:00:00 2001 From: rvdwegen Date: Mon, 16 Feb 2026 14:38:48 +0100 Subject: [PATCH 48/97] Move NamedLocations CIPPDbRequest outside of the loop --- .../Invoke-CIPPStandardConditionalAccessTemplate.ps1 | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardConditionalAccessTemplate.ps1 b/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardConditionalAccessTemplate.ps1 index 9f8a8559c2a7..c1455d3ba37b 100644 --- a/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardConditionalAccessTemplate.ps1 +++ b/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardConditionalAccessTemplate.ps1 @@ -92,6 +92,9 @@ function Invoke-CIPPStandardConditionalAccessTemplate { $Policies = (Get-CippAzDataTableEntity @Table -Filter $Filter | Where-Object RowKey -In $Settings.TemplateList.value).JSON | ConvertFrom-Json -Depth 10 $AllCAPolicies = New-GraphGetRequest -Uri 'https://graph.microsoft.com/beta/identity/conditionalAccess/policies?$top=999' -tenantid $Tenant -asApp $true + # Preload named locations once outside the loop to avoid duplicate database queries + $preloadedLocations = New-CIPPDbRequest -TenantFilter $tenant -Type 'NamedLocations' + #check if all groups.displayName are in the existingGroups, if not $fieldvalue should contain all missing groups, else it should be true. $MissingPolicies = foreach ($Setting in $Settings.TemplateList) { $policy = $Policies | Where-Object { $_.displayName -eq $Setting.label } @@ -108,7 +111,6 @@ function Invoke-CIPPStandardConditionalAccessTemplate { Set-CIPPStandardsCompareField -FieldName "standards.ConditionalAccessTemplate.$($Setting.value)" -FieldValue "Policy $($Setting.label) is missing from this tenant." -Tenant $Tenant } } else { - $preloadedLocations = New-CIPPDbRequest -TenantFilter $tenant -Type 'NamedLocations' $templateResult = New-CIPPCATemplate -TenantFilter $tenant -JSON $CheckExististing -preloadedLocations $preloadedLocations $CompareObj = ConvertFrom-Json -ErrorAction SilentlyContinue -InputObject $templateResult try { From 970454c9ec6797705441f9513acc7b6444a3f7d8 Mon Sep 17 00:00:00 2001 From: rvdwegen Date: Mon, 16 Feb 2026 14:45:20 +0100 Subject: [PATCH 49/97] Move helper functions outside of New-CIPPCAPolicy --- .../Public/Functions/Remove-EmptyArrays.ps1 | 50 +++++++++++++++++++ .../CIPPCore/Public/Functions/Test-IsGuid.ps1 | 23 +++++++++ Modules/CIPPCore/Public/New-CIPPCAPolicy.ps1 | 29 ++--------- .../CIPPCore/Public/New-CIPPCATemplate.ps1 | 9 +--- 4 files changed, 78 insertions(+), 33 deletions(-) create mode 100644 Modules/CIPPCore/Public/Functions/Remove-EmptyArrays.ps1 create mode 100644 Modules/CIPPCore/Public/Functions/Test-IsGuid.ps1 diff --git a/Modules/CIPPCore/Public/Functions/Remove-EmptyArrays.ps1 b/Modules/CIPPCore/Public/Functions/Remove-EmptyArrays.ps1 new file mode 100644 index 000000000000..85726be4607a --- /dev/null +++ b/Modules/CIPPCore/Public/Functions/Remove-EmptyArrays.ps1 @@ -0,0 +1,50 @@ +function Remove-EmptyArrays { + <# + .SYNOPSIS + Recursively removes empty arrays and null properties from objects + .DESCRIPTION + This function recursively traverses an object (Array, Hashtable, or PSCustomObject) and removes: + - Empty arrays + - Null properties + The function modifies the object in place. + .PARAMETER Object + The object to process (can be Array, Hashtable, or PSCustomObject) + .FUNCTIONALITY + Internal + .EXAMPLE + $obj = @{ items = @(); name = "test"; value = $null } + Remove-EmptyArrays -Object $obj + .EXAMPLE + $obj = [PSCustomObject]@{ items = @(); name = "test" } + Remove-EmptyArrays -Object $obj + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [object]$Object + ) + + if ($Object -is [Array]) { + foreach ($Item in $Object) { + Remove-EmptyArrays -Object $Item + } + } elseif ($Object -is [HashTable]) { + foreach ($Key in @($Object.get_Keys())) { + if ($Object[$Key] -is [Array] -and $Object[$Key].get_Count() -eq 0) { + $Object.Remove($Key) + } else { + Remove-EmptyArrays -Object $Object[$Key] + } + } + } elseif ($Object -is [PSCustomObject]) { + foreach ($Name in @($Object.PSObject.Properties.Name)) { + if ($Object.$Name -is [Array] -and $Object.$Name.get_Count() -eq 0) { + $Object.PSObject.Properties.Remove($Name) + } elseif ($null -eq $Object.$Name) { + $Object.PSObject.Properties.Remove($Name) + } else { + Remove-EmptyArrays -Object $Object.$Name + } + } + } +} diff --git a/Modules/CIPPCore/Public/Functions/Test-IsGuid.ps1 b/Modules/CIPPCore/Public/Functions/Test-IsGuid.ps1 new file mode 100644 index 000000000000..3beda62ec9cf --- /dev/null +++ b/Modules/CIPPCore/Public/Functions/Test-IsGuid.ps1 @@ -0,0 +1,23 @@ +function Test-IsGuid { + <# + .SYNOPSIS + Tests if a string is a valid GUID + .DESCRIPTION + This function checks if a string can be parsed as a valid GUID using .NET's Guid.TryParse method. + .PARAMETER String + The string to test for GUID format + .FUNCTIONALITY + Internal + .EXAMPLE + Test-IsGuid -String "123e4567-e89b-12d3-a456-426614174000" + .EXAMPLE + Test-IsGuid -String "not-a-guid" + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$String + ) + + return [guid]::TryParse($String, [ref][guid]::Empty) +} diff --git a/Modules/CIPPCore/Public/New-CIPPCAPolicy.ps1 b/Modules/CIPPCore/Public/New-CIPPCAPolicy.ps1 index 5abdc29c44a6..e97eb74500d3 100644 --- a/Modules/CIPPCore/Public/New-CIPPCAPolicy.ps1 +++ b/Modules/CIPPCore/Public/New-CIPPCAPolicy.ps1 @@ -14,36 +14,13 @@ function New-CIPPCAPolicy { $PreloadedCAPolicies = $null ) - function Remove-EmptyArrays ($Object) { - if ($Object -is [Array]) { - foreach ($Item in $Object) { Remove-EmptyArrays $Item } - } elseif ($Object -is [HashTable]) { - foreach ($Key in @($Object.get_Keys())) { - if ($Object[$Key] -is [Array] -and $Object[$Key].get_Count() -eq 0) { - $Object.Remove($Key) - } else { Remove-EmptyArrays $Object[$Key] } - } - } elseif ($Object -is [PSCustomObject]) { - foreach ($Name in @($Object.PSObject.Properties.Name)) { - if ($Object.$Name -is [Array] -and $Object.$Name.get_Count() -eq 0) { - $Object.PSObject.Properties.Remove($Name) - } elseif ($null -eq $Object.$Name) { - $Object.PSObject.Properties.Remove($Name) - } else { Remove-EmptyArrays $Object.$Name } - } - } - } - # Function to check if a string is a GUID - function Test-IsGuid($string) { - return [guid]::TryParse($string, [ref][guid]::Empty) - } # Helper function to replace group display names with GUIDs function Convert-GroupNameToId { param($TenantFilter, $groupNames, $CreateGroups, $GroupTemplates) $GroupIds = [System.Collections.Generic.List[string]]::new() $groupNames | ForEach-Object { - if (Test-IsGuid $_) { + if (Test-IsGuid -String $_) { Write-LogMessage -Headers $Headers -API $APIName -message "Already GUID, no need to replace: $_" -Sev 'Debug' $GroupIds.Add($_) # it's a GUID, so we keep it } else { @@ -89,7 +66,7 @@ function New-CIPPCAPolicy { $UserIds = [System.Collections.Generic.List[string]]::new() $userNames | ForEach-Object { - if (Test-IsGuid $_) { + if (Test-IsGuid -String $_) { Write-LogMessage -Headers $Headers -API $APIName -message "Already GUID, no need to replace: $_" -Sev 'Debug' $UserIds.Add($_) # it's a GUID, so we keep it } else { @@ -111,7 +88,7 @@ function New-CIPPCAPolicy { $displayName = ($RawJSON | ConvertFrom-Json).displayName $JSONobj = $RawJSON | ConvertFrom-Json | Select-Object * -ExcludeProperty ID, GUID, *time* - Remove-EmptyArrays $JSONobj + Remove-EmptyArrays -Object $JSONobj #Remove context as it does not belong in the payload. try { $JSONobj.grantControls.PSObject.Properties.Remove('authenticationStrength@odata.context') diff --git a/Modules/CIPPCore/Public/New-CIPPCATemplate.ps1 b/Modules/CIPPCore/Public/New-CIPPCATemplate.ps1 index d3b4fd97ad35..6103d5ad945c 100644 --- a/Modules/CIPPCore/Public/New-CIPPCATemplate.ps1 +++ b/Modules/CIPPCore/Public/New-CIPPCATemplate.ps1 @@ -18,11 +18,6 @@ function New-CIPPCATemplate { Write-Information "Processing CA Template for tenant $TenantFilter" Write-Information ($JSON | ConvertTo-Json -Depth 10) - # Function to check if a string is a GUID - function Test-IsGuid($string) { - return [guid]::tryparse($string, [ref][guid]::Empty) - } - if ($preloadedUsers) { $users = $preloadedUsers } else { @@ -94,7 +89,7 @@ function New-CIPPCATemplate { if ($isPSCustomObject -and $null -ne $JSON.conditions.users.includeGroups) { $JSON.conditions.users.includeGroups = @($JSON.conditions.users.includeGroups | ForEach-Object { $originalID = $_ - if ($_ -in 'All', 'None', 'GuestOrExternalUsers' -or -not (Test-IsGuid $_)) { return $_ } + if ($_ -in 'All', 'None', 'GuestOrExternalUsers' -or -not (Test-IsGuid -String $_)) { return $_ } $match = $groups | Where-Object { $_.id -eq $originalID } if ($match) { $match.displayName } else { $originalID } }) @@ -102,7 +97,7 @@ function New-CIPPCATemplate { if ($isPSCustomObject -and $null -ne $JSON.conditions.users.excludeGroups) { $JSON.conditions.users.excludeGroups = @($JSON.conditions.users.excludeGroups | ForEach-Object { $originalID = $_ - if ($_ -in 'All', 'None', 'GuestOrExternalUsers' -or -not (Test-IsGuid $_)) { return $_ } + if ($_ -in 'All', 'None', 'GuestOrExternalUsers' -or -not (Test-IsGuid -String $_)) { return $_ } $match = $groups | Where-Object { $_.id -eq $originalID } if ($match) { $match.displayName } else { $originalID } }) From 42608852ca931135b8268ab8a4810b2bec409ff5 Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Mon, 16 Feb 2026 16:50:36 +0100 Subject: [PATCH 50/97] updated CATemplates --- .../Standards/Push-CIPPStandardsList.ps1 | 32 ++++ Modules/CIPPCore/Public/New-CIPPCAPolicy.ps1 | 22 ++- ...-CIPPStandardConditionalAccessTemplate.ps1 | 142 +++++++++--------- 3 files changed, 117 insertions(+), 79 deletions(-) diff --git a/Modules/CIPPCore/Public/Entrypoints/Activity Triggers/Standards/Push-CIPPStandardsList.ps1 b/Modules/CIPPCore/Public/Entrypoints/Activity Triggers/Standards/Push-CIPPStandardsList.ps1 index 01acec8da950..c0af2f9c5467 100644 --- a/Modules/CIPPCore/Public/Entrypoints/Activity Triggers/Standards/Push-CIPPStandardsList.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/Activity Triggers/Standards/Push-CIPPStandardsList.ps1 @@ -180,6 +180,38 @@ function Push-CIPPStandardsList { } } + $CAStandardFound = ($ComputedStandards.Keys.Where({ $_ -like '*ConditionalAccessTemplate*' }, 'First').Count -gt 0) + if ($CAStandardFound) { + $TestResult = Test-CIPPStandardLicense -StandardName 'ConditionalAccessTemplate_general' -TenantFilter $TenantFilter -RequiredCapabilities @('AAD_PREMIUM', 'AAD_PREMIUM_P2') + if (-not $TestResult) { + $CAKeys = @($ComputedStandards.Keys | Where-Object { $_ -like '*ConditionalAccessTemplate*' }) + $BulkFields = [System.Collections.Generic.List[object]]::new() + foreach ($Key in $CAKeys) { + $TemplateKey = ($Key -split '\|', 2)[1] + if ($TemplateKey) { + $BulkFields.Add([PSCustomObject]@{ + FieldName = "standards.ConditionalAccessTemplate.$TemplateKey" + FieldValue = 'This tenant does not have the required license for this standard.' + }) + } + [void]$ComputedStandards.Remove($Key) + } + if ($BulkFields.Count -gt 0) { + Set-CIPPStandardsCompareField -TenantFilter $TenantFilter -BulkFields $BulkFields + } + + Write-Information "Removed ConditionalAccessTemplate standards for $TenantFilter - missing required license" + } else { + # License valid - update CIPPDB cache with latest CA information before we run so that standards have the most up to date info + try { + Write-Information "Updating CIPPDB cache for Conditional Access policies for $TenantFilter" + Set-CIPPDBCacheConditionalAccessPolicies -TenantFilter $TenantFilter + } catch { + Write-Warning "Failed to update CA cache for $TenantFilter : $($_.Exception.Message)" + } + } + } + Write-Host "Returning $($ComputedStandards.Count) standards for tenant $TenantFilter after filtering." # Return filtered standards $FilteredStandards = $ComputedStandards.Values | ForEach-Object { diff --git a/Modules/CIPPCore/Public/New-CIPPCAPolicy.ps1 b/Modules/CIPPCore/Public/New-CIPPCAPolicy.ps1 index 5abdc29c44a6..bcea50fb716b 100644 --- a/Modules/CIPPCore/Public/New-CIPPCAPolicy.ps1 +++ b/Modules/CIPPCore/Public/New-CIPPCAPolicy.ps1 @@ -11,7 +11,8 @@ function New-CIPPCAPolicy { $CreateGroups = $false, $APIName = 'Create CA Policy', $Headers, - $PreloadedCAPolicies = $null + $PreloadedCAPolicies = $null, + $PreloadedLocations = $null ) function Remove-EmptyArrays ($Object) { @@ -148,13 +149,18 @@ function New-CIPPCAPolicy { # Get named locations once if needed $AllNamedLocations = $null if ($JSONobj.LocationInfo) { - try { - Write-Information 'Fetching all named locations...' - $AllNamedLocations = New-GraphGETRequest -uri 'https://graph.microsoft.com/beta/identity/conditionalAccess/namedLocations?$top=999' -tenantid $TenantFilter -asApp $true - } catch { - $ErrorMessage = Get-CippException -Exception $_ - Write-Information "Error fetching named locations: $($ErrorMessage | ConvertTo-Json -Depth 10 -Compress)" - throw "Failed to fetch named locations: $($ErrorMessage.NormalizedError)" + if ($PreloadedLocations) { + Write-Information 'Using preloaded named locations' + $AllNamedLocations = $PreloadedLocations + } else { + try { + Write-Information 'Fetching all named locations...' + $AllNamedLocations = New-GraphGETRequest -uri 'https://graph.microsoft.com/beta/identity/conditionalAccess/namedLocations?$top=999' -tenantid $TenantFilter -asApp $true + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-Information "Error fetching named locations: $($ErrorMessage | ConvertTo-Json -Depth 10 -Compress)" + throw "Failed to fetch named locations: $($ErrorMessage.NormalizedError)" + } } } diff --git a/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardConditionalAccessTemplate.ps1 b/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardConditionalAccessTemplate.ps1 index c1455d3ba37b..85261df84091 100644 --- a/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardConditionalAccessTemplate.ps1 +++ b/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardConditionalAccessTemplate.ps1 @@ -33,20 +33,29 @@ function Invoke-CIPPStandardConditionalAccessTemplate { https://docs.cipp.app/user-documentation/tenant/standards/list-standards #> param($Tenant, $Settings) - ##$Rerun -Type Standard -Tenant $Tenant -Settings $Settings 'ConditionalAccess' + + #Checking if the DB has been updated in the last 3h, if not, run an update before we run the standard, as CA policies are critical and we want to make sure we have the latest state before making changes or comparisons. + $LastDBUpdate = Get-CIPPDbItem -TenantFilter $Tenant -Type 'ConditionalAccessPolicies' -CountsOnly + if ($LastDBUpdate -eq $null -or ($LastDBUpdate.Timestamp -lt (Get-Date).AddHours(-3) -or $LastDBUpdate.DataCount -eq 0)) { + Write-Information "DB last updated at $($LastDBUpdate.Timestamp). Updating DB before running standard, this is probably a manual run." + Set-CIPPDBCacheConditionalAccessPolicies -TenantFilter $Tenant + } else { + Write-Information "DB last updated at $($LastDBUpdate.Timestamp). No need to update before running standard." + } + + $TestResult = Test-CIPPStandardLicense -StandardName 'ConditionalAccessTemplate_general' -TenantFilter $Tenant -RequiredCapabilities @('AAD_PREMIUM', 'AAD_PREMIUM_P2') if ($TestResult -eq $false) { - #writing to each item that the license is not present. - foreach ($Template in $settings.TemplateList) { - Set-CIPPStandardsCompareField -FieldName "standards.ConditionalAccessTemplate.$($Template.value)" -FieldValue 'This tenant does not have the required license for this standard.' -Tenant $Tenant - } + Set-CIPPStandardsCompareField -FieldName "standards.ConditionalAccessTemplate.$($Settings.TemplateList.value)" -FieldValue 'This tenant does not have the required license for this standard.' -Tenant $Tenant return $true } #we're done. $Table = Get-CippTable -tablename 'templates' try { - $AllCAPolicies = New-GraphGetRequest -Uri 'https://graph.microsoft.com/beta/identity/conditionalAccess/policies?$top=999' -tenantid $Tenant -asApp $true + #Get from DB, as we just downloaded the latest before the standard runs. + $AllCAPolicies = New-CIPPDbRequest -TenantFilter $tenant -Type 'ConditionalAccessPolicies' + $PreloadedLocations = New-CIPPDbRequest -TenantFilter $tenant -Type 'NamedLocations' } catch { $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message Write-LogMessage -API 'Standards' -Tenant $Tenant -Message "Could not get the ConditionalAccessTemplate state for $Tenant. Error: $ErrorMessage" -Sev Error @@ -54,80 +63,71 @@ function Invoke-CIPPStandardConditionalAccessTemplate { } if ($Settings.remediate -eq $true) { - foreach ($Setting in $Settings) { - try { - $Filter = "PartitionKey eq 'CATemplate' and RowKey eq '$($Setting.TemplateList.value)'" - $JSONObj = (Get-CippAzDataTableEntity @Table -Filter $Filter).JSON - $Policy = $JSONObj | ConvertFrom-Json - if ($Policy.conditions.userRiskLevels.count -gt 0 -or $Policy.conditions.signInRiskLevels.count -gt 0) { - $TestP2 = Test-CIPPStandardLicense -StandardName 'ConditionalAccessTemplate_p2' -TenantFilter $Tenant -RequiredCapabilities @('AAD_PREMIUM_P2') -SkipLog - if (!$TestP2) { - Write-Information "Skipping policy $($Policy.displayName) as it requires AAD Premium P2 license." - Set-CIPPStandardsCompareField -FieldName "standards.ConditionalAccessTemplate.$($Setting.value)" -FieldValue "Policy $($Policy.displayName) requires AAD Premium P2 license." -Tenant $Tenant - continue - } - } - $NewCAPolicy = @{ - replacePattern = 'displayName' - TenantFilter = $Tenant - state = $Setting.state - RawJSON = $JSONObj - Overwrite = $true - APIName = 'Standards' - Headers = $Request.Headers - DisableSD = $Setting.DisableSD - CreateGroups = $Setting.CreateGroups ?? $false - PreloadedCAPolicies = $AllCAPolicies + try { + $Filter = "PartitionKey eq 'CATemplate' and RowKey eq '$($Settings.TemplateList.value)'" + $JSONObj = (Get-CippAzDataTableEntity @Table -Filter $Filter).JSON + $Policy = $JSONObj | ConvertFrom-Json + if ($Policy.conditions.userRiskLevels.count -gt 0 -or $Policy.conditions.signInRiskLevels.count -gt 0) { + $TestP2 = Test-CIPPStandardLicense -StandardName 'ConditionalAccessTemplate_p2' -TenantFilter $Tenant -RequiredCapabilities @('AAD_PREMIUM_P2') -SkipLog + if (!$TestP2) { + Write-Information "Skipping policy $($Policy.displayName) as it requires AAD Premium P2 license." + Set-CIPPStandardsCompareField -FieldName "standards.ConditionalAccessTemplate.$($Settings.TemplateList.value)" -FieldValue "Policy $($Policy.displayName) requires AAD Premium P2 license." -Tenant $Tenant + return $true } - - $null = New-CIPPCAPolicy @NewCAPolicy - } catch { - $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message - Write-LogMessage -API 'Standards' -tenant $tenant -message "Failed to create or update conditional access rule $($JSONObj.displayName). Error: $ErrorMessage" -sev 'Error' } + $NewCAPolicy = @{ + replacePattern = 'displayName' + TenantFilter = $Tenant + state = $Settings.state + RawJSON = $JSONObj + Overwrite = $true + APIName = 'Standards' + Headers = $Request.Headers + DisableSD = $Settings.DisableSD + CreateGroups = $Settings.CreateGroups ?? $false + PreloadedCAPolicies = $AllCAPolicies + PreloadedLocations = $PreloadedLocations + } + + $null = New-CIPPCAPolicy @NewCAPolicy + } catch { + $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message + Write-LogMessage -API 'Standards' -tenant $tenant -message "Failed to create or update conditional access rule $($JSONObj.displayName). Error: $ErrorMessage" -sev 'Error' } } if ($Settings.report -eq $true -or $Settings.remediate -eq $true) { - $Filter = "PartitionKey eq 'CATemplate'" - $Policies = (Get-CippAzDataTableEntity @Table -Filter $Filter | Where-Object RowKey -In $Settings.TemplateList.value).JSON | ConvertFrom-Json -Depth 10 - $AllCAPolicies = New-GraphGetRequest -Uri 'https://graph.microsoft.com/beta/identity/conditionalAccess/policies?$top=999' -tenantid $Tenant -asApp $true - - # Preload named locations once outside the loop to avoid duplicate database queries - $preloadedLocations = New-CIPPDbRequest -TenantFilter $tenant -Type 'NamedLocations' + $Filter = "PartitionKey eq 'CATemplate' and RowKey eq '$($Settings.TemplateList.value)'" + $Policy = (Get-CippAzDataTableEntity @Table -Filter $Filter).JSON | ConvertFrom-Json -Depth 10 - #check if all groups.displayName are in the existingGroups, if not $fieldvalue should contain all missing groups, else it should be true. - $MissingPolicies = foreach ($Setting in $Settings.TemplateList) { - $policy = $Policies | Where-Object { $_.displayName -eq $Setting.label } - $CheckExististing = $AllCAPolicies | Where-Object -Property displayName -EQ $Setting.label - if (!$CheckExististing) { - if ($Setting.conditions.userRiskLevels.Count -gt 0 -or $Setting.conditions.signInRiskLevels.Count -gt 0) { - $TestP2 = Test-CIPPStandardLicense -StandardName 'ConditionalAccessTemplate_p2' -TenantFilter $Tenant -RequiredCapabilities @('AAD_PREMIUM_P2') -SkipLog - if (!$TestP2) { - Set-CIPPStandardsCompareField -FieldName "standards.ConditionalAccessTemplate.$($Setting.value)" -FieldValue "Policy $($Setting.label) requires AAD Premium P2 license." -Tenant $Tenant - } else { - Set-CIPPStandardsCompareField -FieldName "standards.ConditionalAccessTemplate.$($Setting.value)" -FieldValue "Policy $($Setting.label) is missing from this tenant." -Tenant $Tenant - } + $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) { + $TestP2 = Test-CIPPStandardLicense -StandardName 'ConditionalAccessTemplate_p2' -TenantFilter $Tenant -RequiredCapabilities @('AAD_PREMIUM_P2') -SkipLog + if (!$TestP2) { + Set-CIPPStandardsCompareField -FieldName "standards.ConditionalAccessTemplate.$($Settings.TemplateList.value)" -FieldValue "Policy $($Settings.TemplateList.label) requires AAD Premium P2 license." -Tenant $Tenant } else { - Set-CIPPStandardsCompareField -FieldName "standards.ConditionalAccessTemplate.$($Setting.value)" -FieldValue "Policy $($Setting.label) is missing from this tenant." -Tenant $Tenant + Set-CIPPStandardsCompareField -FieldName "standards.ConditionalAccessTemplate.$($Settings.TemplateList.value)" -FieldValue "Policy $($Settings.TemplateList.label) is missing from this tenant." -Tenant $Tenant } } else { - $templateResult = New-CIPPCATemplate -TenantFilter $tenant -JSON $CheckExististing -preloadedLocations $preloadedLocations - $CompareObj = ConvertFrom-Json -ErrorAction SilentlyContinue -InputObject $templateResult - try { - $Compare = Compare-CIPPIntuneObject -ReferenceObject $policy -DifferenceObject $CompareObj -CompareType 'ca' - } catch { - Write-LogMessage -API 'Standards' -tenant $Tenant -message "Error comparing CA policy: $($_.Exception.Message)" -sev Error - Set-CIPPStandardsCompareField -FieldName "standards.ConditionalAccessTemplate.$($Setting.value)" -FieldValue "Error comparing policy: $($_.Exception.Message)" -Tenant $Tenant - continue - } - if (!$Compare) { - Set-CIPPStandardsCompareField -FieldName "standards.ConditionalAccessTemplate.$($Setting.value)" -FieldValue $true -Tenant $Tenant - } else { - #this can still be prettified but is for later. - $ExpectedValue = @{ 'Differences' = @() } - $CurrentValue = @{ 'Differences' = $Compare } - Set-CIPPStandardsCompareField -FieldName "standards.ConditionalAccessTemplate.$($Setting.value)" -CurrentValue $CurrentValue -ExpectedValue $ExpectedValue -Tenant $Tenant - } + Set-CIPPStandardsCompareField -FieldName "standards.ConditionalAccessTemplate.$($Settings.TemplateList.value)" -FieldValue "Policy $($Settings.TemplateList.label) is missing from this tenant." -Tenant $Tenant + } + } else { + $templateResult = New-CIPPCATemplate -TenantFilter $tenant -JSON $CheckExististing -preloadedLocations $preloadedLocations + $CompareObj = ConvertFrom-Json -ErrorAction SilentlyContinue -InputObject $templateResult + try { + $Compare = Compare-CIPPIntuneObject -ReferenceObject $Policy -DifferenceObject $CompareObj -CompareType 'ca' + } catch { + Write-LogMessage -API 'Standards' -tenant $Tenant -message "Error comparing CA policy: $($_.Exception.Message)" -sev Error + Set-CIPPStandardsCompareField -FieldName "standards.ConditionalAccessTemplate.$($Settings.TemplateList.value)" -FieldValue "Error comparing policy: $($_.Exception.Message)" -Tenant $Tenant + return + } + if (!$Compare) { + Set-CIPPStandardsCompareField -FieldName "standards.ConditionalAccessTemplate.$($Settings.TemplateList.value)" -FieldValue $true -Tenant $Tenant + } else { + #this can still be prettified but is for later. + $ExpectedValue = @{ 'Differences' = @() } + $CurrentValue = @{ 'Differences' = $Compare } + Set-CIPPStandardsCompareField -FieldName "standards.ConditionalAccessTemplate.$($Settings.TemplateList.value)" -CurrentValue $CurrentValue -ExpectedValue $ExpectedValue -Tenant $Tenant } } } From 44d6eb8f9db4246703138a60062cc19bbf2cf6b1 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Mon, 16 Feb 2026 14:52:05 -0500 Subject: [PATCH 51/97] Refine mailbox matching and contact filter Remove displayName from the local usernames array comparison to avoid unintended matches. When building the Graph contacts filter, keep displayName as-is but strip spaces from mailNickname and properly escape single quotes so mailNickname queries match Graph-stored values. This adjusts matching behavior for more accurate mailbox/contact lookups. --- .../Administration/Users/Invoke-ListUserMailboxDetails.ps1 | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListUserMailboxDetails.ps1 b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListUserMailboxDetails.ps1 index 636639f32296..a0336ea3f1f9 100644 --- a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListUserMailboxDetails.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListUserMailboxDetails.ps1 @@ -155,7 +155,6 @@ function Invoke-ListUserMailboxDetails { # First try users array $matchedUser = $usernames | Where-Object { $_.id -eq $rawAddress -or - $_.displayName -eq $rawAddress -or $_.mailNickname -eq $rawAddress } @@ -166,7 +165,8 @@ function Invoke-ListUserMailboxDetails { try { # Escape single quotes in the filter value $escapedAddress = $rawAddress -replace "'", "''" - $filterQuery = "displayName eq '$escapedAddress' or mailNickname eq '$escapedAddress'" + $escapedNickname = $rawAddress -replace "'", "''" -replace ' ', '' + $filterQuery = "displayName eq '$escapedAddress' or mailNickname eq '$escapedNickname'" $contactUri = "https://graph.microsoft.com/beta/contacts?`$filter=$filterQuery&`$select=displayName,mail,mailNickname" $matchedContacts = New-GraphGetRequest -tenantid $TenantFilter -uri $contactUri From 9aa02eabb3e364737840328f280b552e188cb88c Mon Sep 17 00:00:00 2001 From: John Duprey Date: Mon, 16 Feb 2026 16:21:08 -0500 Subject: [PATCH 52/97] Guard against null grantControls before removal Add a null-check for $JSONobj.grantControls before removing the 'authenticationStrength@odata.context' property to prevent errors when grantControls is absent. Also adjust indentation/formatting of a Where-Object call for readability; other payload-cleanup logic is unchanged. --- Modules/CIPPCore/Public/New-CIPPCAPolicy.ps1 | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Modules/CIPPCore/Public/New-CIPPCAPolicy.ps1 b/Modules/CIPPCore/Public/New-CIPPCAPolicy.ps1 index 8c9f26a57097..1196ee1f7488 100644 --- a/Modules/CIPPCore/Public/New-CIPPCAPolicy.ps1 +++ b/Modules/CIPPCore/Public/New-CIPPCAPolicy.ps1 @@ -92,7 +92,9 @@ function New-CIPPCAPolicy { Remove-EmptyArrays -Object $JSONobj #Remove context as it does not belong in the payload. try { - $JSONobj.grantControls.PSObject.Properties.Remove('authenticationStrength@odata.context') + if ($JSONobj.grantControls) { + $JSONobj.grantControls.PSObject.Properties.Remove('authenticationStrength@odata.context') + } $JSONobj.templateId ? $JSONobj.PSObject.Properties.Remove('templateId') : $null if ($JSONobj.conditions.users.excludeGuestsOrExternalUsers.externalTenants.Members) { $JSONobj.conditions.users.excludeGuestsOrExternalUsers.externalTenants.PSObject.Properties.Remove('@odata.context') @@ -426,7 +428,7 @@ function New-CIPPCAPolicy { # Preserve any exclusion groups named "Vacation Exclusion - " from existing policy try { $ExistingVacationGroup = New-GraphGETRequest -uri "https://graph.microsoft.com/beta/groups?`$filter=startsWith(displayName,'Vacation Exclusion')&`$select=id,displayName&`$top=999&`$count=true" -ComplexFilter -tenantid $TenantFilter -asApp $true | - Where-Object { $CheckExisting.conditions.users.excludeGroups -contains $_.id } + Where-Object { $CheckExisting.conditions.users.excludeGroups -contains $_.id } if ($ExistingVacationGroup) { if (-not ($JSONobj.conditions.users.PSObject.Properties.Name -contains 'excludeGroups')) { $JSONobj.conditions.users | Add-Member -NotePropertyName 'excludeGroups' -NotePropertyValue @() -Force From 8e7cccb4847b4879d2d5103e8f6592c9fa876e17 Mon Sep 17 00:00:00 2001 From: James Tarran Date: Tue, 17 Feb 2026 09:49:35 +0000 Subject: [PATCH 53/97] Update Invoke-CIPPStandardPasswordExpireDisabled.ps1 Filter out subdomains from password expiration policy checks --- ...voke-CIPPStandardPasswordExpireDisabled.ps1 | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardPasswordExpireDisabled.ps1 b/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardPasswordExpireDisabled.ps1 index 2caaa9e00f7f..fee656c81a6a 100644 --- a/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardPasswordExpireDisabled.ps1 +++ b/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardPasswordExpireDisabled.ps1 @@ -43,8 +43,24 @@ function Invoke-CIPPStandardPasswordExpireDisabled { return } + $DomainIdSet = [System.Collections.Generic.HashSet[string]]::new([string[]]$GraphRequest.id) + $SubDomains = [System.Collections.Generic.HashSet[string]]::new() + foreach ($id in $DomainIdSet) { + $dot = $id.IndexOf('.') + while ($dot -gt 0) { + if ($DomainIdSet.Contains($id.Substring($dot + 1))) { + [void]$SubDomains.Add($id) + break + } + $dot = $id.IndexOf('.', $dot + 1) + } + } $DomainsWithoutPassExpire = $GraphRequest | - Where-Object { $_.isVerified -eq $true -and $_.passwordValidityPeriodInDays -ne 2147483647 } + Where-Object { + $_.isVerified -eq $true -and + $_.passwordValidityPeriodInDays -ne 2147483647 -and + -not $SubDomains.Contains($_.id) + } if ($Settings.remediate -eq $true) { From 11e8a489c1aed78875ab71ce184a8b1b7bb98558 Mon Sep 17 00:00:00 2001 From: James Tarran Date: Tue, 17 Feb 2026 11:04:32 +0000 Subject: [PATCH 54/97] Update Invoke-CIPPStandardPasswordExpireDisabled.ps1 Remove subdomains from password expiry check --- ...oke-CIPPStandardPasswordExpireDisabled.ps1 | 25 +++++++------------ 1 file changed, 9 insertions(+), 16 deletions(-) diff --git a/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardPasswordExpireDisabled.ps1 b/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardPasswordExpireDisabled.ps1 index fee656c81a6a..56a55dad0a4d 100644 --- a/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardPasswordExpireDisabled.ps1 +++ b/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardPasswordExpireDisabled.ps1 @@ -43,23 +43,16 @@ function Invoke-CIPPStandardPasswordExpireDisabled { return } - $DomainIdSet = [System.Collections.Generic.HashSet[string]]::new([string[]]$GraphRequest.id) - $SubDomains = [System.Collections.Generic.HashSet[string]]::new() - foreach ($id in $DomainIdSet) { - $dot = $id.IndexOf('.') - while ($dot -gt 0) { - if ($DomainIdSet.Contains($id.Substring($dot + 1))) { - [void]$SubDomains.Add($id) - break - } - $dot = $id.IndexOf('.', $dot + 1) - } - } + $DomainIds = @($GraphRequest.id) $DomainsWithoutPassExpire = $GraphRequest | - Where-Object { - $_.isVerified -eq $true -and - $_.passwordValidityPeriodInDays -ne 2147483647 -and - -not $SubDomains.Contains($_.id) + Where-Object { + $id = $_.id + $_.isVerified -eq $true ` + -and $_.passwordValidityPeriodInDays -ne 2147483647 ` + -and -not ($DomainIds | Where-Object { + $id -ne $_ ` + -and $id.EndsWith(".$_") + }) } if ($Settings.remediate -eq $true) { From 4f3c6101d2b71f9b8f2a98461e03fbfc73c09c36 Mon Sep 17 00:00:00 2001 From: James Tarran Date: Tue, 17 Feb 2026 11:15:38 +0000 Subject: [PATCH 55/97] Refactor domain ID handling for password expiration check Exclude subdomains from password expiry check --- ...nvoke-CIPPStandardPasswordExpireDisabled.ps1 | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardPasswordExpireDisabled.ps1 b/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardPasswordExpireDisabled.ps1 index 56a55dad0a4d..a84376bb6265 100644 --- a/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardPasswordExpireDisabled.ps1 +++ b/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardPasswordExpireDisabled.ps1 @@ -43,16 +43,19 @@ function Invoke-CIPPStandardPasswordExpireDisabled { return } - $DomainIds = @($GraphRequest.id) + $DomainIds = [System.Collections.Generic.HashSet[string]]::new([string[]]$GraphRequest.id) + $SubDomains = foreach ($id in $DomainIds) { + foreach ($parent in $DomainIds) { + if ($id -ne $parent -and $id.EndsWith(".$parent")) { + $id; break + } + } + } $DomainsWithoutPassExpire = $GraphRequest | Where-Object { - $id = $_.id $_.isVerified -eq $true ` - -and $_.passwordValidityPeriodInDays -ne 2147483647 ` - -and -not ($DomainIds | Where-Object { - $id -ne $_ ` - -and $id.EndsWith(".$_") - }) + -and $_.passwordValidityPeriodInDays -ne 2147483647 ` + -and $_.id -notin $SubDomains } if ($Settings.remediate -eq $true) { From 79e9eb64132a2ce72500e32d4821f963f40012ec Mon Sep 17 00:00:00 2001 From: James Tarran Date: Tue, 17 Feb 2026 11:18:22 +0000 Subject: [PATCH 56/97] Change DomainIds initialization to array format --- .../Standards/Invoke-CIPPStandardPasswordExpireDisabled.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardPasswordExpireDisabled.ps1 b/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardPasswordExpireDisabled.ps1 index a84376bb6265..f4fcbd90c718 100644 --- a/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardPasswordExpireDisabled.ps1 +++ b/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardPasswordExpireDisabled.ps1 @@ -43,7 +43,7 @@ function Invoke-CIPPStandardPasswordExpireDisabled { return } - $DomainIds = [System.Collections.Generic.HashSet[string]]::new([string[]]$GraphRequest.id) + $DomainIds = @($GraphRequest.id) $SubDomains = foreach ($id in $DomainIds) { foreach ($parent in $DomainIds) { if ($id -ne $parent -and $id.EndsWith(".$parent")) { From c77e8b8f8630b9ffd5725e676ddd636f594b6a35 Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Tue, 17 Feb 2026 13:38:34 +0100 Subject: [PATCH 57/97] remove backtics --- .../Invoke-CIPPStandardPasswordExpireDisabled.ps1 | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardPasswordExpireDisabled.ps1 b/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardPasswordExpireDisabled.ps1 index f4fcbd90c718..fcf52e7bf4b8 100644 --- a/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardPasswordExpireDisabled.ps1 +++ b/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardPasswordExpireDisabled.ps1 @@ -46,17 +46,12 @@ function Invoke-CIPPStandardPasswordExpireDisabled { $DomainIds = @($GraphRequest.id) $SubDomains = foreach ($id in $DomainIds) { foreach ($parent in $DomainIds) { - if ($id -ne $parent -and $id.EndsWith(".$parent")) { - $id; break + if ($id -ne $parent -and $id.EndsWith(".$parent")) { + $id; break } } } - $DomainsWithoutPassExpire = $GraphRequest | - Where-Object { - $_.isVerified -eq $true ` - -and $_.passwordValidityPeriodInDays -ne 2147483647 ` - -and $_.id -notin $SubDomains - } + $DomainsWithoutPassExpire = $GraphRequest | Where-Object { $_.isVerified -eq $true -and $_.passwordValidityPeriodInDays -ne 2147483647 -and $_.id -notin $SubDomains } if ($Settings.remediate -eq $true) { From 0b9ea4c48e4c64be326cef667ca09689f090ee0f Mon Sep 17 00:00:00 2001 From: John Duprey Date: Tue, 17 Feb 2026 10:06:04 -0500 Subject: [PATCH 58/97] add OFFICE_BUSINESS license sku for standards targeting branding --- .../CIPPCore/Public/Standards/Invoke-CIPPStandardBranding.ps1 | 2 +- .../Public/Standards/Invoke-CIPPStandardPhishProtection.ps1 | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardBranding.ps1 b/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardBranding.ps1 index a0efe5f4ea5b..d68230294b11 100644 --- a/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardBranding.ps1 +++ b/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardBranding.ps1 @@ -37,7 +37,7 @@ function Invoke-CIPPStandardBranding { param($Tenant, $Settings) - $TestResult = Test-CIPPStandardLicense -StandardName 'Branding' -TenantFilter $Tenant -RequiredCapabilities @('AAD_PREMIUM', 'AAD_PREMIUM_P2') + $TestResult = Test-CIPPStandardLicense -StandardName 'Branding' -TenantFilter $Tenant -RequiredCapabilities @('AAD_PREMIUM', 'AAD_PREMIUM_P2', 'OFFICE_BUSINESS') if ($TestResult -eq $false) { return $true diff --git a/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardPhishProtection.ps1 b/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardPhishProtection.ps1 index 8f79c8d5a850..0f3ca5cc94e4 100644 --- a/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardPhishProtection.ps1 +++ b/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardPhishProtection.ps1 @@ -34,13 +34,13 @@ function Invoke-CIPPStandardPhishProtection { param($Tenant, $Settings) - $TestResult = Test-CIPPStandardLicense -StandardName 'PhishProtection' -TenantFilter $Tenant -RequiredCapabilities @('AAD_PREMIUM', 'AAD_PREMIUM_P2') + $TestResult = Test-CIPPStandardLicense -StandardName 'PhishProtection' -TenantFilter $Tenant -RequiredCapabilities @('AAD_PREMIUM', 'AAD_PREMIUM_P2', 'OFFICE_BUSINESS') if ($TestResult -eq $false) { return $true } #we're done. - $TenantId = Get-Tenants | Where-Object -Property defaultDomainName -EQ $tenant + $TenantId = Get-Tenants | Where-Object -Property defaultDomainName -EQ $Tenant $Table = Get-CIPPTable -TableName Config $CippConfig = (Get-CIPPAzDataTableEntity @Table) From 2ff0251ac99ff912e31a477a073215789a384ee1 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Tue, 17 Feb 2026 13:10:32 -0500 Subject: [PATCH 59/97] Improve GUID extraction for template lookups Update Get-CIPPDrift.ps1 to more robustly extract GUIDs from StandardName for Intune and Conditional Access templates by splitting the string and selecting the element that matches a GUID regex. Use the found GUID to match templates (using -match), add verbose logs when no GUID is present, and warnings when a template isn't found. This makes template resolution more reliable for names like standards.IntuneTemplate.{GUID}.IntuneTemplate.json and similar CA template names. --- Modules/CIPPCore/Public/Get-CIPPDrift.ps1 | 48 +++++++++++++++-------- 1 file changed, 32 insertions(+), 16 deletions(-) diff --git a/Modules/CIPPCore/Public/Get-CIPPDrift.ps1 b/Modules/CIPPCore/Public/Get-CIPPDrift.ps1 index f6cb38f81a04..2396a749d20e 100644 --- a/Modules/CIPPCore/Public/Get-CIPPDrift.ps1 +++ b/Modules/CIPPCore/Public/Get-CIPPDrift.ps1 @@ -104,28 +104,44 @@ function Get-CIPPDrift { $standardDescription = $null #if the $ComparisonItem.StandardName contains *IntuneTemplate*, then it's an Intune policy deviation, and we need to grab the correct displayname from the template table if ($ComparisonItem.StandardName -like '*IntuneTemplate*') { - $CompareGuid = $ComparisonItem.StandardName.Split('.') | Select-Object -Last 1 - Write-Verbose "Extracted Intune GUID: $CompareGuid from $($ComparisonItem.StandardName)" - $Template = $AllIntuneTemplates | Where-Object { $_.GUID -eq "$CompareGuid" } - if ($Template) { - $displayName = $Template.displayName - $standardDescription = $Template.description - Write-Verbose "Found Intune template: $displayName" + # Extract GUID from format like: standards.IntuneTemplate.{GUID}.IntuneTemplate.json + # Split by '.' and find the element that looks like a GUID (contains hyphens and is 36 chars) + $Parts = $ComparisonItem.StandardName.Split('.') + $CompareGuid = $Parts | Where-Object { $_ -match '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' } | Select-Object -First 1 + + if ($CompareGuid) { + Write-Verbose "Extracted Intune GUID: $CompareGuid from $($ComparisonItem.StandardName)" + $Template = $AllIntuneTemplates | Where-Object { $_.GUID -match "$CompareGuid" } + if ($Template) { + $displayName = $Template.displayName + $standardDescription = $Template.description + Write-Verbose "Found Intune template: $displayName" + } else { + Write-Warning "Intune template not found for GUID: $CompareGuid" + } } else { - Write-Warning "Intune template not found for GUID: $CompareGuid" + Write-Verbose "No valid GUID found in: $($ComparisonItem.StandardName)" } } # Handle Conditional Access templates if ($ComparisonItem.StandardName -like '*ConditionalAccessTemplate*') { - $CompareGuid = $ComparisonItem.StandardName.Split('.') | Select-Object -Last 1 - Write-Verbose "Extracted CA GUID: $CompareGuid from $($ComparisonItem.StandardName)" - $Template = $AllCATemplates | Where-Object { $_.GUID -eq "$CompareGuid" } - if ($Template) { - $displayName = $Template.displayName - $standardDescription = $Template.description - Write-Verbose "Found CA template: $displayName" + # Extract GUID from format like: standards.ConditionalAccessTemplate.{GUID}.CATemplate.json + # Split by '.' and find the element that looks like a GUID (contains hyphens and is 36 chars) + $Parts = $ComparisonItem.StandardName.Split('.') + $CompareGuid = $Parts | Where-Object { $_ -match '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' } | Select-Object -First 1 + + if ($CompareGuid) { + Write-Verbose "Extracted CA GUID: $CompareGuid from $($ComparisonItem.StandardName)" + $Template = $AllCATemplates | Where-Object { $_.GUID -match "$CompareGuid" } + if ($Template) { + $displayName = $Template.displayName + $standardDescription = $Template.description + Write-Verbose "Found CA template: $displayName" + } else { + Write-Warning "CA template not found for GUID: $CompareGuid" + } } else { - Write-Warning "CA template not found for GUID: $CompareGuid" + Write-Verbose "No valid GUID found in: $($ComparisonItem.StandardName)" } } $reason = if ($ExistingDriftStates.ContainsKey($ComparisonItem.StandardName)) { $ExistingDriftStates[$ComparisonItem.StandardName].Reason } From fda343efc92299fdc0d18a7620e77786bd5540ba Mon Sep 17 00:00:00 2001 From: John Duprey Date: Tue, 17 Feb 2026 13:46:38 -0500 Subject: [PATCH 60/97] Handle optional GDAP roles and batch group adds Mark several GDAP roles as optional (Billing Administrator, Global Reader, Domain Name Administrator) and update role-check logic to fail only when required roles are missing. Improve logging to list missing roles when failing and to include missing roles when continuing. Refactor Microsoft Graph calls to use bulk requests: fetch /me and transitiveMemberOf in one bulk call and batch group membership additions via New-GraphBulkRequest, with per-request success/error logging and better error messages. Changes applied to Push-ExecOnboardTenantQueue.ps1 and Invoke-ExecAddGDAPRole.ps1. --- .../Push-ExecOnboardTenantQueue.ps1 | 72 +++++++++++++++---- .../Tenant/GDAP/Invoke-ExecAddGDAPRole.ps1 | 5 +- 2 files changed, 62 insertions(+), 15 deletions(-) diff --git a/Modules/CIPPCore/Public/Entrypoints/Activity Triggers/Push-ExecOnboardTenantQueue.ps1 b/Modules/CIPPCore/Public/Entrypoints/Activity Triggers/Push-ExecOnboardTenantQueue.ps1 index 23ca76c1adeb..2d997231efa6 100644 --- a/Modules/CIPPCore/Public/Entrypoints/Activity Triggers/Push-ExecOnboardTenantQueue.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/Activity Triggers/Push-ExecOnboardTenantQueue.ps1 @@ -47,7 +47,10 @@ function Push-ExecOnboardTenantQueue { @{ Name = 'SharePoint Administrator'; Id = 'f28a1f50-f6e7-4571-818b-6a12f2af6b6c' }, @{ Name = 'Authentication Policy Administrator'; Id = '0526716b-113d-4c15-b2c8-68e3c22b9f80' }, @{ Name = 'Privileged Role Administrator'; Id = 'e8611ab8-c189-46e8-94e1-60213ab1f814' }, - @{ Name = 'Privileged Authentication Administrator'; Id = '7be44c8a-adaf-4e2a-84d6-ab2649e08a13' } + @{ Name = 'Privileged Authentication Administrator'; Id = '7be44c8a-adaf-4e2a-84d6-ab2649e08a13' }, + @{ Name = 'Billing Administrator'; Id = 'b0f54661-2d74-4c50-afa3-1ec803f12efe'; Optional = $true }, + @{ Name = 'Global Reader'; Id = 'f2ef992c-3afb-46b9-b7cf-a126ee74c451'; Optional = $true }, + @{ Name = 'Domain Name Administrator'; Id = '8329153b-31d0-4727-b945-745eb3bc5f31'; Optional = $true } ) if ($OnboardingSteps.Step1.Status -ne 'succeeded') { @@ -99,14 +102,16 @@ function Push-ExecOnboardTenantQueue { } if (($MissingRoles | Measure-Object).Count -gt 0) { $Logs.Add([PSCustomObject]@{ Date = (Get-Date).ToUniversalTime(); Log = 'Missing roles for relationship' }) - if ($Item.IgnoreMissingRoles -ne $true) { + $RequiredMissingRoles = $ExpectedRoles | Where-Object { $_.Optional -ne $true -and $MissingRoles -contains $_.Name } + if ($Item.IgnoreMissingRoles -ne $true -and ($RequiredMissingRoles | Measure-Object).Count -gt 0) { + $Logs.Add([PSCustomObject]@{ Date = (Get-Date).ToUniversalTime(); Log = "Missing the following required roles: $($MissingRoles -join ', ')" }) $TenantOnboarding.Status = 'failed' $OnboardingSteps.Step2.Status = 'failed' $OnboardingSteps.Step2.Message = "Your GDAP relationship is missing the following roles: $($MissingRoles -join ', ')" } else { $Logs.Add([PSCustomObject]@{ Date = (Get-Date).ToUniversalTime(); Log = 'Ignoring missing roles' }) $OnboardingSteps.Step2.Status = 'succeeded' - $OnboardingSteps.Step2.Message = 'Your GDAP relationship is missing some roles, but the onboarding will continue' + $OnboardingSteps.Step2.Message = "Your GDAP relationship is missing some roles, but the onboarding will continue. Missing roles: $($MissingRoles -join ', ')" } } else { $Logs.Add([PSCustomObject]@{ Date = (Get-Date).ToUniversalTime(); Log = 'Required roles found' }) @@ -231,19 +236,58 @@ function Push-ExecOnboardTenantQueue { $OnboardingSteps.Step3.Status = 'succeeded' $Logs.Add([PSCustomObject]@{ Date = (Get-Date).ToUniversalTime(); Log = 'Checking for missing groups for SAM user' }) - $SamUserId = (New-GraphGetRequest -uri "https://graph.microsoft.com/beta/me?`$select=id" -NoAuthCheck $true).id - $CurrentMemberships = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/me/transitiveMemberOf?`$select=id,displayName" -NoAuthCheck $true - $ExpectedCippRoles = $Item.Roles | Where-Object { $_.roleDefinitionId -in $ExpectedRoles.roleDefinitionId } + $BulkRequests = @( + @{ + id = 'samUserId' + method = 'GET' + url = "/me?`$select=id" + }, + @{ + id = 'currentMemberships' + method = 'GET' + url = "/me/transitiveMemberOf?`$select=id,displayName" + } + ) + $BulkResults = New-GraphBulkRequest -Requests $BulkRequests -NoAuthCheck $true + $SamUserId = ($BulkResults | Where-Object { $_.id -eq 'samUserId' }).body.id + $CurrentMemberships = ($BulkResults | Where-Object { $_.id -eq 'currentMemberships' }).body.value + $ExpectedCippRoles = $Item.Roles | Where-Object { $_.roleDefinitionId -in $ExpectedRoles.Id } + + # Build bulk requests for missing group memberships + $GroupMembershipRequests = [System.Collections.Generic.List[object]]::new() + $GroupMembershipLogs = [System.Collections.Generic.List[object]]::new() + foreach ($Role in $ExpectedCippRoles) { if ($CurrentMemberships.id -notcontains $Role.GroupId) { - $PostBody = @{ - '@odata.id' = 'https://graph.microsoft.com/v1.0/directoryObjects/{0}' -f $SamUserId - } | ConvertTo-Json -Compress - try { - New-GraphPostRequest -uri "https://graph.microsoft.com/beta/groups/$($Role.GroupId)/members/`$ref" -body $PostBody -AsApp $true -NoAuthCheck $true - $Logs.Add([PSCustomObject]@{ Date = (Get-Date).ToUniversalTime(); Log = "Added SAM user to $($Role.GroupName)" }) - } catch { - $Logs.Add([PSCustomObject]@{ Date = (Get-Date).ToUniversalTime(); Log = "Failed to add SAM user to $($Role.GroupName) - $($_.Exception.Message)" }) + $GroupMembershipRequests.Add(@{ + id = "addSamUser-$($Role.GroupId)" + method = 'POST' + url = "groups/$($Role.GroupId)/members/`$ref" + body = @{ + '@odata.id' = 'https://graph.microsoft.com/v1.0/directoryObjects/{0}' -f $SamUserId + } + headers = @{ + 'Content-Type' = 'application/json' + } + }) + $GroupMembershipLogs.Add(@{ + id = "addSamUser-$($Role.GroupId)" + GroupName = $Role.GroupName + }) + } + } + + # Execute bulk group membership additions if any are needed + if ($GroupMembershipRequests.Count -gt 0) { + $GroupMembershipResults = New-GraphBulkRequest -Requests $GroupMembershipRequests -AsApp $true -NoAuthCheck $true + + foreach ($LogEntry in $GroupMembershipLogs) { + $Result = $GroupMembershipResults | Where-Object { $_.id -eq $LogEntry.id } + if ($Result.status -match '^2[0-9]+') { + $Logs.Add([PSCustomObject]@{ Date = (Get-Date).ToUniversalTime(); Log = "Added SAM user to $($LogEntry.GroupName)" }) + } else { + $ErrorMessage = if ($Result.body.error.message) { $Result.body.error.message } else { 'Unknown error' } + $Logs.Add([PSCustomObject]@{ Date = (Get-Date).ToUniversalTime(); Log = "Failed to add SAM user to $($LogEntry.GroupName) - $ErrorMessage" }) } } } diff --git a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Tenant/GDAP/Invoke-ExecAddGDAPRole.ps1 b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Tenant/GDAP/Invoke-ExecAddGDAPRole.ps1 index 65380b19933c..dcf555008f41 100644 --- a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Tenant/GDAP/Invoke-ExecAddGDAPRole.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Tenant/GDAP/Invoke-ExecAddGDAPRole.ps1 @@ -71,7 +71,10 @@ function Invoke-ExecAddGDAPRole { @{ label = 'SharePoint Administrator'; value = 'f28a1f50-f6e7-4571-818b-6a12f2af6b6c' }, @{ label = 'Authentication Policy Administrator'; value = '0526716b-113d-4c15-b2c8-68e3c22b9f80' }, @{ label = 'Privileged Role Administrator'; value = 'e8611ab8-c189-46e8-94e1-60213ab1f814' }, - @{ label = 'Privileged Authentication Administrator'; value = '7be44c8a-adaf-4e2a-84d6-ab2649e08a13' } + @{ label = 'Privileged Authentication Administrator'; value = '7be44c8a-adaf-4e2a-84d6-ab2649e08a13' }, + @{ label = 'Billing Administrator'; value = 'b0f54661-2d74-4c50-afa3-1ec803f12efe' }, + @{ label = 'Global Reader'; value = 'f2ef992c-3afb-46b9-b7cf-a126ee74c451' }, + @{ label = 'Domain Name Administrator'; value = '8329153b-31d0-4727-b945-745eb3bc5f31' } ) $Groups = $Request.Body.gdapRoles ?? $CippDefaults From f34126f571a0951c46d206f712af4b45445d1a18 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Tue, 17 Feb 2026 14:03:31 -0500 Subject: [PATCH 61/97] Ignore deleted accessAssignments & add optional roles Filter out accessAssignments with status 'deleted' or 'deleting' in Push-ExecOnboardTenantQueue.ps1 to avoid treating removed entries as active during mapping and polling. In Test-CIPPAccessTenant.ps1, add Billing Administrator, Global Reader, and Domain Name Administrator as optional GDAP roles, store an Optional flag on missing role objects, and update the status message to distinguish missing required vs optional GDAP roles. Also apply minor formatting adjustments. --- .../Push-ExecOnboardTenantQueue.ps1 | 2 ++ .../CIPPCore/Public/Test-CIPPAccessTenant.ps1 | 18 ++++++++++++++---- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/Modules/CIPPCore/Public/Entrypoints/Activity Triggers/Push-ExecOnboardTenantQueue.ps1 b/Modules/CIPPCore/Public/Entrypoints/Activity Triggers/Push-ExecOnboardTenantQueue.ps1 index 2d997231efa6..b3d6805ff988 100644 --- a/Modules/CIPPCore/Public/Entrypoints/Activity Triggers/Push-ExecOnboardTenantQueue.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/Activity Triggers/Push-ExecOnboardTenantQueue.ps1 @@ -126,6 +126,7 @@ function Push-ExecOnboardTenantQueue { if ($OnboardingSteps.Step2.Status -eq 'succeeded') { $Logs.Add([PSCustomObject]@{ Date = (Get-Date).ToUniversalTime(); Log = 'Checking group mapping' }) $AccessAssignments = New-GraphGetRequest -Uri "https://graph.microsoft.com/beta/tenantRelationships/delegatedAdminRelationships/$Id/accessAssignments" + $AccessAssignments = $AccessAssignments | Where-Object { $_.status -notin @('deleted', 'deleting') } if ($AccessAssignments.id -and $Item.AutoMapRoles -ne $true) { $Logs.Add([PSCustomObject]@{ Date = (Get-Date).ToUniversalTime(); Log = 'Groups mapped' }) $OnboardingSteps.Step3.Status = 'succeeded' @@ -228,6 +229,7 @@ function Push-ExecOnboardTenantQueue { do { $AccessAssignments = New-GraphGetRequest -Uri "https://graph.microsoft.com/beta/tenantRelationships/delegatedAdminRelationships/$Id/accessAssignments" + $AccessAssignments = $AccessAssignments | Where-Object { $_.status -notin @('deleted', 'deleting') } Start-Sleep -Seconds 15 } while ($AccessAssignments.status -contains 'pending' -and (Get-Date) -lt $Start.AddMinutes(8)) diff --git a/Modules/CIPPCore/Public/Test-CIPPAccessTenant.ps1 b/Modules/CIPPCore/Public/Test-CIPPAccessTenant.ps1 index a9f8459aec59..51dda90f7ff4 100644 --- a/Modules/CIPPCore/Public/Test-CIPPAccessTenant.ps1 +++ b/Modules/CIPPCore/Public/Test-CIPPAccessTenant.ps1 @@ -17,7 +17,10 @@ function Test-CIPPAccessTenant { @{ Name = 'SharePoint Administrator'; Id = 'f28a1f50-f6e7-4571-818b-6a12f2af6b6c' }, @{ Name = 'Authentication Policy Administrator'; Id = '0526716b-113d-4c15-b2c8-68e3c22b9f80' }, @{ Name = 'Privileged Role Administrator'; Id = 'e8611ab8-c189-46e8-94e1-60213ab1f814' }, - @{ Name = 'Privileged Authentication Administrator'; Id = '7be44c8a-adaf-4e2a-84d6-ab2649e08a13' } + @{ Name = 'Privileged Authentication Administrator'; Id = '7be44c8a-adaf-4e2a-84d6-ab2649e08a13' }, + @{ Name = 'Billing Administrator'; Id = 'b0f54661-2d74-4c50-afa3-1ec803f12efe'; Optional = $true }, + @{ Name = 'Global Reader'; Id = 'f2ef992c-3afb-46b9-b7cf-a126ee74c451'; Optional = $true }, + @{ Name = 'Domain Name Administrator'; Id = '8329153b-31d0-4727-b945-745eb3bc5f31'; Optional = $true } ) $TenantParams = @{ @@ -82,11 +85,11 @@ function Test-CIPPAccessTenant { if (!$Role) { $MissingRoles.Add( [PSCustomObject]@{ - Name = $RoleId.Name - Type = 'Tenant' + Name = $RoleId.Name + Type = 'Tenant' + Optional = $RoleId.Optional } ) - $AddedText = 'but missing GDAP roles' } else { $GDAPRoles.Add([PSCustomObject]@{ Role = $RoleId.Name @@ -95,6 +98,13 @@ function Test-CIPPAccessTenant { } } + $RequiredMissingRoles = $MissingRoles | Where-Object { $_.Optional -ne $true } + if (($RequiredMissingRoles | Measure-Object).Count -gt 0) { + $AddedText = 'but missing required GDAP roles' + } elseif (($MissingRoles | Measure-Object).Count -gt 0) { + $AddedText = 'but missing optional GDAP roles' + } + $GraphTest = "Successfully connected to Graph $($AddedText)" $GraphStatus = $true } catch { From d95fa06ce6a7ac539bd6a9c665b1fa394bf924d1 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Tue, 17 Feb 2026 14:30:51 -0500 Subject: [PATCH 62/97] Use Graph bulk requests for JIT admin listing Replace per-user Graph queries with Graph Bulk requests when listing JIT admin states and role memberships. Both entrypoints now build a bulk GET for users (with $count, $select, $filter and $top=999), parse the bulk response to get users, then construct and submit bulk membership requests. Added explicit initialization/clearing of the BulkRequests list and a guard to ensure non-empty requests before sending. Updated metadata to indicate Method='BulkRequest'. This reduces the number of individual Graph calls and improves performance and reliability when enumerating users and their directory roles. --- .../Push-ExecJITAdminListAllTenants.ps1 | 33 +++++++++--------- .../Users/Invoke-ListJITAdmin.ps1 | 34 +++++++++---------- 2 files changed, 33 insertions(+), 34 deletions(-) diff --git a/Modules/CIPPCore/Public/Entrypoints/Activity Triggers/Push-ExecJITAdminListAllTenants.ps1 b/Modules/CIPPCore/Public/Entrypoints/Activity Triggers/Push-ExecJITAdminListAllTenants.ps1 index 0f14c76645ab..2a4ec142805d 100644 --- a/Modules/CIPPCore/Public/Entrypoints/Activity Triggers/Push-ExecJITAdminListAllTenants.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/Activity Triggers/Push-ExecJITAdminListAllTenants.ps1 @@ -13,27 +13,26 @@ function Push-ExecJITAdminListAllTenants { # Get schema extensions $Schema = Get-CIPPSchemaExtensions | Where-Object { $_.id -match '_cippUser' } | Select-Object -First 1 - # Query users with JIT Admin enabled - $Query = @{ - TenantFilter = $DomainName # Use $DomainName for the current tenant - Endpoint = 'users' - Parameters = @{ - '$count' = 'true' - '$select' = "id,accountEnabled,displayName,userPrincipalName,$($Schema.id)" - '$filter' = "$($Schema.id)/jitAdminEnabled eq true or $($Schema.id)/jitAdminEnabled eq false" # Fetches both states to cache current status - } - } - $Users = Get-GraphRequestList @Query | Where-Object { $_.id } + # Query users with JIT Admin enabled using bulk request + $BulkRequests = [System.Collections.Generic.List[object]]::new() + $BulkRequests.Add(@{ + id = 'users' + method = 'GET' + url = "users?`$count=true&`$select=id,accountEnabled,displayName,userPrincipalName,$($Schema.id)&`$filter=$($Schema.id)/jitAdminEnabled eq true or $($Schema.id)/jitAdminEnabled eq false&`$top=999" + }) + + $BulkResults = New-GraphBulkRequest -tenantid $DomainName -Requests $BulkRequests + $Users = ($BulkResults | Where-Object { $_.id -eq 'users' }).body.value | Where-Object { $_.id } if ($Users) { # Get role memberships - $BulkRequests = $Users | ForEach-Object { @( - @{ - id = $_.id + $BulkRequests.Clear() + foreach ($User in $Users) { + $BulkRequests.Add(@{ + id = $User.id method = 'GET' - url = "users/$($_.id)/memberOf/microsoft.graph.directoryRole/?`$select=id,displayName" - } - ) + url = "users/$($User.id)/memberOf/microsoft.graph.directoryRole/?`$select=id,displayName" + }) } # Ensure $BulkRequests is not empty or null before making the bulk request if ($BulkRequests -and $BulkRequests.Count -gt 0) { diff --git a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListJITAdmin.ps1 b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListJITAdmin.ps1 index 705e4b205258..954760a7a464 100644 --- a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListJITAdmin.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListJITAdmin.ps1 @@ -17,23 +17,23 @@ if ($TenantFilter -ne 'AllTenants') { # Single tenant logic - $Query = @{ - TenantFilter = $TenantFilter - Endpoint = 'users' - Parameters = @{ - '$count' = 'true' - '$select' = "id,accountEnabled,displayName,userPrincipalName,$($Schema.id)" - '$filter' = "$($Schema.id)/jitAdminEnabled eq true or $($Schema.id)/jitAdminEnabled eq false" - } - } - $Users = Get-GraphRequestList @Query | Where-Object { $_.id } - $BulkRequests = $Users | ForEach-Object { @( - @{ - id = $_.id + $BulkRequests = [System.Collections.Generic.List[object]]::new() + $BulkRequests.Add(@{ + id = 'users' + method = 'GET' + url = "users?`$count=true&`$select=id,accountEnabled,displayName,userPrincipalName,$($Schema.id)&`$filter=$($Schema.id)/jitAdminEnabled eq true or $($Schema.id)/jitAdminEnabled eq false&`$top=999" + }) + + $BulkResults = New-GraphBulkRequest -tenantid $TenantFilter -Requests $BulkRequests + $Users = ($BulkResults | Where-Object { $_.id -eq 'users' }).body.value | Where-Object { $_.id } + + $BulkRequests.Clear() + foreach ($User in $Users) { + $BulkRequests.Add(@{ + id = $User.id method = 'GET' - url = "users/$($_.id)/memberOf/microsoft.graph.directoryRole/?`$select=id,displayName" - } - ) + url = "users/$($User.id)/memberOf/microsoft.graph.directoryRole/?`$select=id,displayName" + }) } $RoleResults = New-GraphBulkRequest -tenantid $TenantFilter -Requests @($BulkRequests) # Write-Information ($RoleResults | ConvertTo-Json -Depth 10 ) @@ -54,7 +54,7 @@ } # Write-Information ($Results | ConvertTo-Json -Depth 10) - $Metadata = [PSCustomObject]@{Parameters = $Query.Parameters } + $Metadata = [PSCustomObject]@{Method = 'BulkRequest' } } else { # AllTenants logic $Results = [System.Collections.Generic.List[object]]::new() From 226cb241c9dc9823741c0d8272f8daf590132a4a Mon Sep 17 00:00:00 2001 From: John Duprey Date: Tue, 17 Feb 2026 15:23:57 -0500 Subject: [PATCH 63/97] Make user cache query dynamic based on licenses Build a BaseSelect property list of user fields (identity, contact, org, licenses, on-prem sync, etc.) and detect if the tenant supports signInActivity via Test-CIPPStandardLicense. If signInActivity is available, include signInActivity in the $select and use $top=500; otherwise use the full BaseSelect and $top=999. Update the Graph request to use the dynamic $select and $top parameters and include $count, streaming results into Add-CIPPDbItem. This ensures required fields for tests, UI and integrations are cached while handling the signInActivity limitation. --- .../CIPPCore/Public/Set-CIPPDBCacheUsers.ps1 | 70 ++++++++++++++++++- 1 file changed, 68 insertions(+), 2 deletions(-) diff --git a/Modules/CIPPCore/Public/Set-CIPPDBCacheUsers.ps1 b/Modules/CIPPCore/Public/Set-CIPPDBCacheUsers.ps1 index d500fcbe16f2..e987260ab09b 100644 --- a/Modules/CIPPCore/Public/Set-CIPPDBCacheUsers.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPDBCacheUsers.ps1 @@ -19,9 +19,75 @@ function Set-CIPPDBCacheUsers { try { Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching users' -sev Debug + $SignInLogsCapable = Test-CIPPStandardLicense -StandardName 'UserSignInLogsCapable' -TenantFilter $TenantFilter -RequiredCapabilities @('AAD_PREMIUM', 'AAD_PREMIUM_P2') -SkipLog + + # Base properties needed by tests, standards, reports, UI, and integrations (Hudu, NinjaOne) + $BaseSelect = @( + # Core identity + 'id' + 'displayName' + 'userPrincipalName' + 'givenName' + 'surname' + 'mailNickname' + + # Account status + 'accountEnabled' + 'userType' + 'isResourceAccount' + 'createdDateTime' + + # Security & policies + 'passwordPolicies' + 'perUserMfaState' + + # Contact information + 'mail' + 'otherMails' + 'mobilePhone' + 'businessPhones' + 'faxNumber' + 'proxyAddresses' + + # Location & organization + 'jobTitle' + 'department' + 'companyName' + 'officeLocation' + 'city' + 'state' + 'country' + 'postalCode' + 'streetAddress' + + # Settings + 'preferredLanguage' + 'usageLocation' + 'preferredDataLocation' + 'showInAddressList' + + # Licenses + 'assignedLicenses' + 'assignedPlans' + 'licenseAssignmentStates' + + # On-premises sync + 'onPremisesSyncEnabled' + 'onPremisesImmutableId' + 'onPremisesLastSyncDateTime' + 'onPremisesDistinguishedName' + ) + + if ($SignInLogsCapable) { + $Select = ($BaseSelect + 'signInActivity') -join ',' + $Top = 500 + } else { + $Select = $BaseSelect -join ',' + $Top = 999 + } + # Stream users directly from Graph API to batch processor - # Using $top=500 due to signInActivity limitation - New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/users?$top=500&$select=signInActivity' -tenantid $TenantFilter | + New-GraphGetRequest -uri "https://graph.microsoft.com/beta/users?`$top=$Top&`$select=$Select&`$count=true" -ComplexFilter -tenantid $TenantFilter | Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'Users' -AddCount Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached users successfully' -sev Debug From 2c3bc5be4fda65f89746e603985e68f710c2fd9b Mon Sep 17 00:00:00 2001 From: John Duprey Date: Tue, 17 Feb 2026 16:04:34 -0500 Subject: [PATCH 64/97] Fetch full managedDevices; improve NinjaOne sync Remove the $select projection from the Graph managedDevices request so the full device objects are cached. In the NinjaOne tenant sync, avoid re-evaluating the device pipeline by introducing $DevicesToProcess, normalize serial numbers (strip spaces) for more reliable serial matching, fall back to deviceName for name matching, and wrap the PATCH update in a try/catch that logs error details. Also remove/comment noisy Write-Information lines and the debug Ninja body log to reduce log spam. --- .../Public/Set-CIPPDBCacheManagedDevices.ps1 | 2 +- .../NinjaOne/Invoke-NinjaOneTenantSync.ps1 | 31 ++++++++++--------- 2 files changed, 18 insertions(+), 15 deletions(-) diff --git a/Modules/CIPPCore/Public/Set-CIPPDBCacheManagedDevices.ps1 b/Modules/CIPPCore/Public/Set-CIPPDBCacheManagedDevices.ps1 index 509a136a4fec..5190fd72c24f 100644 --- a/Modules/CIPPCore/Public/Set-CIPPDBCacheManagedDevices.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPDBCacheManagedDevices.ps1 @@ -18,7 +18,7 @@ function Set-CIPPDBCacheManagedDevices { try { Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching managed devices' -sev Debug - $ManagedDevices = New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/deviceManagement/managedDevices?$top=999&$select=id,deviceName,operatingSystem,osVersion,complianceState,managedDeviceOwnerType,enrolledDateTime,lastSyncDateTime' -tenantid $TenantFilter + $ManagedDevices = New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/deviceManagement/managedDevices?$top=999' -tenantid $TenantFilter if (!$ManagedDevices) { $ManagedDevices = @() } Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'ManagedDevices' -Data $ManagedDevices Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'ManagedDevices' -Data $ManagedDevices -Count diff --git a/Modules/CippExtensions/Public/NinjaOne/Invoke-NinjaOneTenantSync.ps1 b/Modules/CippExtensions/Public/NinjaOne/Invoke-NinjaOneTenantSync.ps1 index 7d2b1a4b9aab..bca90aa87eba 100644 --- a/Modules/CippExtensions/Public/NinjaOne/Invoke-NinjaOneTenantSync.ps1 +++ b/Modules/CippExtensions/Public/NinjaOne/Invoke-NinjaOneTenantSync.ps1 @@ -461,15 +461,21 @@ function Invoke-NinjaOneTenantSync { [System.Collections.Generic.List[PSCustomObject]]$DeviceMap = @() } + $DevicesToProcess = $Devices | Where-Object { $_.id -notin $ParsedDevices.id } + # Parse Devices - foreach ($Device in $Devices | Where-Object { $_.id -notin $ParsedDevices.id }) { + foreach ($Device in $DevicesToProcess) { - # First lets match on serial - $MatchedNinjaDevice = $NinjaDevices | Where-Object { $_.system.biosSerialNumber -eq $Device.SerialNumber -or $_.system.serialNumber -eq $Device.SerialNumber } + # First lets match on serial (normalize by removing spaces for comparison) + $NormalizedDeviceSerial = $Device.SerialNumber -replace '\s', '' + $MatchedNinjaDevice = $NinjaDevices | Where-Object { + ($_.system.biosSerialNumber -replace '\s', '') -eq $NormalizedDeviceSerial -or + ($_.system.serialNumber -replace '\s', '') -eq $NormalizedDeviceSerial + } # See if we found just one device, if not match on name if (($MatchedNinjaDevice | Measure-Object).count -ne 1) { - $MatchedNinjaDevice = $NinjaDevices | Where-Object { $_.systemName -eq $Device.Name -or $_.dnsName -eq $Device.Name } + $MatchedNinjaDevice = $NinjaDevices | Where-Object { $_.systemName -eq $Device.deviceName -or $_.dnsName -eq $Device.deviceName } } # Check on a match again and set name @@ -710,7 +716,12 @@ function Invoke-NinjaOneTenantSync { # Update Device if ($MappedFields.DeviceSummary -or $MappedFields.DeviceLinks -or $MappedFields.DeviceCompliance) { - $Result = Invoke-WebRequest -Uri "https://$($Configuration.Instance)/api/v2/device/$($MatchedNinjaDevice.id)/custom-fields" -Method PATCH -Headers @{Authorization = "Bearer $($token.access_token)" } -ContentType 'application/json; charset=utf-8' -Body ($NinjaDeviceUpdate | ConvertTo-Json -Depth 100) + try { + $UpdateBody = $NinjaDeviceUpdate | ConvertTo-Json -Depth 100 + $Result = Invoke-WebRequest -Uri "https://$($Configuration.Instance)/api/v2/device/$($MatchedNinjaDevice.id)/custom-fields" -Method PATCH -Headers @{Authorization = "Bearer $($token.access_token)" } -ContentType 'application/json; charset=utf-8' -Body $UpdateBody + } catch { + Write-Verbose "Error details: $($_ | ConvertTo-Json -Depth 5)" + } } } @@ -1543,8 +1554,6 @@ function Invoke-NinjaOneTenantSync { ### M365 Links Section if ($MappedFields.TenantLinks) { - Write-Information 'Tenant Links' - $ManagementLinksData = @( @{ Name = 'M365 Admin Portal' @@ -1650,8 +1659,6 @@ function Invoke-NinjaOneTenantSync { if ($MappedFields.TenantSummary) { - Write-Information 'Tenant Summary' - ### Tenant Overview Card $ParsedAdmins = [PSCustomObject]@{} @@ -1672,7 +1679,6 @@ function Invoke-NinjaOneTenantSync { $TenantSummaryCard = Get-NinjaOneInfoCard -Title 'Tenant Details' -Data $TenantDetailsItems -Icon 'fas fa-building' ### Users details card - Write-Information 'User Details' $TotalUsersCount = ($Users | Measure-Object).count $GuestUsersCount = ($Users | Where-Object { $_.UserType -eq 'Guest' } | Measure-Object).count $LicensedUsersCount = ($licensedUsers | Measure-Object).count @@ -1730,7 +1736,6 @@ function Invoke-NinjaOneTenantSync { ### Device Details Card - Write-Information 'Device Details' $TotalDeviceswCount = ($Devices | Measure-Object).count $ComplianceDevicesCount = ($Devices | Where-Object { $_.complianceState -eq 'compliant' } | Measure-Object).count $WindowsCount = ($Devices | Where-Object { $_.operatingSystem -eq 'Windows' } | Measure-Object).count @@ -1810,7 +1815,6 @@ function Invoke-NinjaOneTenantSync { $DeviceSummaryCardHTML = Get-NinjaOneCard -Title 'Device Details' -Body $DeviceCardBodyHTML -Icon 'fas fa-network-wired' -TitleLink $TitleLink #### Secure Score Card - Write-Information 'Secure Score Details' $Top5Actions = ($SecureScoreParsed | Where-Object { $_.scoreInPercentage -ne 100 } | Sort-Object 'Score Impact', adjustedRank -Descending) | Select-Object -First 5 # Score Chart @@ -1845,7 +1849,6 @@ function Invoke-NinjaOneTenantSync { ### CIPP Applied Standards Cards - Write-Information 'Applied Standards' $ModuleBase = Get-Module CIPPExtensions | Select-Object -ExpandProperty ModuleBase $CIPPRoot = (Get-Item $ModuleBase).Parent.Parent.FullName Set-Location $CIPPRoot @@ -2195,7 +2198,7 @@ function Invoke-NinjaOneTenantSync { $Token = Get-NinjaOneToken -configuration $Configuration - Write-Information "Ninja Body: $($NinjaOrgUpdate | ConvertTo-Json -Depth 100)" + #Write-Information "Ninja Body: $($NinjaOrgUpdate | ConvertTo-Json -Depth 100)" $Result = Invoke-WebRequest -Uri "https://$($Configuration.Instance)/api/v2/organization/$($MappedTenant.IntegrationId)/custom-fields" -Method PATCH -Headers @{Authorization = "Bearer $($token.access_token)" } -ContentType 'application/json; charset=utf-8' -Body ($NinjaOrgUpdate | ConvertTo-Json -Depth 100) From c9afb7ec9cb072d3c6a5255c88c13183bbf3916f Mon Sep 17 00:00:00 2001 From: John Duprey Date: Wed, 18 Feb 2026 12:05:46 -0500 Subject: [PATCH 65/97] Orchestrator offboarding, task alerts, and fixes Introduce orchestration-driven offboarding and improve scheduled task handling. Added Push-CIPPOffboardingTask and Push-CIPPOffboardingComplete entrypoints and refactored Invoke-CIPPOffboardingJob to build a task batch and start a durable orchestration. Updated Push-ExecScheduledCommand to recognize orchestrator-based commands (skip post-exec alerts/state updates and attach TaskInfo for offboarding). Enhanced Clear-CIPPImmutableId to schedule immutable ID clears when users are synced from on-premises and to log/restore as needed. Added Send-CIPPScheduledTaskAlert utility and wired it into task flows. Made Set-CIPPMailboxAccess and Set-CIPPSharePointPerms handle arrays and return per-user results; ensure scheduled tasks avoid duplicate names in Remove-CIPPLicense. Minor fix in CippEntrypoints to capture invoked function output. --- .../CIPPCore/Public/Clear-CIPPImmutableId.ps1 | 63 ++- .../Push-CIPPOffboardingComplete.ps1 | 120 +++++ .../Push-CIPPOffboardingTask.ps1 | 38 ++ .../Push-ExecScheduledCommand.ps1 | 56 +-- .../Users/Invoke-CIPPOffboardingJob.ps1 | 450 +++++++++++------- .../CIPPCore/Public/Remove-CIPPLicense.ps1 | 2 +- .../Public/Send-CIPPScheduledTaskAlert.ps1 | 100 ++++ .../CIPPCore/Public/Set-CIPPMailboxAccess.ps1 | 39 +- .../Public/Set-CIPPSharePointPerms.ps1 | 57 ++- Modules/CippEntrypoints/CippEntrypoints.psm1 | 2 +- 10 files changed, 686 insertions(+), 241 deletions(-) create mode 100644 Modules/CIPPCore/Public/Entrypoints/Activity Triggers/Push-CIPPOffboardingComplete.ps1 create mode 100644 Modules/CIPPCore/Public/Entrypoints/Activity Triggers/Push-CIPPOffboardingTask.ps1 create mode 100644 Modules/CIPPCore/Public/Send-CIPPScheduledTaskAlert.ps1 diff --git a/Modules/CIPPCore/Public/Clear-CIPPImmutableId.ps1 b/Modules/CIPPCore/Public/Clear-CIPPImmutableId.ps1 index 60255008687e..4f4f11e1ad2e 100644 --- a/Modules/CIPPCore/Public/Clear-CIPPImmutableId.ps1 +++ b/Modules/CIPPCore/Public/Clear-CIPPImmutableId.ps1 @@ -3,14 +3,69 @@ function Clear-CIPPImmutableId { param ( $TenantFilter, $UserID, + $Username, # Optional - used for better logging and scheduling messages + $User, # Optional - if provided, will check sync status and schedule if needed $Headers, $APIName = 'Clear Immutable ID' ) try { + # If User object is provided, check if we need to schedule instead of clearing immediately + if ($User) { + # User has ImmutableID but is not synced from on-premises - safe to clear immediately + if ($User.onPremisesSyncEnabled -ne $true -and ![string]::IsNullOrEmpty($User.onPremisesImmutableId)) { + $DisplayName = $Username ?? $UserID + Write-LogMessage -Message "User $DisplayName has an ImmutableID set but is not synced from on-premises. Proceeding to clear the ImmutableID." -TenantFilter $TenantFilter -Severity 'Warning' -APIName $APIName -headers $Headers + # Continue to clear below + } + # User is synced from on-premises - must schedule for after deletion + elseif ($User.onPremisesSyncEnabled -eq $true -and ![string]::IsNullOrEmpty($User.onPremisesImmutableId)) { + $DisplayName = $Username ?? $UserID + Write-LogMessage -Message "User $DisplayName is synced from on-premises. Scheduling an Immutable ID clear for when the user account has been soft deleted." -TenantFilter $TenantFilter -Severity 'Warning' -APIName $APIName -headers $Headers + + $ScheduledTask = @{ + TenantFilter = $TenantFilter + Name = "Clear Immutable ID: $DisplayName" + Command = @{ value = 'Clear-CIPPImmutableID' } + Parameters = [pscustomobject]@{ + UserID = $UserID + TenantFilter = $TenantFilter + APIName = $APIName + } + Trigger = @{ + Type = 'DeltaQuery' + DeltaResource = 'users' + ResourceFilter = @($UserID) + EventType = 'deleted' + UseConditions = $false + ExecutePerResource = $true + ExecutionMode = 'once' + } + ScheduledTime = [int64](([datetime]::UtcNow).AddMinutes(5) - (Get-Date '1/1/1970')).TotalSeconds + Recurrence = '15m' + PostExecution = @{ + Webhook = $false + Email = $false + PSA = $false + } + } + Add-CIPPScheduledTask -Task $ScheduledTask -hidden $false -DisallowDuplicateName $true + return 'Scheduled Immutable ID clear task for when the user account is no longer synced in the on-premises directory.' + } + # User has no ImmutableID or is already clear + else { + $DisplayName = $Username ?? $UserID + $Result = "User $DisplayName does not have an ImmutableID set or it is already cleared." + Write-LogMessage -headers $Headers -API $APIName -message $Result -sev Info -tenant $TenantFilter + return $Result + } + } + + # Perform the actual clear operation try { - $User = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/users/$UserID" -tenantid $TenantFilter -ErrorAction SilentlyContinue + $UserObj = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/users/$UserID" -tenantid $TenantFilter -ErrorAction SilentlyContinue } catch { + # User might be deleted, try to restore it $DeletedUser = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/directory/deletedItems/$UserID" -tenantid $TenantFilter if ($DeletedUser.id) { # Restore deleted user object @@ -22,12 +77,14 @@ function Clear-CIPPImmutableId { $Body = [pscustomobject]@{ onPremisesImmutableId = $null } $Body = ConvertTo-Json -InputObject $Body -Depth 5 -Compress $null = New-GraphPostRequest -uri "https://graph.microsoft.com/beta/users/$UserID" -tenantid $TenantFilter -type PATCH -body $Body - $Result = "Successfully cleared immutable ID for user $UserID" + $DisplayName = $Username ?? $UserID + $Result = "Successfully cleared immutable ID for user $DisplayName" Write-LogMessage -headers $Headers -API $APIName -message $Result -sev Info -tenant $TenantFilter return $Result } catch { $ErrorMessage = Get-CippException -Exception $_ - $Result = "Failed to clear immutable ID for $($UserID). Error: $($ErrorMessage.NormalizedError)" + $DisplayName = $Username ?? $UserID + $Result = "Failed to clear immutable ID for $DisplayName. Error: $($ErrorMessage.NormalizedError)" Write-LogMessage -headers $Headers -API $APIName -message $Result -sev Error -tenant $TenantFilter -LogData $ErrorMessage throw $Result } diff --git a/Modules/CIPPCore/Public/Entrypoints/Activity Triggers/Push-CIPPOffboardingComplete.ps1 b/Modules/CIPPCore/Public/Entrypoints/Activity Triggers/Push-CIPPOffboardingComplete.ps1 new file mode 100644 index 000000000000..8d86e31a27a3 --- /dev/null +++ b/Modules/CIPPCore/Public/Entrypoints/Activity Triggers/Push-CIPPOffboardingComplete.ps1 @@ -0,0 +1,120 @@ +function Push-CIPPOffboardingComplete { + <# + .SYNOPSIS + Post-execution handler for offboarding orchestration completion + + .DESCRIPTION + Updates the scheduled task state when offboarding completes + + .FUNCTIONALITY + Entrypoint + #> + [CmdletBinding()] + param($Item) + + $TaskInfo = $Item.Parameters.TaskInfo + $TenantFilter = $Item.Parameters.TenantFilter + $Username = $Item.Parameters.Username + $Results = $Item.Results # Results come from orchestrator, not Parameters + + try { + Write-Information "Completing offboarding orchestration for $Username in tenant $TenantFilter" + Write-Information "Raw results from orchestrator: $($Results | ConvertTo-Json -Depth 10)" + + # Flatten nested arrays from orchestrator results + # Activity functions may return arrays like [result, "status message"] + $FlattenedResults = @( + foreach ($BatchResult in $Results) { + if ($BatchResult -is [array] -and $BatchResult.Count -gt 0) { + Write-Information "Result is array with $($BatchResult.Count) elements, extracting elements" + # Output all elements from the array + foreach ($element in $BatchResult) { + if ($null -ne $element -and $element -ne '') { + $element + } + } + } elseif ($null -ne $BatchResult -and $BatchResult -ne '') { + # Single item - output it + $BatchResult + } + } + ) + + # Process results in the same way as Push-ExecScheduledCommand + if ($FlattenedResults.Count -eq 0) { + $ProcessedResults = "Offboarding completed successfully for $Username" + } else { + Write-Information "Processing $($FlattenedResults.Count) flattened results: $($FlattenedResults | ConvertTo-Json -Depth 10)" + + # Normalize results format + if ($FlattenedResults -is [string]) { + $ProcessedResults = @{ Results = $FlattenedResults } + } elseif ($FlattenedResults -is [array]) { + # Filter and process string or resultText items + $StringResults = $FlattenedResults | Where-Object { $_ -is [string] -or $_.resultText -is [string] } + if ($StringResults) { + $ProcessedResults = $StringResults | ForEach-Object { + $Message = if ($_ -is [string]) { $_ } else { $_.resultText } + @{ Results = $Message } + } + } else { + # Keep structured results as-is + $ProcessedResults = $FlattenedResults + } + } else { + $ProcessedResults = $FlattenedResults + } + } + + Write-Information "Results after processing: $($ProcessedResults | ConvertTo-Json -Depth 10)" + + # Prepare results for storage + if ($ProcessedResults -is [string]) { + $StoredResults = $ProcessedResults + } else { + $ProcessedResults = $ProcessedResults | Select-Object * -ExcludeProperty RowKey, PartitionKey + $StoredResults = $ProcessedResults | ConvertTo-Json -Compress -Depth 20 | Out-String + } + + if ($TaskInfo) { + # Update scheduled task to completed state + $Table = Get-CippTable -tablename 'ScheduledTasks' + $currentUnixTime = [int64](([datetime]::UtcNow) - (Get-Date '1/1/1970')).TotalSeconds + + # Check if results are too large and need separate storage + if ($StoredResults.Length -gt 64000) { + Write-Information 'Results exceed 64KB limit. Storing in ScheduledTaskResults table.' + $TaskResultsTable = Get-CippTable -tablename 'ScheduledTaskResults' + $TaskResults = @{ + PartitionKey = $TaskInfo.RowKey + RowKey = $TenantFilter + Results = [string](ConvertTo-Json -Compress -Depth 20 $ProcessedResults) + } + $null = Add-CIPPAzDataTableEntity @TaskResultsTable -Entity $TaskResults -Force + $StoredResults = @{ Results = 'Offboarding completed, details are available in the More Info pane' } | ConvertTo-Json -Compress + } + + $null = Update-AzDataTableEntity -Force @Table -Entity @{ + PartitionKey = $TaskInfo.PartitionKey + RowKey = $TaskInfo.RowKey + Results = "$StoredResults" + ExecutedTime = "$currentUnixTime" + TaskState = 'Completed' + } + + Write-LogMessage -API 'Offboarding' -tenant $TenantFilter -message "Offboarding completed successfully for $Username" -sev Info + + # Send post-execution alerts if configured + if ($TaskInfo.PostExecution -and $ProcessedResults) { + Send-CIPPScheduledTaskAlert -Results $ProcessedResults -TaskInfo $TaskInfo -TenantFilter $TenantFilter + } + } + + return "Offboarding completed for $Username" + + } catch { + $ErrorMsg = "Failed to complete offboarding for $Username : $($_.Exception.Message)" + Write-LogMessage -API 'Offboarding' -tenant $TenantFilter -message $ErrorMsg -sev Error + throw $ErrorMsg + } +} diff --git a/Modules/CIPPCore/Public/Entrypoints/Activity Triggers/Push-CIPPOffboardingTask.ps1 b/Modules/CIPPCore/Public/Entrypoints/Activity Triggers/Push-CIPPOffboardingTask.ps1 new file mode 100644 index 000000000000..7f0243f4d9c9 --- /dev/null +++ b/Modules/CIPPCore/Public/Entrypoints/Activity Triggers/Push-CIPPOffboardingTask.ps1 @@ -0,0 +1,38 @@ +function Push-CIPPOffboardingTask { + <# + .SYNOPSIS + Generic wrapper to execute individual offboarding task cmdlets + + .DESCRIPTION + Executes the specified cmdlet with the provided parameters as part of user offboarding + + .FUNCTIONALITY + Entrypoint + #> + [CmdletBinding()] + param($Item) + + $Cmdlet = $Item.Cmdlet + $Parameters = $Item.Parameters | ConvertTo-Json -Depth 5 | ConvertFrom-Json -AsHashtable + + try { + Write-Information "Executing offboarding cmdlet: $Cmdlet" + + # Check if cmdlet exists + $CmdletInfo = Get-Command -Name $Cmdlet -ErrorAction SilentlyContinue + if (-not $CmdletInfo) { + throw "Cmdlet $Cmdlet does not exist" + } + + # Execute the cmdlet with splatting + $Result = & $Cmdlet @Parameters + + Write-Information "Completed $Cmdlet successfully" + return $Result + + } catch { + $ErrorMsg = "Failed to execute $Cmdlet : $($_.Exception.Message)" + Write-Information $ErrorMsg + return $ErrorMsg + } +} diff --git a/Modules/CIPPCore/Public/Entrypoints/Activity Triggers/Push-ExecScheduledCommand.ps1 b/Modules/CIPPCore/Public/Entrypoints/Activity Triggers/Push-ExecScheduledCommand.ps1 index ccc7249ed798..22607cb0841b 100644 --- a/Modules/CIPPCore/Public/Entrypoints/Activity Triggers/Push-ExecScheduledCommand.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/Activity Triggers/Push-ExecScheduledCommand.ps1 @@ -7,6 +7,9 @@ function Push-ExecScheduledCommand { $item = $Item | ConvertTo-Json -Depth 100 | ConvertFrom-Json Write-Information "We are going to be running a scheduled task: $($Item.TaskInfo | ConvertTo-Json -Depth 10)" + # Define orchestrator-based commands that handle their own post-execution and state updates + $OrchestratorBasedCommands = @('Invoke-CIPPOffboardingJob') + # Initialize AsyncLocal storage for thread-safe per-invocation context if (-not $script:CippScheduledTaskIdStorage) { $script:CippScheduledTaskIdStorage = [System.Threading.AsyncLocal[string]]::new() @@ -225,6 +228,12 @@ function Push-ExecScheduledCommand { try { if (-not $Trigger.ExecutePerResource) { try { + # For orchestrator-based commands, add TaskInfo to enable post-execution updates + if ($Item.Command -eq 'Invoke-CIPPOffboardingJob') { + Write-Information 'Adding TaskInfo to command parameters for orchestrator-based offboarding' + $commandParameters['TaskInfo'] = $task + } + Write-Information "Starting task: $($Item.Command) for tenant: $Tenant with parameters: $($commandParameters | ConvertTo-Json)" $results = & $Item.Command @commandParameters } catch { @@ -310,43 +319,24 @@ function Push-ExecScheduledCommand { } Write-Information 'Sending task results to target. Updating the task state.' - if ($Results) { - $TableDesign = '' - $FinalResults = if ($results -is [array] -and $results[0] -is [string]) { $Results | ConvertTo-Html -Fragment -Property @{ l = 'Text'; e = { $_ } } } else { $Results | ConvertTo-Html -Fragment } - $HTML = $FinalResults -replace '', "This alert is for tenant $Tenant.

$TableDesign
" | Out-String - - # Add alert comment if available - if ($task.AlertComment) { - if ($task.AlertComment -match '%resultcount%') { - $resultCount = if ($Results -is [array]) { $Results.Count } else { 1 } - $task.AlertComment = $task.AlertComment -replace '%resultcount%', "$resultCount" - } - $task.AlertComment = Get-CIPPTextReplacement -Text $task.AlertComment -TenantFilter $Tenant - $HTML += "

Alert Information

$($task.AlertComment)

" - } - - $title = "$TaskType - $Tenant - $($task.Name)$(if ($task.Reference) { " - Reference: $($task.Reference)" })" - Write-Information 'Scheduler: Sending the results to the target.' - Write-Information "The content of results is: $Results" - switch -wildcard ($task.PostExecution) { - '*psa*' { Send-CIPPAlert -Type 'psa' -Title $title -HTMLContent $HTML -TenantFilter $Tenant } - '*email*' { Send-CIPPAlert -Type 'email' -Title $title -HTMLContent $HTML -TenantFilter $Tenant } - '*webhook*' { - $Webhook = [PSCustomObject]@{ - 'tenantId' = $TenantInfo.customerId - 'Tenant' = $Tenant - 'TaskInfo' = $Item.TaskInfo - 'Results' = $Results - 'AlertComment' = $task.AlertComment - } - Send-CIPPAlert -Type 'webhook' -Title $title -TenantFilter $Tenant -JSONContent $($Webhook | ConvertTo-Json -Depth 20) - } - } + # For orchestrator-based commands, skip post-execution alerts as they will be handled by the orchestrator's post-execution function + if ($Results -and $Item.Command -notin $OrchestratorBasedCommands) { + Send-CIPPScheduledTaskAlert -Results $Results -TaskInfo $task -TenantFilter $Tenant -TaskType $TaskType } Write-Information 'Sent the results to the target. Updating the task state.' try { - if ($task.Recurrence -eq '0' -or [string]::IsNullOrEmpty($task.Recurrence) -or $Trigger.ExecutionMode.value -eq 'once' -or $Trigger.ExecutionMode -eq 'once') { + # For orchestrator-based commands, skip task state update as it will be handled by post-execution + if ($Item.Command -in $OrchestratorBasedCommands) { + Write-Information "Command $($Item.Command) is orchestrator-based. Skipping task state update - will be handled by post-execution." + # Update task state to 'Running' to indicate orchestration is in progress + Update-AzDataTableEntity -Force @Table -Entity @{ + PartitionKey = $task.PartitionKey + RowKey = $task.RowKey + Results = 'Orchestration in progress' + TaskState = 'Processing' + } + } elseif ($task.Recurrence -eq '0' -or [string]::IsNullOrEmpty($task.Recurrence) -or $Trigger.ExecutionMode.value -eq 'once' -or $Trigger.ExecutionMode -eq 'once') { Write-Information 'Recurrence empty or 0. Task is not recurring. Setting task state to completed.' Update-AzDataTableEntity -Force @Table -Entity @{ PartitionKey = $task.PartitionKey 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 94d7e806ccd8..6da71f0a98bf 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 @@ -6,206 +6,308 @@ function Invoke-CIPPOffboardingJob { [switch]$RunScheduled, $Options, $APIName = 'Offboard user', - $Headers + $Headers, + $TaskInfo ) - if ($Options -is [string]) { - $Options = $Options | ConvertFrom-Json - } - $User = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/users/$($Username)?`$select=id,displayName,onPremisesSyncEnabled,onPremisesImmutableId" -tenantid $TenantFilter - $UserID = $User.id - $DisplayName = $User.displayName - Write-Host "Running offboarding job for $Username with options: $($Options | ConvertTo-Json -Depth 10)" - $Return = switch ($Options) { - { $_.ConvertToShared -eq $true } { - try { - Set-CIPPMailboxType -Headers $Headers -tenantFilter $TenantFilter -userid $UserID -username $Username -MailboxType 'Shared' -APIName $APIName - } catch { - $_.Exception.Message - } + + try { + if ($Options -is [string]) { + $Options = $Options | ConvertFrom-Json } - { $_.RevokeSessions -eq $true } { - try { - Revoke-CIPPSessions -tenantFilter $TenantFilter -username $Username -userid $UserID -Headers $Headers -APIName $APIName - } catch { - $_.Exception.Message + + Write-Information "Starting offboarding job for $Username in tenant $TenantFilter" + Write-LogMessage -API 'Offboarding' -tenant $TenantFilter -message "Starting offboarding orchestration for user $Username" -sev Info + + # Get user information needed for various tasks + $User = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/users/$($Username)?`$select=id,displayName,onPremisesSyncEnabled,onPremisesImmutableId" -tenantid $TenantFilter + $UserID = $User.id + $DisplayName = $User.displayName + + # Build dynamic batch of offboarding tasks based on selected options + $Batch = [System.Collections.Generic.List[object]]::new() + + # Build list of tasks in execution order with their cmdlets + $TaskOrder = @( + @{ + Condition = { $Options.RevokeSessions -eq $true } + Cmdlet = 'Revoke-CIPPSessions' + Parameters = @{ + tenantFilter = $TenantFilter + username = $Username + userid = $UserID + APIName = $APIName + } } - } - { $_.ResetPass -eq $true } { - try { - Set-CIPPResetPassword -tenantFilter $TenantFilter -DisplayName $DisplayName -UserID $username -Headers $Headers -APIName $APIName - } catch { - $_.Exception.Message + @{ + Condition = { $Options.ResetPass -eq $true } + Cmdlet = 'Set-CIPPResetPassword' + Parameters = @{ + tenantFilter = $TenantFilter + DisplayName = $DisplayName + UserID = $Username + APIName = $APIName + } } - } - { $_.RemoveGroups -eq $true } { - Remove-CIPPGroups -userid $UserID -tenantFilter $TenantFilter -Headers $Headers -APIName $APIName -Username $Username - } - { $_.HideFromGAL -eq $true } { - try { - Set-CIPPHideFromGAL -tenantFilter $TenantFilter -UserID $username -hidefromgal $true -Headers $Headers -APIName $APIName - } catch { - $_.Exception.Message + @{ + Condition = { $Options.DisableSignIn -eq $true } + Cmdlet = 'Set-CIPPSignInState' + Parameters = @{ + TenantFilter = $TenantFilter + userid = $Username + AccountEnabled = $false + APIName = $APIName + } } - } - { $_.DisableSignIn -eq $true } { - try { - Set-CIPPSignInState -TenantFilter $TenantFilter -userid $username -AccountEnabled $false -Headers $Headers -APIName $APIName - } catch { - $_.Exception.Message + @{ + Condition = { $Options.HideFromGAL -eq $true } + Cmdlet = 'Set-CIPPHideFromGAL' + Parameters = @{ + tenantFilter = $TenantFilter + UserID = $Username + hidefromgal = $true + APIName = $APIName + } } - } - { $_.OnedriveAccess } { - $Options.OnedriveAccess | ForEach-Object { - try { - Set-CIPPSharePointPerms -tenantFilter $TenantFilter -userid $username -OnedriveAccessUser $_.value -Headers $Headers -APIName $APIName - } catch { - $_.Exception.Message + @{ + Condition = { $Options.RemoveGroups -eq $true } + Cmdlet = 'Remove-CIPPGroups' + Parameters = @{ + userid = $UserID + tenantFilter = $TenantFilter + APIName = $APIName + Username = $Username } } - } - { $_.AccessNoAutomap } { - $Options.AccessNoAutomap | ForEach-Object { - try { - Set-CIPPMailboxAccess -tenantFilter $TenantFilter -userid $username -AccessUser $_.value -Automap $false -AccessRights @('FullAccess') -Headers $Headers -APIName $APIName - } catch { - $_.Exception.Message + @{ + Condition = { $Options.RemoveRules -eq $true } + Cmdlet = 'Remove-CIPPMailboxRule' + Parameters = @{ + userid = $UserID + username = $Username + tenantFilter = $TenantFilter + APIName = $APIName + RemoveAllRules = $true } } - } - { $_.AccessAutomap } { - $Options.AccessAutomap | ForEach-Object { - try { - Set-CIPPMailboxAccess -tenantFilter $TenantFilter -userid $username -AccessUser $_.value -Automap $true -AccessRights @('FullAccess') -Headers $Headers -APIName $APIName - } catch { - $_.Exception.Message + @{ + Condition = { $Options.RemoveMobile -eq $true } + Cmdlet = 'Remove-CIPPMobileDevice' + Parameters = @{ + userid = $UserID + username = $Username + tenantFilter = $TenantFilter + APIName = $APIName } } - } - { $_.OOO } { - try { - Set-CIPPOutOfOffice -tenantFilter $TenantFilter -UserID $username -InternalMessage $Options.OOO -ExternalMessage $Options.OOO -Headers $Headers -APIName $APIName -state 'Enabled' - } catch { - $_.Exception.Message + @{ + Condition = { $Options.removeCalendarInvites -eq $true } + Cmdlet = 'Remove-CIPPCalendarInvites' + Parameters = @{ + UserID = $UserID + Username = $Username + TenantFilter = $TenantFilter + APIName = $APIName + } } - } - { $_.forward } { - if (!$Options.KeepCopy) { - try { - Set-CIPPForwarding -userid $userid -username $username -tenantFilter $TenantFilter -Forward $Options.forward.value -Headers $Headers -APIName $APIName - } catch { - $_.Exception.Message + @{ + Condition = { ![string]::IsNullOrEmpty($Options.OOO) } + Cmdlet = 'Set-CIPPOutOfOffice' + Parameters = @{ + tenantFilter = $TenantFilter + UserID = $Username + InternalMessage = $Options.OOO + ExternalMessage = $Options.OOO + APIName = $APIName + state = 'Enabled' } - } else { - $KeepCopy = [boolean]$Options.KeepCopy - try { - Set-CIPPForwarding -userid $userid -username $username -tenantFilter $TenantFilter -Forward $Options.forward.value -KeepCopy $KeepCopy -Headers $Headers -APIName $APIName - } catch { - $_.Exception.Message + } + @{ + Condition = { ![string]::IsNullOrEmpty($Options.forward) } + Cmdlet = 'Set-CIPPForwarding' + Parameters = @{ + userid = $UserID + username = $Username + tenantFilter = $TenantFilter + Forward = $Options.forward.value + KeepCopy = [bool]$Options.KeepCopy + APIName = $APIName } } - } - { $_.disableForwarding } { - try { - Set-CIPPForwarding -userid $userid -username $username -tenantFilter $TenantFilter -Disable $true -Headers $Headers -APIName $APIName - } catch { - $_.Exception.Message + @{ + Condition = { $Options.disableForwarding -eq $true } + Cmdlet = 'Set-CIPPForwarding' + Parameters = @{ + userid = $UserID + username = $Username + tenantFilter = $TenantFilter + Disable = $true + APIName = $APIName + } } - } - { $_.RemoveTeamsPhoneDID } { - try { - Remove-CIPPUserTeamsPhoneDIDs -userid $userid -username $username -tenantFilter $TenantFilter -Headers $Headers -APIName $APIName - } catch { - $_.Exception.Message + @{ + Condition = { ![string]::IsNullOrEmpty($Options.OnedriveAccess) } + Cmdlet = 'Set-CIPPSharePointPerms' + Parameters = @{ + tenantFilter = $TenantFilter + userid = $Username + OnedriveAccessUser = $Options.OnedriveAccess + APIName = $APIName + } } - } - { $_.RemoveLicenses -eq $true } { - Remove-CIPPLicense -userid $userid -username $Username -tenantFilter $TenantFilter -Headers $Headers -APIName $APIName -Schedule - } - { $_.DeleteUser -eq $true } { - try { - Remove-CIPPUser -UserID $userid -Username $Username -TenantFilter $TenantFilter -Headers $Headers -APIName $APIName - } catch { - $_.Exception.Message + @{ + Condition = { ![string]::IsNullOrEmpty($Options.AccessNoAutomap) } + Cmdlet = 'Set-CIPPMailboxAccess' + Parameters = @{ + tenantFilter = $TenantFilter + userid = $Username + AccessUser = $Options.AccessNoAutomap + Automap = $false + AccessRights = @('FullAccess') + APIName = $APIName + } } - } - { $_.RemoveRules -eq $true } { - Write-Host "Removing rules for $username" - try { - Remove-CIPPMailboxRule -userid $userid -username $Username -tenantFilter $TenantFilter -Headers $Headers -APIName $APIName -RemoveAllRules - } catch { - $_.Exception.Message + @{ + Condition = { ![string]::IsNullOrEmpty($Options.AccessAutomap) } + Cmdlet = 'Set-CIPPMailboxAccess' + Parameters = @{ + tenantFilter = $TenantFilter + userid = $Username + AccessUser = $Options.AccessAutomap + Automap = $true + AccessRights = @('FullAccess') + APIName = $APIName + } } - } - { $_.RemoveMobile -eq $true } { - try { - Remove-CIPPMobileDevice -userid $userid -username $Username -tenantFilter $TenantFilter -Headers $Headers -APIName $APIName - } catch { - $_.Exception.Message + @{ + Condition = { $Options.removePermissions -eq $true } + Cmdlet = 'Remove-CIPPMailboxPermissions' + Parameters = @{ + AccessUser = $Username + TenantFilter = $TenantFilter + UseCache = $true + APIName = $APIName + } } - } - { $_.removeCalendarInvites -eq $true } { - try { - Remove-CIPPCalendarInvites -UserID $userid -Username $Username -TenantFilter $TenantFilter -Headers $Headers -APIName $APIName - } catch { - $_.Exception.Message + @{ + Condition = { $Options.removeCalendarPermissions -eq $true } + Cmdlet = 'Remove-CIPPCalendarPermissions' + Parameters = @{ + UserToRemove = $Username + TenantFilter = $TenantFilter + UseCache = $true + APIName = $APIName + } + } + @{ + Condition = { $Options.ConvertToShared -eq $true } + Cmdlet = 'Set-CIPPMailboxType' + Parameters = @{ + tenantFilter = $TenantFilter + userid = $UserID + username = $Username + MailboxType = 'Shared' + APIName = $APIName + } + } + @{ + Condition = { $Options.RemoveMFADevices -eq $true } + Cmdlet = 'Remove-CIPPUserMFA' + Parameters = @{ + UserPrincipalName = $Username + TenantFilter = $TenantFilter + } + } + @{ + Condition = { $Options.RemoveTeamsPhoneDID -eq $true } + Cmdlet = 'Remove-CIPPUserTeamsPhoneDIDs' + Parameters = @{ + userid = $UserID + username = $Username + tenantFilter = $TenantFilter + APIName = $APIName + } + } + @{ + Condition = { $Options.RemoveLicenses -eq $true } + Cmdlet = 'Remove-CIPPLicense' + Parameters = @{ + userid = $UserID + username = $Username + tenantFilter = $TenantFilter + APIName = $APIName + Schedule = $true + } + } + @{ + Condition = { $Options.ClearImmutableId -eq $true } + Cmdlet = 'Clear-CIPPImmutableID' + Parameters = @{ + UserID = $UserID + Username = $Username + TenantFilter = $TenantFilter + User = $User + APIName = $APIName + } + } + @{ + Condition = { $Options.DeleteUser -eq $true } + Cmdlet = 'Remove-CIPPUser' + Parameters = @{ + UserID = $UserID + Username = $Username + TenantFilter = $TenantFilter + APIName = $APIName + } + } + ) + + # Build batch from selected tasks + foreach ($Task in $TaskOrder) { + if (& $Task.Condition) { + $Batch.Add(@{ + FunctionName = 'CIPPOffboardingTask' + Cmdlet = $Task.Cmdlet + Parameters = $Task.Parameters + }) } } - { $_.removePermissions } { - Remove-CIPPMailboxPermissions -AccessUser $Username -TenantFilter $TenantFilter -UseCache -APIName $APIName -Headers $Headers - } - { $_.removeCalendarPermissions } { - Remove-CIPPCalendarPermissions -UserToRemove $Username -TenantFilter $TenantFilter -UseCache -APIName $APIName -Headers $Headers + + if ($Batch.Count -eq 0) { + Write-LogMessage -API 'Offboarding' -tenant $TenantFilter -message "No offboarding tasks selected for user $Username" -sev Warning + return "No offboarding tasks were selected for $Username" } - { $_.RemoveMFADevices -eq $true } { - try { - Remove-CIPPUserMFA -UserPrincipalName $Username -TenantFilter $TenantFilter -Headers $Headers - } catch { - $_.Exception.Message - } + + Write-Information "Built batch of $($Batch.Count) offboarding tasks for $Username" + + # Start orchestration + $InputObject = [PSCustomObject]@{ + OrchestratorName = "OffboardingUser_$($Username)_$TenantFilter" + Batch = @($Batch) + SkipLog = $true + DurableMode = 'Sequence' } - { $_.ClearImmutableId -eq $true } { - if ($User.onPremisesSyncEnabled -ne $true -and ![string]::IsNullOrEmpty($User.onPremisesImmutableId)) { - Write-LogMessage -Message "User $Username has an ImmutableID set but is not synced from on-premises. Proceeding to clear the ImmutableID." -TenantFilter $TenantFilter -Severity 'Warning' -APIName $APIName -Headers $Headers - try { - Clear-CIPPImmutableID -UserID $userid -TenantFilter $TenantFilter -Headers $Headers -APIName $APIName - } catch { - $_.Exception.Message - } - } elseif ($User.onPremisesSyncEnabled -eq $true -and ![string]::IsNullOrEmpty($User.onPremisesImmutableId)) { - Write-LogMessage -Message "User $Username is synced from on-premises. Scheduling an Immutable ID clear for when the user account has been soft deleted." -TenantFilter $TenantFilter -Severity 'Error' -APIName $APIName -Headers $Headers - 'Scheduling Immutable ID clear task for when the user account is no longer synced in the on-premises directory.' - $ScheduledTask = @{ - TenantFilter = $TenantFilter - Name = "Clear Immutable ID: $Username" - Command = @{ - value = 'Clear-CIPPImmutableID' - } - Parameters = [pscustomobject]@{ - userid = $userid - APIName = $APIName - Headers = $Headers - } - Trigger = @{ - Type = 'DeltaQuery' - DeltaResource = 'users' - ResourceFilter = @($UserID) - EventType = 'deleted' - UseConditions = $false - ExecutePerResource = $true - ExecutionMode = 'once' - } - ScheduledTime = [int64](([datetime]::UtcNow).AddMinutes(5) - (Get-Date '1/1/1970')).TotalSeconds - Recurrence = '15m' - PostExecution = @{ - Webhook = $false - Email = $false - PSA = $false - } - } - Add-CIPPScheduledTask -Task $ScheduledTask -hidden $false + + # Add post-execution handler if TaskInfo is provided (from scheduled task) + if ($TaskInfo) { + $InputObject | Add-Member -NotePropertyName PostExecution -NotePropertyValue @{ + FunctionName = 'CIPPOffboardingComplete' + Parameters = @{ + TaskInfo = $TaskInfo + TenantFilter = $TenantFilter + Username = $Username + } } } - } - return $Return + $InstanceId = Start-NewOrchestration -FunctionName 'CIPPOrchestrator' -InputObject ($InputObject | ConvertTo-Json -Depth 10 -Compress) + Write-Information "Started offboarding job for $Username with ID = '$InstanceId'" + Write-LogMessage -API 'Offboarding' -tenant $TenantFilter -message "Started offboarding job for $Username with $($Batch.Count) tasks. Instance ID: $InstanceId" -sev Info + + return "Offboarding job started for $Username with $($Batch.Count) tasks" + + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'Offboarding' -tenant $TenantFilter -message "Failed to start offboarding job for $Username : $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage + throw $ErrorMessage + } } diff --git a/Modules/CIPPCore/Public/Remove-CIPPLicense.ps1 b/Modules/CIPPCore/Public/Remove-CIPPLicense.ps1 index 3b18b54b0678..be55ac1b73c1 100644 --- a/Modules/CIPPCore/Public/Remove-CIPPLicense.ps1 +++ b/Modules/CIPPCore/Public/Remove-CIPPLicense.ps1 @@ -29,7 +29,7 @@ function Remove-CIPPLicense { PSA = $false } } - Add-CIPPScheduledTask -Task $ScheduledTask -hidden $false + Add-CIPPScheduledTask -Task $ScheduledTask -hidden $false -DisallowDuplicateName $true return "Scheduled license removal for $username" } else { try { diff --git a/Modules/CIPPCore/Public/Send-CIPPScheduledTaskAlert.ps1 b/Modules/CIPPCore/Public/Send-CIPPScheduledTaskAlert.ps1 new file mode 100644 index 000000000000..1f8b163fe714 --- /dev/null +++ b/Modules/CIPPCore/Public/Send-CIPPScheduledTaskAlert.ps1 @@ -0,0 +1,100 @@ +function Send-CIPPScheduledTaskAlert { + <# + .SYNOPSIS + Send post-execution alerts for scheduled tasks + + .DESCRIPTION + Handles sending alerts (PSA, Email, Webhook) for scheduled task completion + + .PARAMETER Results + The results to send in the alert + + .PARAMETER TaskInfo + The task information from the ScheduledTasks table + + .PARAMETER TenantFilter + The tenant filter for the task + + .PARAMETER TaskType + The type of task (default: 'Scheduled Task') + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + $Results, + + [Parameter(Mandatory = $true)] + $TaskInfo, + + [Parameter(Mandatory = $true)] + [string]$TenantFilter, + + [Parameter(Mandatory = $false)] + [string]$TaskType = 'Scheduled Task' + ) + + try { + Write-Information "Sending post-execution alerts for task $($TaskInfo.Name)" + + # Get tenant information + $TenantInfo = Get-Tenants -TenantFilter $TenantFilter + + # Build HTML with adaptive table styling + $TableDesign = '' + $FinalResults = if ($Results -is [array] -and $Results[0] -is [string]) { + $Results | ConvertTo-Html -Fragment -Property @{ l = 'Text'; e = { $_ } } + } else { + $Results | ConvertTo-Html -Fragment + } + $HTML = $FinalResults -replace '
', "This alert is for tenant $TenantFilter.

$TableDesign
" | Out-String + + # Add alert comment if available + if ($TaskInfo.AlertComment) { + $AlertComment = $TaskInfo.AlertComment + + # Replace %resultcount% variable + if ($AlertComment -match '%resultcount%') { + $resultCount = if ($Results -is [array]) { $Results.Count } else { 1 } + $AlertComment = $AlertComment -replace '%resultcount%', "$resultCount" + } + + # Replace other variables + $AlertComment = Get-CIPPTextReplacement -Text $AlertComment -TenantFilter $TenantFilter + $HTML += "

Alert Information

$AlertComment

" + } + + # Build title + $title = "$TaskType - $TenantFilter - $($TaskInfo.Name)" + if ($TaskInfo.Reference) { + $title += " - Reference: $($TaskInfo.Reference)" + } + + Write-Information 'Scheduler: Sending the results to configured targets.' + + # Send to configured alert targets + switch -wildcard ($TaskInfo.PostExecution) { + '*psa*' { + Send-CIPPAlert -Type 'psa' -Title $title -HTMLContent $HTML -TenantFilter $TenantFilter + } + '*email*' { + Send-CIPPAlert -Type 'email' -Title $title -HTMLContent $HTML -TenantFilter $TenantFilter + } + '*webhook*' { + $Webhook = [PSCustomObject]@{ + 'tenantId' = $TenantInfo.customerId + 'Tenant' = $TenantFilter + 'TaskInfo' = $TaskInfo + 'Results' = $Results + 'AlertComment' = $TaskInfo.AlertComment + } + Send-CIPPAlert -Type 'webhook' -Title $title -TenantFilter $TenantFilter -JSONContent $($Webhook | ConvertTo-Json -Depth 20) + } + } + + Write-Information "Successfully sent alerts for task $($TaskInfo.Name)" + + } catch { + Write-Warning "Failed to send scheduled task alerts: $($_.Exception.Message)" + Write-LogMessage -API 'Scheduler_Alerts' -tenant $TenantFilter -message "Failed to send alerts for task $($TaskInfo.Name): $($_.Exception.Message)" -sev Error + } +} diff --git a/Modules/CIPPCore/Public/Set-CIPPMailboxAccess.ps1 b/Modules/CIPPCore/Public/Set-CIPPMailboxAccess.ps1 index c4ab09866086..b16d867d6f41 100644 --- a/Modules/CIPPCore/Public/Set-CIPPMailboxAccess.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPMailboxAccess.ps1 @@ -2,7 +2,7 @@ function Set-CIPPMailboxAccess { [CmdletBinding()] param ( $userid, - $AccessUser, + [array]$AccessUser, # Can be single value or array of users [bool]$Automap, $TenantFilter, $APIName = 'Manage Shared Mailbox Access', @@ -10,16 +10,33 @@ function Set-CIPPMailboxAccess { [array]$AccessRights ) - try { - $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Add-MailboxPermission' -cmdParams @{Identity = $userid; user = $AccessUser; AutoMapping = $Automap; accessRights = $AccessRights; InheritanceType = 'all' } -Anchor $userid + # Ensure AccessUser is always an array + if ($AccessUser -isnot [array]) { + $AccessUser = @($AccessUser) + } + + # Extract values if objects with .value property (from frontend) + $AccessUser = $AccessUser | ForEach-Object { + if ($_ -is [PSCustomObject] -and $_.value) { $_.value } else { $_ } + } - $Message = "Successfully added $($AccessUser) to $($userid) Shared Mailbox $($Automap ? 'with' : 'without') AutoMapping, with the following permissions: $AccessRights" - Write-LogMessage -headers $Headers -API $APIName -message $Message -Sev 'Info' -tenant $TenantFilter - return $Message - } catch { - $ErrorMessage = Get-CippException -Exception $_ - $Message = "Failed to add mailbox permissions for $($AccessUser) on $($userid). Error: $($ErrorMessage.NormalizedError)" - Write-LogMessage -headers $Headers -API $APIName -message $Message -Sev 'Error' -tenant $TenantFilter -LogData $ErrorMessage - throw $Message + $Results = [system.collections.generic.list[string]]::new() + + # Process each access user + foreach ($User in $AccessUser) { + try { + $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Add-MailboxPermission' -cmdParams @{Identity = $userid; user = $User; AutoMapping = $Automap; accessRights = $AccessRights; InheritanceType = 'all' } -Anchor $userid + + $Message = "Successfully added $($User) to $($userid) Shared Mailbox $($Automap ? 'with' : 'without') AutoMapping, with the following permissions: $AccessRights" + Write-LogMessage -headers $Headers -API $APIName -message $Message -Sev 'Info' -tenant $TenantFilter + $Results.Add($Message) + } catch { + $ErrorMessage = Get-CippException -Exception $_ + $Message = "Failed to add mailbox permissions for $($User) on $($userid). Error: $($ErrorMessage.NormalizedError)" + Write-LogMessage -headers $Headers -API $APIName -message $Message -Sev 'Error' -tenant $TenantFilter -LogData $ErrorMessage + $Results.Add($Message) + } } + + return $Results } diff --git a/Modules/CIPPCore/Public/Set-CIPPSharePointPerms.ps1 b/Modules/CIPPCore/Public/Set-CIPPSharePointPerms.ps1 index ffc4dbd72fd6..525ae41f1f39 100644 --- a/Modules/CIPPCore/Public/Set-CIPPSharePointPerms.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPSharePointPerms.ps1 @@ -2,19 +2,27 @@ function Set-CIPPSharePointPerms { [CmdletBinding()] param ( $UserId, # The UPN or ID of the users OneDrive we are changing permissions on - $OnedriveAccessUser, # The UPN of the user we are adding or removing permissions for + [array]$OnedriveAccessUser, # The UPN(s) of the user(s) we are adding or removing permissions for - can be single value or array $TenantFilter, $APIName = 'Manage SharePoint Owner', $RemovePermission, $Headers, $URL ) - if ($RemovePermission -eq $true) { - $SiteAdmin = 'false' - } else { - $SiteAdmin = 'true' + + # Ensure OnedriveAccessUser is always an array + if ($OnedriveAccessUser -isnot [array]) { + $OnedriveAccessUser = @($OnedriveAccessUser) + } + + # Extract values if objects with .value property (from frontend) + $OnedriveAccessUser = $OnedriveAccessUser | ForEach-Object { + if ($_ -is [PSCustomObject] -and $_.value) { $_.value } else { $_ } } + $SiteAdmin = if ($RemovePermission -eq $true) { 'false' } else { 'true' } + $Results = [system.collections.generic.list[string]]::new() + try { if (!$URL) { Write-Information 'No URL provided, getting URL from Graph' @@ -22,7 +30,11 @@ function Set-CIPPSharePointPerms { } $SharePointInfo = Get-SharePointAdminLink -Public $false -tenantFilter $TenantFilter - $XML = @" + + # Process each access user + foreach ($AccessUser in $OnedriveAccessUser) { + try { + $XML = @" @@ -31,7 +43,7 @@ function Set-CIPPSharePointPerms { $URL - $OnedriveAccessUser + $AccessUser $SiteAdmin @@ -39,20 +51,29 @@ function Set-CIPPSharePointPerms { "@ - $request = New-GraphPostRequest -scope "$($SharePointInfo.AdminUrl)/.default" -tenantid $TenantFilter -Uri "$($SharePointInfo.AdminUrl)/_vti_bin/client.svc/ProcessQuery" -Type POST -Body $XML -ContentType 'text/xml' - # Write-Host $($request) - if (!$request.ErrorInfo.ErrorMessage) { - $Message = "Successfully $($RemovePermission ? 'removed' : 'added') $($OnedriveAccessUser) as an owner of $URL" - Write-LogMessage -headers $Headers -API $APIName -message $Message -Sev Info -tenant $TenantFilter - return $Message - } else { - $Message = "Failed to change access: $($request.ErrorInfo.ErrorMessage)" - Write-LogMessage -headers $Headers -API $APIName -message $Message -Sev Error -tenant $TenantFilter - throw $Message + $request = New-GraphPostRequest -scope "$($SharePointInfo.AdminUrl)/.default" -tenantid $TenantFilter -Uri "$($SharePointInfo.AdminUrl)/_vti_bin/client.svc/ProcessQuery" -Type POST -Body $XML -ContentType 'text/xml' + + if (!$request.ErrorInfo.ErrorMessage) { + $Message = "Successfully $($RemovePermission ? 'removed' : 'added') $($AccessUser) as an owner of $URL" + Write-LogMessage -headers $Headers -API $APIName -message $Message -Sev Info -tenant $TenantFilter + $Results.Add($Message) + } else { + $Message = "Failed to change access for $($AccessUser): $($request.ErrorInfo.ErrorMessage)" + Write-LogMessage -headers $Headers -API $APIName -message $Message -Sev Error -tenant $TenantFilter + $Results.Add($Message) + } + } catch { + $ErrorMessage = Get-CippException -Exception $_ + $Message = "Failed to change access for $($AccessUser) on $URL. Error: $($ErrorMessage.NormalizedError)" + Write-LogMessage -headers $Headers -API $APIName -message $Message -Sev Error -tenant $TenantFilter -LogData $ErrorMessage + $Results.Add($Message) + } } + + return $Results } catch { $ErrorMessage = Get-CippException -Exception $_ - $Message = "Failed to set SharePoint permissions for $($OnedriveAccessUser) on $URL. Error: $($ErrorMessage.NormalizedError)" + $Message = "Failed to process SharePoint permissions. Error: $($ErrorMessage.NormalizedError)" Write-LogMessage -headers $Headers -API $APIName -message $Message -Sev Error -tenant $TenantFilter -LogData $ErrorMessage throw $Message } diff --git a/Modules/CippEntrypoints/CippEntrypoints.psm1 b/Modules/CippEntrypoints/CippEntrypoints.psm1 index bc94fb638e28..08614116fba9 100644 --- a/Modules/CippEntrypoints/CippEntrypoints.psm1 +++ b/Modules/CippEntrypoints/CippEntrypoints.psm1 @@ -360,7 +360,7 @@ function Receive-CippActivityTrigger { try { Write-Verbose "Activity starting Function: $FunctionName." - Invoke-Command -ScriptBlock { & $FunctionName -Item $Item } + $Output = Invoke-Command -ScriptBlock { & $FunctionName -Item $Item } $Status = 'Completed' Write-Verbose "Activity completed Function: $FunctionName." From 646ed28b48dfdc53494529f32a5fe1d92d048c93 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Wed, 18 Feb 2026 12:23:24 -0500 Subject: [PATCH 66/97] Enforce tenant access in application entrypoints Filter selected tenants using Test-CIPPAccess and restrict processing to allowed tenants; add AnyTenant to functionality tags. This change updates Invoke-AddChocoApp, Invoke-AddMSPApp, Invoke-AddOfficeApp and Invoke-AddStoreApp to call Test-CIPPAccess -TenantList, compute $AllowedTenants, and only iterate over tenants present in that list (or 'AllTenants'). Minor doc updates mark these entrypoints as AnyTenant and ensure AllTenants handling remains supported. --- .../Endpoint/Applications/Invoke-AddChocoApp.ps1 | 8 +++++--- .../Endpoint/Applications/Invoke-AddMSPApp.ps1 | 5 +++-- .../Endpoint/Applications/Invoke-AddOfficeApp.ps1 | 6 +++--- .../Endpoint/Applications/Invoke-AddStoreApp.ps1 | 6 +++--- 4 files changed, 14 insertions(+), 11 deletions(-) diff --git a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/Applications/Invoke-AddChocoApp.ps1 b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/Applications/Invoke-AddChocoApp.ps1 index 246ee7071f45..de8bdb8a5172 100644 --- a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/Applications/Invoke-AddChocoApp.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/Applications/Invoke-AddChocoApp.ps1 @@ -1,7 +1,7 @@ -Function Invoke-AddChocoApp { +function Invoke-AddChocoApp { <# .FUNCTIONALITY - Entrypoint + Entrypoint,AnyTenant .ROLE Endpoint.Application.ReadWrite #> @@ -30,7 +30,9 @@ Function Invoke-AddChocoApp { $intuneBody.detectionRules[0].path = "$($ENV:SystemDrive)\programdata\chocolatey\lib" $intuneBody.detectionRules[0].fileOrFolderName = "$($ChocoApp.PackageName)" - $Tenants = $Request.Body.selectedTenants.defaultDomainName + $AllowedTenants = Test-CIPPAccess -Request $Request -TenantList + $Tenants = ($Request.Body.selectedTenants | Where-Object { $AllowedTenants -contains $_.customerId -or $AllowedTenants -contains 'AllTenants' }).defaultDomainName + $Results = foreach ($Tenant in $Tenants) { try { # Apply CIPP text replacement for tenant-specific variables diff --git a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/Applications/Invoke-AddMSPApp.ps1 b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/Applications/Invoke-AddMSPApp.ps1 index bdbc7f7dba1f..fe750f11e6fb 100644 --- a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/Applications/Invoke-AddMSPApp.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/Applications/Invoke-AddMSPApp.ps1 @@ -1,7 +1,7 @@ function Invoke-AddMSPApp { <# .FUNCTIONALITY - Entrypoint + Entrypoint,AnyTenant .ROLE Endpoint.Application.ReadWrite #> @@ -17,7 +17,8 @@ function Invoke-AddMSPApp { $intuneBody = Get-Content "AddMSPApp\$($RMMApp.RMMName.value).app.json" | ConvertFrom-Json $intuneBody.displayName = $RMMApp.DisplayName - $Tenants = $Request.Body.selectedTenants + $AllowedTenants = Test-CIPPAccess -Request $Request -TenantList + $Tenants = $Request.Body.selectedTenants | Where-Object { $AllowedTenants -contains $_.customerId -or $AllowedTenants -contains 'AllTenants' } $Results = foreach ($Tenant in $Tenants) { $InstallParams = [PSCustomObject]$RMMApp.params switch ($RMMApp.RMMName.value) { diff --git a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/Applications/Invoke-AddOfficeApp.ps1 b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/Applications/Invoke-AddOfficeApp.ps1 index 97d65b678542..ff880318819f 100644 --- a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/Applications/Invoke-AddOfficeApp.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/Applications/Invoke-AddOfficeApp.ps1 @@ -1,15 +1,15 @@ function Invoke-AddOfficeApp { <# .FUNCTIONALITY - Entrypoint + Entrypoint,AnyTenant .ROLE Endpoint.Application.ReadWrite #> [CmdletBinding()] param($Request, $TriggerMetadata) - + $AllowedTenants = Test-CIPPAccess -Request $Request -TenantList + $Tenants = ($Request.Body.selectedTenants | Where-Object { $AllowedTenants -contains $_.customerId -or $AllowedTenants -contains 'AllTenants' }).defaultDomainName # Input bindings are passed in via param block. - $Tenants = $Request.Body.selectedTenants.defaultDomainName $Headers = $Request.Headers $APIName = $Request.Params.CIPPEndpoint if ('AllTenants' -in $Tenants) { $Tenants = (Get-Tenants).defaultDomainName } diff --git a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/Applications/Invoke-AddStoreApp.ps1 b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/Applications/Invoke-AddStoreApp.ps1 index af6eb44c1be7..90d4c94285ea 100644 --- a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/Applications/Invoke-AddStoreApp.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/Applications/Invoke-AddStoreApp.ps1 @@ -1,7 +1,7 @@ function Invoke-AddStoreApp { <# .FUNCTIONALITY - Entrypoint + Entrypoint,AnyTenant .ROLE Endpoint.Application.ReadWrite #> @@ -26,8 +26,8 @@ function Invoke-AddStoreApp { 'runAsAccount' = 'system' } } - - $Tenants = $Request.body.selectedTenants.defaultDomainName + $AllowedTenants = Test-CIPPAccess -Request $Request -TenantList + $Tenants = ($Request.Body.selectedTenants | Where-Object { $AllowedTenants -contains $_.customerId -or $AllowedTenants -contains 'AllTenants' }).defaultDomainName $Results = foreach ($Tenant in $Tenants) { try { $CompleteObject = [PSCustomObject]@{ From 5ca0443148b2c94f2b7d54995f595f644e611868 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Wed, 18 Feb 2026 15:41:22 -0500 Subject: [PATCH 67/97] Enable servicePrincipalLockConfiguration in SAM Add a servicePrincipalLockConfiguration entry to Modules/CIPPCore/lib/data/SAMManifest.json with isEnabled: true and allProperties: true. This updates the SAM manifest to include service principal lock settings so the service principal's properties are locked according to the manifest configuration. --- Modules/CIPPCore/lib/data/SAMManifest.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Modules/CIPPCore/lib/data/SAMManifest.json b/Modules/CIPPCore/lib/data/SAMManifest.json index 534b0a29e5de..bbdeaa675acf 100644 --- a/Modules/CIPPCore/lib/data/SAMManifest.json +++ b/Modules/CIPPCore/lib/data/SAMManifest.json @@ -10,6 +10,10 @@ "http://localhost:8400" ] }, + "servicePrincipalLockConfiguration": { + "isEnabled": true, + "allProperties": true + }, "requiredResourceAccess": [ { "resourceAppId": "c5393580-f805-4401-95e8-94b7a6ef2fc2", From 68a5d082a553138e11bde1a847ef526cfd944703 Mon Sep 17 00:00:00 2001 From: Bobby <31723128+kris6673@users.noreply.github.com> Date: Thu, 19 Feb 2026 15:27:36 +0100 Subject: [PATCH 68/97] feat: enhance SendFromAlias standard to be able to disable too --- .../Invoke-CIPPStandardSendFromAlias.ps1 | 47 ++++++++++--------- 1 file changed, 24 insertions(+), 23 deletions(-) diff --git a/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardSendFromAlias.ps1 b/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardSendFromAlias.ps1 index 8dc6a4af027b..94b377657fdc 100644 --- a/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardSendFromAlias.ps1 +++ b/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardSendFromAlias.ps1 @@ -5,9 +5,9 @@ function Invoke-CIPPStandardSendFromAlias { .COMPONENT (APIName) SendFromAlias .SYNOPSIS - (Label) Allow users to send from their alias addresses + (Label) Set Send from alias state .DESCRIPTION - (Helptext) Enables the ability for users to send from their alias addresses. + (Helptext) Enables or disables the ability for users to send from their alias addresses. (DocsDescription) Allows users to change the 'from' address to any set in their Azure AD Profile. .NOTES CAT @@ -16,6 +16,7 @@ function Invoke-CIPPStandardSendFromAlias { EXECUTIVETEXT Allows employees to send emails from their alternative email addresses (aliases) rather than just their primary address. This is useful for employees who manage multiple roles or departments, enabling them to send emails from the most appropriate address for the context. ADDEDCOMPONENT + {"type":"autoComplete","multiple":false,"creatable":false,"label":"Select value","name":"standards.SendFromAlias.state","options":[{"label":"Enabled","value":"true"},{"label":"Disabled","value":"false"}]} IMPACT Medium Impact ADDEDDATE @@ -40,43 +41,43 @@ function Invoke-CIPPStandardSendFromAlias { try { $CurrentInfo = (New-ExoRequest -tenantid $Tenant -cmdlet 'Get-OrganizationConfig').SendFromAliasEnabled } catch { - $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message - Write-LogMessage -API 'Standards' -Tenant $Tenant -Message "Could not get the SendFromAlias state for $Tenant. Error: $ErrorMessage" -Sev Error + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'Standards' -Tenant $Tenant -Message "Could not get the SendFromAlias state for $Tenant. Error: $($ErrorMessage.NormalizedError)" -Sev Error -LogData $ErrorMessage return } + # Backwards compat: existing configs have no state (null) → default to 'true' (original behavior). For pre v10.1 + $state = $Settings.state.value ?? $Settings.state ?? 'true' + $WantedState = [System.Convert]::ToBoolean($state) + if ($Settings.remediate -eq $true) { - if ($CurrentInfo -ne $true) { + if ($CurrentInfo -ne $WantedState) { try { - New-ExoRequest -tenantid $Tenant -cmdlet 'Set-OrganizationConfig' -cmdParams @{ SendFromAliasEnabled = $true } - Write-LogMessage -API 'Standards' -tenant $tenant -message 'Send from alias enabled.' -sev Info - $CurrentInfo = $true + New-ExoRequest -tenantid $Tenant -cmdlet 'Set-OrganizationConfig' -cmdParams @{ SendFromAliasEnabled = $WantedState } + Write-LogMessage -API 'Standards' -tenant $Tenant -message "Send from alias set to $state." -sev Info + $CurrentInfo = $WantedState } catch { - $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message - Write-LogMessage -API 'Standards' -tenant $tenant -message "Failed to enable send from alias. Error: $ErrorMessage" -sev Error + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'Standards' -tenant $Tenant -message "Failed to set send from alias to $state. Error: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage } } else { - Write-LogMessage -API 'Standards' -tenant $tenant -message 'Send from alias is already enabled.' -sev Info + Write-LogMessage -API 'Standards' -tenant $Tenant -message "Send from alias is already set to $state." -sev Info } } if ($Settings.alert -eq $true) { - if ($CurrentInfo -eq $true) { - Write-LogMessage -API 'Standards' -tenant $tenant -message 'Send from alias is enabled.' -sev Info + if ($CurrentInfo -eq $WantedState) { + Write-LogMessage -API 'Standards' -tenant $Tenant -message "Send from alias is set to $state." -sev Info } else { - Write-StandardsAlert -message 'Send from alias is not enabled' -object $CurrentInfo -tenant $tenant -standardName 'SendFromAlias' -standardId $Settings.standardId - Write-LogMessage -API 'Standards' -tenant $tenant -message 'Send from alias is not enabled.' -sev Info + Write-StandardsAlert -message "Send from alias is not set to $state" -object $CurrentInfo -tenant $Tenant -standardName 'SendFromAlias' -standardId $Settings.standardId + Write-LogMessage -API 'Standards' -tenant $Tenant -message "Send from alias is not set to $state." -sev Info } } if ($Settings.report -eq $true) { - Add-CIPPBPAField -FieldName 'SendFromAlias' -FieldValue $CurrentInfo -StoreAs bool -Tenant $tenant - $CurrentValue = @{ - SendFromAliasEnabled = $CurrentInfo - } - $ExpectedValue = @{ - SendFromAliasEnabled = $true - } - Set-CIPPStandardsCompareField -FieldName 'standards.SendFromAlias' -CurrentValue $CurrentValue -ExpectedValue $ExpectedValue -Tenant $tenant + Add-CIPPBPAField -FieldName 'SendFromAlias' -FieldValue $CurrentInfo -StoreAs bool -Tenant $Tenant + $CurrentValue = @{ SendFromAliasEnabled = $CurrentInfo } + $ExpectedValue = @{ SendFromAliasEnabled = $WantedState } + Set-CIPPStandardsCompareField -FieldName 'standards.SendFromAlias' -CurrentValue $CurrentValue -ExpectedValue $ExpectedValue -Tenant $Tenant } } From 0452567e662303da8c11e03b91f102a2d4b8ed16 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Thu, 19 Feb 2026 09:55:29 -0500 Subject: [PATCH 69/97] Add app lock config Update Start-UpdateTokensTimer.ps1 to include servicePrincipalLockConfiguration in the Graph GET response, rename variables for clarity. Check servicePrincipalLockConfiguration; if it's not enabled, enable it via a PATCH request and write an informational log entry. --- .../Start-UpdateTokensTimer.ps1 | 20 ++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-UpdateTokensTimer.ps1 b/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-UpdateTokensTimer.ps1 index 416318bb4438..8b9695149aab 100644 --- a/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-UpdateTokensTimer.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-UpdateTokensTimer.ps1 @@ -39,12 +39,12 @@ function Start-UpdateTokensTimer { # Check application secret expiration for $env:ApplicationId and generate a new application secret if expiration is within 30 days. try { $AppId = $env:ApplicationID - $PasswordCredentials = New-GraphGetRequest -uri "https://graph.microsoft.com/v1.0/applications(appId='$AppId')?`$select=id,passwordCredentials" -NoAuthCheck $true -AsApp $true -ErrorAction Stop + $AppRegistration = New-GraphGetRequest -uri "https://graph.microsoft.com/v1.0/applications(appId='$AppId')?`$select=id,passwordCredentials,servicePrincipalLockConfiguration" -NoAuthCheck $true -AsApp $true -ErrorAction Stop # sort by latest expiration date and get the first one - $LastPasswordCredential = $PasswordCredentials.passwordCredentials | Sort-Object -Property endDateTime -Descending | Select-Object -First 1 + $LastPasswordCredential = $AppRegistration.passwordCredentials | Sort-Object -Property endDateTime -Descending | Select-Object -First 1 if ($LastPasswordCredential.endDateTime -lt (Get-Date).AddDays(30).ToUniversalTime()) { Write-Information "Application secret for $AppId is expiring soon. Generating a new application secret." - $AppSecret = New-GraphPostRequest -uri "https://graph.microsoft.com/v1.0/applications/$($PasswordCredentials.id)/addPassword" -Body '{"passwordCredential":{"displayName":"UpdateTokens"}}' -NoAuthCheck $true -AsApp $true -ErrorAction Stop + $AppSecret = New-GraphPostRequest -uri "https://graph.microsoft.com/v1.0/applications/$($AppRegistration.id)/addPassword" -Body '{"passwordCredential":{"displayName":"UpdateTokens"}}' -NoAuthCheck $true -AsApp $true -ErrorAction Stop Write-Information "New application secret generated for $AppId. Expiration date: $($AppSecret.endDateTime)." } else { Write-Information "Application secret for $AppId is valid until $($LastPasswordCredential.endDateTime). No need to generate a new application secret." @@ -77,6 +77,20 @@ function Start-UpdateTokensTimer { } else { Write-Information "No expired application secrets found for $AppId." } + + if (!$AppRegistration.servicePrincipalLockConfiguration.isEnabled) { + Write-Warning "Service principal lock configuration is not enabled for $AppId" + $Body = @{ + servicePrincipalLockConfiguration = @{ + isEnabled = $true + allProperties = $true + } + } | ConvertTo-Json + New-GraphPOSTRequest -type PATCH -uri "https://graph.microsoft.com/v1.0/applications/$($AppRegistration.id)" -Body $Body -NoAuthCheck $true -AsApp $true -ErrorAction Stop + Write-Information "Service principal lock configuration has been enabled for application $AppId." + Write-LogMessage -API 'Update Tokens' -message "Service principal lock configuration has been enabled for application $AppId." -sev 'Info' + } + } catch { Write-Warning "Error updating application secret $($_.Exception.Message)." Write-Information ($_.InvocationInfo.PositionMessage) From 4447d609c7628527436ed23a7225d268f177b5ee Mon Sep 17 00:00:00 2001 From: John Duprey Date: Thu, 19 Feb 2026 10:54:04 -0500 Subject: [PATCH 70/97] Add app management policy helper Introduce Update-AppManagementPolicy.ps1 which queries tenant default and app management policies via bulk Graph requests, detects credential creation restrictions, and creates/updates/assigns a "CIPP-SAM Exemption Policy" to allow the CIPP-SAM app to manage credentials. The function returns a PSCustomObject with policy state and a PolicyAction message and handles errors gracefully. Also update Invoke-ExecCreateSAMApp.ps1 and Start-UpdateTokensTimer.ps1 to call Update-AppManagementPolicy and log the resulting PolicyAction before proceeding with password/key operations. --- .../CIPP/Setup/Invoke-ExecCreateSAMApp.ps1 | 4 + .../Start-UpdateTokensTimer.ps1 | 4 + .../Update-AppManagementPolicy.ps1 | 237 ++++++++++++++++++ 3 files changed, 245 insertions(+) create mode 100644 Modules/CIPPCore/Public/GraphHelper/Update-AppManagementPolicy.ps1 diff --git a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/CIPP/Setup/Invoke-ExecCreateSAMApp.ps1 b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/CIPP/Setup/Invoke-ExecCreateSAMApp.ps1 index 3e00555ce247..75569685738a 100644 --- a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/CIPP/Setup/Invoke-ExecCreateSAMApp.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/CIPP/Setup/Invoke-ExecCreateSAMApp.ps1 @@ -68,6 +68,10 @@ function Invoke-ExecCreateSAMApp { } } until ($attempt -gt 3) } + + $AppPolicyStatus = Update-AppManagementPolicy + Write-Information $AppPolicyStatus.PolicyAction + $AppPassword = (Invoke-RestMethod "https://graph.microsoft.com/v1.0/applications/$($AppId.id)/addPassword" -Headers @{ authorization = "Bearer $($Token.access_token)" } -Method POST -Body '{"passwordCredential":{"displayName":"CIPPInstall"}}' -ContentType 'application/json').secretText if ($env:AzureWebJobsStorage -eq 'UseDevelopmentStorage=true' -or $env:NonLocalHostAzurite -eq 'true') { diff --git a/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-UpdateTokensTimer.ps1 b/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-UpdateTokensTimer.ps1 index 8b9695149aab..ecfff8c18feb 100644 --- a/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-UpdateTokensTimer.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-UpdateTokensTimer.ps1 @@ -42,6 +42,10 @@ function Start-UpdateTokensTimer { $AppRegistration = New-GraphGetRequest -uri "https://graph.microsoft.com/v1.0/applications(appId='$AppId')?`$select=id,passwordCredentials,servicePrincipalLockConfiguration" -NoAuthCheck $true -AsApp $true -ErrorAction Stop # sort by latest expiration date and get the first one $LastPasswordCredential = $AppRegistration.passwordCredentials | Sort-Object -Property endDateTime -Descending | Select-Object -First 1 + + $AppPolicyStatus = Update-AppManagementPolicy + Write-Information $AppPolicyStatus.PolicyAction + if ($LastPasswordCredential.endDateTime -lt (Get-Date).AddDays(30).ToUniversalTime()) { Write-Information "Application secret for $AppId is expiring soon. Generating a new application secret." $AppSecret = New-GraphPostRequest -uri "https://graph.microsoft.com/v1.0/applications/$($AppRegistration.id)/addPassword" -Body '{"passwordCredential":{"displayName":"UpdateTokens"}}' -NoAuthCheck $true -AsApp $true -ErrorAction Stop diff --git a/Modules/CIPPCore/Public/GraphHelper/Update-AppManagementPolicy.ps1 b/Modules/CIPPCore/Public/GraphHelper/Update-AppManagementPolicy.ps1 new file mode 100644 index 000000000000..3655919169c5 --- /dev/null +++ b/Modules/CIPPCore/Public/GraphHelper/Update-AppManagementPolicy.ps1 @@ -0,0 +1,237 @@ +function Update-AppManagementPolicy { + <# + .SYNOPSIS + Check and update app management policies for credential restrictions + + .DESCRIPTION + Retrieves tenant default app management policy and app management policies to check if + passwordCredential or keyCredential creation is restricted. If the default policy blocks + credential addition and CIPP-SAM app doesn't have an exemption, creates or updates a policy + to allow CIPP-SAM to manage credentials. + + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param() + + try { + # Create bulk request to fetch both policies at once + $Requests = @( + @{ + id = 'defaultPolicy' + method = 'GET' + url = '/policies/defaultAppManagementPolicy' + } + @{ + id = 'appPolicies' + method = 'GET' + url = '/policies/appManagementPolicies' + } + @{ + id = 'appRegistration' + method = 'GET' + url = "applications(appId='$env:ApplicationID')?`$select=id,appId,displayName" + } + ) + + # Execute bulk request + $Results = New-GraphBulkRequest -Requests $Requests -NoAuthCheck $true -asapp $true + + # Parse results + $DefaultPolicy = ($Results | Where-Object { $_.id -eq 'defaultPolicy' }).body + $AppPolicies = ($Results | Where-Object { $_.id -eq 'appPolicies' }).body.value + $CIPPApp = ($Results | Where-Object { $_.id -eq 'appRegistration' }).body + + # Check if CIPP-SAM app is targeted by any policies + $CIPPAppTargeted = $false + $CIPPAppPolicyId = $null + if ($AppPolicies -and $env:ApplicationID) { + # Build bulk requests to get appliesTo for each policy + $AppliesToRequests = @($AppPolicies | ForEach-Object { + @{ + id = $_.id + method = 'GET' + url = "/policies/appManagementPolicies/$($_.id)/appliesTo" + } + }) + + if ($AppliesToRequests.Count -gt 0) { + $AppliesToResults = New-GraphBulkRequest -Requests $AppliesToRequests -NoAuthCheck $true -asapp $true + + # Find which policy (if any) targets CIPP Ap + $CIPPPolicyResult = $AppliesToResults | Where-Object { $_.body.value.appId -contains $env:ApplicationID } | Select-Object -First 1 + if ($CIPPPolicyResult) { + $CIPPAppTargeted = $true + $CIPPAppPolicyId = $CIPPPolicyResult.id + } + } + } + + # Check for credential restrictions across all policies + $PasswordAdditionBlocked = $false + $SymmetricKeyAdditionBlocked = $false + $AsymmetricKeyAdditionBlocked = $false + $PasswordLifetimeRestricted = $false + $KeyLifetimeRestricted = $false + + # Helper function to check restrictions in a policy + function Test-PolicyRestrictions { + param($Policy, [switch]$IsDefaultPolicy) + + # Default policy has applicationRestrictions, app-specific policies have restrictions + $pwdCreds = if ($IsDefaultPolicy) { $Policy.applicationRestrictions.passwordCredentials } else { $Policy.restrictions.passwordCredentials } + $keyCreds = if ($IsDefaultPolicy) { $Policy.applicationRestrictions.keyCredentials } else { $Policy.restrictions.keyCredentials } + + if ($pwdCreds) { + foreach ($restriction in $pwdCreds | Where-Object { $_.state -eq 'enabled' }) { + switch ($restriction.restrictionType) { + 'passwordAddition' { $PasswordAdditionBlocked = $true } + 'symmetricKeyAddition' { $SymmetricKeyAdditionBlocked = $true } + 'passwordLifetime' { $PasswordLifetimeRestricted = $true } + 'symmetricKeyLifetime' { $PasswordLifetimeRestricted = $true } + } + } + } + + if ($keyCreds) { + foreach ($restriction in $keyCreds | Where-Object { $_.state -eq 'enabled' }) { + switch ($restriction.restrictionType) { + 'asymmetricKeyLifetime' { $KeyLifetimeRestricted = $true } + 'trustedCertificateAuthority' { $AsymmetricKeyAdditionBlocked = $true } + } + } + } + } + + # Check default policy (uses applicationRestrictions structure) + if ($DefaultPolicy) { + Test-PolicyRestrictions -Policy $DefaultPolicy -IsDefaultPolicy + } + + # Check app-specific policies (use restrictions structure) + if ($AppPolicies) { + foreach ($AppPolicy in $AppPolicies | Where-Object { $_.isEnabled -eq $true }) { + Test-PolicyRestrictions -Policy $AppPolicy + } + } + + # Determine if default policy blocks credential addition + $DefaultPolicyBlocksCredentials = $false + if ($DefaultPolicy.applicationRestrictions.passwordCredentials) { + $DefaultPolicyBlocksCredentials = ($DefaultPolicy.applicationRestrictions.passwordCredentials | Where-Object { $_.restrictionType -in @('passwordAddition', 'symmetricKeyAddition') -and $_.state -eq 'enabled' }).Count -gt 0 + } + + # If default policy blocks credentials and CIPP app doesn't have an exemption, create/update policy + $PolicyAction = $null + if ($DefaultPolicyBlocksCredentials -and $CIPPApp) { + # Check if a CIPP-SAM Exemption Policy already exists + $ExistingExemptionPolicy = $AppPolicies | Where-Object { $_.displayName -eq 'CIPP-SAM Exemption Policy' } | Select-Object -First 1 + + # Check if CIPP app has a policy that allows credentials + $CIPPHasExemption = $false + if ($CIPPAppPolicyId) { + $CIPPPolicy = $AppPolicies | Where-Object { $_.id -eq $CIPPAppPolicyId } + # Check if the policy explicitly allows credentials (no enabled passwordAddition/symmetricKeyAddition restriction) + if ($CIPPPolicy.restrictions.passwordCredentials) { + $CIPPHasExemption = -not ($CIPPPolicy.restrictions.passwordCredentials | Where-Object { $_.restrictionType -in @('passwordAddition', 'symmetricKeyAddition') -and $_.state -eq 'enabled' }) + } else { + # No password restrictions means it allows credentials + $CIPPHasExemption = $true + } + } + + if (-not $CIPPHasExemption) { + # Need to create or update a policy for CIPP-SAM + try { + # Define policy structure with disabled restrictions + $PolicyBody = @{ + displayName = 'CIPP-SAM Exemption Policy' + description = 'Allows CIPP-SAM app to manage credentials' + isEnabled = $true + restrictions = @{ + passwordCredentials = @( + @{ + restrictionType = 'passwordAddition' + state = 'disabled' + restrictForAppsCreatedAfterDateTime = '0001-01-01T00:00:00Z' + } + @{ + restrictionType = 'symmetricKeyAddition' + state = 'disabled' + restrictForAppsCreatedAfterDateTime = '0001-01-01T00:00:00Z' + } + ) + keyCredentials = @() + } + } + + if ($CIPPAppPolicyId) { + # Update existing policy that's already assigned to the app + $null = New-GraphPostRequest -uri "https://graph.microsoft.com/v1.0/policies/appManagementPolicies/$CIPPAppPolicyId" -type PATCH -body ($PolicyBody | ConvertTo-Json -Depth 10) -asapp $true -NoAuthCheck $true + $PolicyAction = "Updated existing policy $CIPPAppPolicyId to allow credentials" + } elseif ($ExistingExemptionPolicy) { + # Exemption policy exists but not assigned to app - update and assign it + $null = New-GraphPostRequest -uri "https://graph.microsoft.com/v1.0/policies/appManagementPolicies/$($ExistingExemptionPolicy.id)" -type PATCH -body ($PolicyBody | ConvertTo-Json -Depth 10) -asapp $true -NoAuthCheck $true + + if ($CIPPApp.id) { + # Assign existing policy to CIPP-SAM application + $AssignBody = @{ + '@odata.id' = "https://graph.microsoft.com/beta/policies/appManagementPolicies/$($ExistingExemptionPolicy.id)" + } + $null = New-GraphPostRequest -uri "https://graph.microsoft.com/beta/applications/$($CIPPApp.id)/appManagementPolicies/`$ref" -type POST -body ($AssignBody | ConvertTo-Json) -asapp $true -NoAuthCheck $true + $PolicyAction = "Updated and assigned existing policy $($ExistingExemptionPolicy.id) to CIPP-SAM" + $CIPPAppPolicyId = $ExistingExemptionPolicy.id + $CIPPAppTargeted = $true + } else { + $PolicyAction = "Updated policy $($ExistingExemptionPolicy.id) but failed to assign: CIPP application not found" + } + } else { + # Create new policy and assign to CIPP-SAM app + $CreatedPolicy = New-GraphPostRequest -uri 'https://graph.microsoft.com/v1.0/policies/appManagementPolicies' -type POST -body ($PolicyBody | ConvertTo-Json -Depth 10) -asapp $true -NoAuthCheck $true + + if ($CIPPApp.id) { + # Assign policy to CIPP-SAM application using beta endpoint + $AssignBody = @{ + '@odata.id' = "https://graph.microsoft.com/beta/policies/appManagementPolicies/$($CreatedPolicy.id)" + } + $null = New-GraphPostRequest -uri "https://graph.microsoft.com/beta/applications/$($CIPPApp.id)/appManagementPolicies/`$ref" -type POST -body ($AssignBody | ConvertTo-Json) -asapp $true -NoAuthCheck $true + $PolicyAction = "Created new policy $($CreatedPolicy.id) and assigned to CIPP-SAM" + $CIPPAppPolicyId = $CreatedPolicy.id + $CIPPAppTargeted = $true + } else { + $PolicyAction = "Created new policy $($CreatedPolicy.id) but failed to assign: CIPP application not found" + } + } + } catch { + $PolicyAction = "Failed to update policy: $($_.Exception.Message)" + } + } else { + $PolicyAction = 'CIPP-SAM app is already exempt from credential restrictions. No action needed.' + } + } + + # Build result object + $PolicyInfo = [PSCustomObject]@{ + DefaultPolicy = $DefaultPolicy + AppPolicies = $AppPolicies + CIPPAppTargeted = $CIPPAppTargeted + CIPPAppPolicyId = $CIPPAppPolicyId + CIPPHasExemption = $CIPPHasExemption + PolicyAction = $PolicyAction + PasswordAdditionBlocked = $PasswordAdditionBlocked + SymmetricKeyAdditionBlocked = $SymmetricKeyAdditionBlocked + PasswordLifetimeRestricted = $PasswordLifetimeRestricted + KeyLifetimeRestricted = $KeyLifetimeRestricted + AnyCredentialCreationRestricted = $PasswordAdditionBlocked -or $SymmetricKeyAdditionBlocked + PolicyCount = if ($AppPolicies) { $AppPolicies.Count } else { 0 } + EnabledPolicyCount = if ($AppPolicies) { ($AppPolicies | Where-Object { $_.isEnabled -eq $true }).Count } else { 0 } + } + + return $PolicyInfo + + } catch { + Write-Warning "Failed to retrieve app management policies: $($_.Exception.Message)" + return $null + } +} From 49d4bcff72c26870cf09c790feac9701aff264f6 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Thu, 19 Feb 2026 10:55:18 -0500 Subject: [PATCH 71/97] Handle errors from Update-AppManagementPolicy Wrap calls to Update-AppManagementPolicy in try/catch in two entrypoints to avoid unhandled exceptions and improve diagnostics. Files changed: Invoke-ExecCreateSAMApp.ps1 and Start-UpdateTokensTimer.ps1. On success the original PolicyAction is still written; on failure a warning with the exception message is logged and the invocation position info is emitted to aid troubleshooting. --- .../CIPP/Setup/Invoke-ExecCreateSAMApp.ps1 | 9 +++++++-- .../Timer Functions/Start-UpdateTokensTimer.ps1 | 9 +++++++-- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/CIPP/Setup/Invoke-ExecCreateSAMApp.ps1 b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/CIPP/Setup/Invoke-ExecCreateSAMApp.ps1 index 75569685738a..202d6e4dc182 100644 --- a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/CIPP/Setup/Invoke-ExecCreateSAMApp.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/CIPP/Setup/Invoke-ExecCreateSAMApp.ps1 @@ -69,8 +69,13 @@ function Invoke-ExecCreateSAMApp { } until ($attempt -gt 3) } - $AppPolicyStatus = Update-AppManagementPolicy - Write-Information $AppPolicyStatus.PolicyAction + try { + $AppPolicyStatus = Update-AppManagementPolicy + Write-Information $AppPolicyStatus.PolicyAction + } catch { + Write-Warning "Error updating app management policy $($_.Exception.Message)." + Write-Information ($_.InvocationInfo.PositionMessage) + } $AppPassword = (Invoke-RestMethod "https://graph.microsoft.com/v1.0/applications/$($AppId.id)/addPassword" -Headers @{ authorization = "Bearer $($Token.access_token)" } -Method POST -Body '{"passwordCredential":{"displayName":"CIPPInstall"}}' -ContentType 'application/json').secretText diff --git a/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-UpdateTokensTimer.ps1 b/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-UpdateTokensTimer.ps1 index ecfff8c18feb..037203bb9952 100644 --- a/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-UpdateTokensTimer.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-UpdateTokensTimer.ps1 @@ -43,8 +43,13 @@ function Start-UpdateTokensTimer { # sort by latest expiration date and get the first one $LastPasswordCredential = $AppRegistration.passwordCredentials | Sort-Object -Property endDateTime -Descending | Select-Object -First 1 - $AppPolicyStatus = Update-AppManagementPolicy - Write-Information $AppPolicyStatus.PolicyAction + try { + $AppPolicyStatus = Update-AppManagementPolicy + Write-Information $AppPolicyStatus.PolicyAction + } catch { + Write-Warning "Error updating app management policy $($_.Exception.Message)." + Write-Information ($_.InvocationInfo.PositionMessage) + } if ($LastPasswordCredential.endDateTime -lt (Get-Date).AddDays(30).ToUniversalTime()) { Write-Information "Application secret for $AppId is expiring soon. Generating a new application secret." From 55ec43f81a60e74b3c42a72124a6c3e7129e947b Mon Sep 17 00:00:00 2001 From: John Duprey Date: Thu, 19 Feb 2026 11:08:25 -0500 Subject: [PATCH 72/97] Update app management policy handling Call Update-AppManagementPolicy after creating apps/SPs and make the policy helper tenant- and app-aware. - New-CIPPAPIConfig.ps1 & Invoke-ExecSendPush.ps1: add try/catch calls to Update-AppManagementPolicy immediately after creating the application/service principal and log the result or failure. - Update-AppManagementPolicy.ps1: add parameters (TenantFilter, ApplicationId) instead of relying on environment variables; pass tenantid into Graph requests; check the provided ApplicationId when evaluating policy targets; rename exemption policy displayName/description from "CIPP-SAM Exemption Policy" to "CIPP Exemption Policy" and adjust related logic; ensure updates/assignments use the tenant scope. These changes ensure newly created apps get an exemption when tenant defaults block credential creation and allow the helper to operate across explicit tenants and application IDs. --- .../Authentication/New-CIPPAPIConfig.ps1 | 8 +++++ .../Users/Invoke-ExecSendPush.ps1 | 6 ++++ .../Update-AppManagementPolicy.ps1 | 33 ++++++++++--------- 3 files changed, 32 insertions(+), 15 deletions(-) diff --git a/Modules/CIPPCore/Public/Authentication/New-CIPPAPIConfig.ps1 b/Modules/CIPPCore/Public/Authentication/New-CIPPAPIConfig.ps1 index 948d2a17f1fa..0ddd57a18fd2 100644 --- a/Modules/CIPPCore/Public/Authentication/New-CIPPAPIConfig.ps1 +++ b/Modules/CIPPCore/Public/Authentication/New-CIPPAPIConfig.ps1 @@ -65,6 +65,14 @@ function New-CIPPAPIConfig { Write-Information $CreateBody $Step = 'Creating Application' $APIApp = New-GraphPOSTRequest -uri 'https://graph.microsoft.com/v1.0/applications' -AsApp $true -NoAuthCheck $true -type POST -body $CreateBody + + try { + $PolicyUpdate = Update-AppManagementPolicy -ApplicationId $APIApp.appId + Write-Information $PolicyUpdate.PolicyAction + } catch { + Write-Information "Failed to update app management policy: $($_.Exception.Message)" + } + Write-Information 'Creating password' $Step = 'Creating Application Password' $APIPassword = New-GraphPOSTRequest -uri "https://graph.microsoft.com/v1.0/applications/$($APIApp.id)/addPassword" -AsApp $true -NoAuthCheck $true -type POST -body "{`"passwordCredential`":{`"displayName`":`"Generated by API Setup`"}}" -maxRetries 3 diff --git a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ExecSendPush.ps1 b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ExecSendPush.ps1 index 93d534a2a37e..f04ecb0608fc 100644 --- a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ExecSendPush.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ExecSendPush.ps1 @@ -51,6 +51,12 @@ function Invoke-ExecSendPush { $SPID = (New-GraphPostRequest -uri 'https://graph.microsoft.com/v1.0/servicePrincipals' -tenantid $TenantFilter -type POST -body $SPBody -AsApp $true).id } + try { + $PolicyUpdate = Update-AppManagementPolicy -TenantFilter $TenantFilter -ApplicationId $MFAAppID + Write-Information $PolicyUpdate.PolicyAction + } catch { + Write-Information "Failed to update app management policy: $($_.Exception.Message)" + } $PassReqBody = @{ 'passwordCredential' = @{ diff --git a/Modules/CIPPCore/Public/GraphHelper/Update-AppManagementPolicy.ps1 b/Modules/CIPPCore/Public/GraphHelper/Update-AppManagementPolicy.ps1 index 3655919169c5..1c5e20ae81df 100644 --- a/Modules/CIPPCore/Public/GraphHelper/Update-AppManagementPolicy.ps1 +++ b/Modules/CIPPCore/Public/GraphHelper/Update-AppManagementPolicy.ps1 @@ -6,14 +6,17 @@ function Update-AppManagementPolicy { .DESCRIPTION Retrieves tenant default app management policy and app management policies to check if passwordCredential or keyCredential creation is restricted. If the default policy blocks - credential addition and CIPP-SAM app doesn't have an exemption, creates or updates a policy - to allow CIPP-SAM to manage credentials. + credential addition and the targeted app doesn't have an exemption, creates or updates a policy + to allow the app to manage credentials. .FUNCTIONALITY Internal #> [CmdletBinding()] - param() + param( + $TenantFilter = $env:TenantID, + $ApplicationId = $env:ApplicationID + ) try { # Create bulk request to fetch both policies at once @@ -31,12 +34,12 @@ function Update-AppManagementPolicy { @{ id = 'appRegistration' method = 'GET' - url = "applications(appId='$env:ApplicationID')?`$select=id,appId,displayName" + url = "applications(appId='$ApplicationId')?`$select=id,appId,displayName" } ) # Execute bulk request - $Results = New-GraphBulkRequest -Requests $Requests -NoAuthCheck $true -asapp $true + $Results = New-GraphBulkRequest -Requests $Requests -NoAuthCheck $true -asapp $true -tenantid $TenantFilter # Parse results $DefaultPolicy = ($Results | Where-Object { $_.id -eq 'defaultPolicy' }).body @@ -46,7 +49,7 @@ function Update-AppManagementPolicy { # Check if CIPP-SAM app is targeted by any policies $CIPPAppTargeted = $false $CIPPAppPolicyId = $null - if ($AppPolicies -and $env:ApplicationID) { + if ($AppPolicies -and $ApplicationId) { # Build bulk requests to get appliesTo for each policy $AppliesToRequests = @($AppPolicies | ForEach-Object { @{ @@ -57,10 +60,10 @@ function Update-AppManagementPolicy { }) if ($AppliesToRequests.Count -gt 0) { - $AppliesToResults = New-GraphBulkRequest -Requests $AppliesToRequests -NoAuthCheck $true -asapp $true + $AppliesToResults = New-GraphBulkRequest -Requests $AppliesToRequests -NoAuthCheck $true -asapp $true -tenantid $TenantFilter - # Find which policy (if any) targets CIPP Ap - $CIPPPolicyResult = $AppliesToResults | Where-Object { $_.body.value.appId -contains $env:ApplicationID } | Select-Object -First 1 + # Find which policy (if any) targets the app + $CIPPPolicyResult = $AppliesToResults | Where-Object { $_.body.value.appId -contains $ApplicationId } | Select-Object -First 1 if ($CIPPPolicyResult) { $CIPPAppTargeted = $true $CIPPAppPolicyId = $CIPPPolicyResult.id @@ -126,7 +129,7 @@ function Update-AppManagementPolicy { $PolicyAction = $null if ($DefaultPolicyBlocksCredentials -and $CIPPApp) { # Check if a CIPP-SAM Exemption Policy already exists - $ExistingExemptionPolicy = $AppPolicies | Where-Object { $_.displayName -eq 'CIPP-SAM Exemption Policy' } | Select-Object -First 1 + $ExistingExemptionPolicy = $AppPolicies | Where-Object { $_.displayName -eq 'CIPP Exemption Policy' } | Select-Object -First 1 # Check if CIPP app has a policy that allows credentials $CIPPHasExemption = $false @@ -142,12 +145,12 @@ function Update-AppManagementPolicy { } if (-not $CIPPHasExemption) { - # Need to create or update a policy for CIPP-SAM + # Need to create or update a policy for CIPP try { # Define policy structure with disabled restrictions $PolicyBody = @{ - displayName = 'CIPP-SAM Exemption Policy' - description = 'Allows CIPP-SAM app to manage credentials' + displayName = 'CIPP Exemption Policy' + description = 'Allows CIPP app to manage credentials' isEnabled = $true restrictions = @{ passwordCredentials = @( @@ -168,7 +171,7 @@ function Update-AppManagementPolicy { if ($CIPPAppPolicyId) { # Update existing policy that's already assigned to the app - $null = New-GraphPostRequest -uri "https://graph.microsoft.com/v1.0/policies/appManagementPolicies/$CIPPAppPolicyId" -type PATCH -body ($PolicyBody | ConvertTo-Json -Depth 10) -asapp $true -NoAuthCheck $true + $null = New-GraphPostRequest -uri "https://graph.microsoft.com/v1.0/policies/appManagementPolicies/$CIPPAppPolicyId" -type PATCH -body ($PolicyBody | ConvertTo-Json -Depth 10) -asapp $true -NoAuthCheck $true -tenantid $TenantFilter $PolicyAction = "Updated existing policy $CIPPAppPolicyId to allow credentials" } elseif ($ExistingExemptionPolicy) { # Exemption policy exists but not assigned to app - update and assign it @@ -179,7 +182,7 @@ function Update-AppManagementPolicy { $AssignBody = @{ '@odata.id' = "https://graph.microsoft.com/beta/policies/appManagementPolicies/$($ExistingExemptionPolicy.id)" } - $null = New-GraphPostRequest -uri "https://graph.microsoft.com/beta/applications/$($CIPPApp.id)/appManagementPolicies/`$ref" -type POST -body ($AssignBody | ConvertTo-Json) -asapp $true -NoAuthCheck $true + $null = New-GraphPostRequest -uri "https://graph.microsoft.com/beta/applications/$($CIPPApp.id)/appManagementPolicies/`$ref" -type POST -body ($AssignBody | ConvertTo-Json) -asapp $true -NoAuthCheck $true -tenantid $TenantFilter $PolicyAction = "Updated and assigned existing policy $($ExistingExemptionPolicy.id) to CIPP-SAM" $CIPPAppPolicyId = $ExistingExemptionPolicy.id $CIPPAppTargeted = $true From 2c39eae4ad8667e16fdcc07679ec462d7368edb2 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Thu, 19 Feb 2026 12:00:07 -0500 Subject: [PATCH 73/97] Add try/catch and logging for Autopilot assignment Wraps the Autopilot profile assignment in a try/catch to handle errors, moves the success info log into the try block, and logs failures with Get-CippException details. Also tightens message interpolation for AssignTo and TenantFilter to produce clearer logs and a consistent success string. --- .../Set-CIPPDefaultAPDeploymentProfile.ps1 | 21 ++++++++++++------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/Modules/CIPPCore/Public/Set-CIPPDefaultAPDeploymentProfile.ps1 b/Modules/CIPPCore/Public/Set-CIPPDefaultAPDeploymentProfile.ps1 index 818c2f97dfea..4a46e344d5e9 100644 --- a/Modules/CIPPCore/Public/Set-CIPPDefaultAPDeploymentProfile.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPDefaultAPDeploymentProfile.ps1 @@ -70,17 +70,22 @@ function Set-CIPPDefaultAPDeploymentProfile { } if ($AssignTo -eq $true) { - $AssignBody = '{"target":{"@odata.type":"#microsoft.graph.allDevicesAssignmentTarget"}}' - if ($PSCmdlet.ShouldProcess($AssignTo, "Assign Autopilot profile $DisplayName")) { - #Get assignments - $Assignments = New-GraphGETRequest -uri "https://graph.microsoft.com/beta/deviceManagement/windowsAutopilotDeploymentProfiles/$($GraphRequest.id)/assignments" -tenantid $TenantFilter - if (!$Assignments) { - $null = New-GraphPOSTRequest -uri "https://graph.microsoft.com/beta/deviceManagement/windowsAutopilotDeploymentProfiles/$($GraphRequest.id)/assignments" -tenantid $TenantFilter -type POST -body $AssignBody + try { + $AssignBody = '{"target":{"@odata.type":"#microsoft.graph.allDevicesAssignmentTarget"}}' + if ($PSCmdlet.ShouldProcess($AssignTo, "Assign Autopilot profile $DisplayName")) { + #Get assignments + $Assignments = New-GraphGETRequest -uri "https://graph.microsoft.com/beta/deviceManagement/windowsAutopilotDeploymentProfiles/$($GraphRequest.id)/assignments" -tenantid $TenantFilter + if (!$Assignments) { + $null = New-GraphPOSTRequest -uri "https://graph.microsoft.com/beta/deviceManagement/windowsAutopilotDeploymentProfiles/$($GraphRequest.id)/assignments" -tenantid $TenantFilter -type POST -body $AssignBody + } + Write-LogMessage -Headers $User -API $APIName -tenant $TenantFilter -message "Assigned autopilot profile $($DisplayName) to $($AssignTo)" -Sev 'Info' } - Write-LogMessage -Headers $User -API $APIName -tenant $TenantFilter -message "Assigned autopilot profile $($DisplayName) to $AssignTo" -Sev 'Info' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -Headers $User -API $APIName -tenant $TenantFilter -message "Failed to assign Autopilot profile $($DisplayName) to $($AssignTo): $($ErrorMessage.NormalizedError)" -Sev 'Error' -LogData $ErrorMessage } } - "Successfully $($Type)ed profile for $TenantFilter" + "Successfully $($Type)ed profile for $($TenantFilter)" } catch { $ErrorMessage = Get-CippException -Exception $_ $Result = "Failed $($Type)ing Autopilot Profile $($DisplayName). Error: $($ErrorMessage.NormalizedError)" From 9fd4fc7b306416a2f25612f35588eb8ec9604adb Mon Sep 17 00:00:00 2001 From: John Duprey Date: Thu, 19 Feb 2026 12:24:28 -0500 Subject: [PATCH 74/97] fix autopilot standard comparisons --- .../Invoke-CIPPStandardAutopilotProfile.ps1 | 2 +- .../Invoke-CIPPStandardAutopilotStatusPage.ps1 | 13 ++++++++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardAutopilotProfile.ps1 b/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardAutopilotProfile.ps1 index 508e56635b2e..d077b530922e 100644 --- a/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardAutopilotProfile.ps1 +++ b/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardAutopilotProfile.ps1 @@ -52,7 +52,7 @@ function Invoke-CIPPStandardAutopilotProfile { Where-Object { $_.displayName -eq $Settings.DisplayName } | Select-Object -Property displayName, description, deviceNameTemplate, locale, preprovisioningAllowed, hardwareHashExtractionEnabled, outOfBoxExperienceSetting - if ($Settings.NotLocalAdmin -eq $true) { $userType = 'Standard' } else { $userType = 'Administrator' } + if ($Settings.NotLocalAdmin -eq $true) { $userType = 'standard' } else { $userType = 'administrator' } if ($Settings.SelfDeployingMode -eq $true) { $DeploymentMode = 'shared' $Settings.AllowWhiteGlove = $false diff --git a/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardAutopilotStatusPage.ps1 b/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardAutopilotStatusPage.ps1 index 1fcf83165723..6631b4809b5d 100644 --- a/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardAutopilotStatusPage.ps1 +++ b/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardAutopilotStatusPage.ps1 @@ -67,7 +67,18 @@ function Invoke-CIPPStandardAutopilotStatusPage { $StateIsCorrect = $false } - $CurrentValue = $CurrentConfig | Select-Object -Property id, displayName, priority, showInstallationProgress, blockDeviceSetupRetryByUser, allowDeviceResetOnInstallFailure, allowLogCollectionOnInstallFailure, customErrorMessage, installProgressTimeoutInMinutes, allowDeviceUseOnInstallFailure, trackInstallProgressForAutopilotOnly, installQualityUpdates + $CurrentValue = [PSCustomObject]@{ + installProgressTimeoutInMinutes = $CurrentConfig.installProgressTimeoutInMinutes + customErrorMessage = $CurrentConfig.customErrorMessage + showInstallationProgress = $CurrentConfig.showInstallationProgress + allowLogCollectionOnInstallFailure = $CurrentConfig.allowLogCollectionOnInstallFailure + trackInstallProgressForAutopilotOnly = $CurrentConfig.trackInstallProgressForAutopilotOnly + blockDeviceSetupRetryByUser = $CurrentConfig.blockDeviceSetupRetryByUser + installQualityUpdates = $CurrentConfig.installQualityUpdates + allowDeviceResetOnInstallFailure = $CurrentConfig.allowDeviceResetOnInstallFailure + allowDeviceUseOnInstallFailure = $CurrentConfig.allowDeviceUseOnInstallFailure + } + $ExpectedValue = [PSCustomObject]@{ installProgressTimeoutInMinutes = $Settings.TimeOutInMinutes customErrorMessage = $Settings.ErrorMessage From ac075b253f56587d39e20e3cad93abdddc6f2e14 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Thu, 19 Feb 2026 12:34:49 -0500 Subject: [PATCH 75/97] add default empty strings for better comparison --- .../Invoke-CIPPStandardintuneBrandingProfile.ps1 | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardintuneBrandingProfile.ps1 b/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardintuneBrandingProfile.ps1 index 66ed49f285ec..ecac361e88e6 100644 --- a/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardintuneBrandingProfile.ps1 +++ b/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardintuneBrandingProfile.ps1 @@ -114,13 +114,13 @@ function Invoke-CIPPStandardintuneBrandingProfile { displayName = $CurrentState.displayName showLogo = $CurrentState.showLogo showDisplayNameNextToLogo = $CurrentState.showDisplayNameNextToLogo - contactITName = $CurrentState.contactITName - contactITPhoneNumber = $CurrentState.contactITPhoneNumber - contactITEmailAddress = $CurrentState.contactITEmailAddress - contactITNotes = $CurrentState.contactITNotes - onlineSupportSiteName = $CurrentState.onlineSupportSiteName - onlineSupportSiteUrl = $CurrentState.onlineSupportSiteUrl - privacyUrl = $CurrentState.privacyUrl + contactITName = $CurrentState.contactITName ?? '' + contactITPhoneNumber = $CurrentState.contactITPhoneNumber ?? '' + contactITEmailAddress = $CurrentState.contactITEmailAddress ?? '' + contactITNotes = $CurrentState.contactITNotes ?? '' + onlineSupportSiteName = $CurrentState.onlineSupportSiteName ?? '' + onlineSupportSiteUrl = $CurrentState.onlineSupportSiteUrl ?? '' + privacyUrl = $CurrentState.privacyUrl ?? '' } $ExpectedValue = @{ displayName = $Settings.displayName From d38f8af30d9aedadf6eca544818ec46a2e0ecdd7 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Thu, 19 Feb 2026 12:48:20 -0500 Subject: [PATCH 76/97] return error if blob upload fails --- Modules/CIPPCore/Public/New-CIPPBackup.ps1 | 1 + 1 file changed, 1 insertion(+) diff --git a/Modules/CIPPCore/Public/New-CIPPBackup.ps1 b/Modules/CIPPCore/Public/New-CIPPBackup.ps1 index 374dfaf4ee8e..f5760c802330 100644 --- a/Modules/CIPPCore/Public/New-CIPPBackup.ps1 +++ b/Modules/CIPPCore/Public/New-CIPPBackup.ps1 @@ -147,6 +147,7 @@ function New-CIPPBackup { } catch { $ErrorMessage = Get-CippException -Exception $_ Write-LogMessage -headers $Headers -API $APINAME -message "Blob upload failed: $($ErrorMessage.NormalizedError)" -Sev 'Error' -LogData $ErrorMessage + return [pscustomobject]@{'Results' = "Blob Upload failed: $($ErrorMessage.NormalizedError)" } } # Write table entity pointing to blob resource From 3aebafb112b5a8e5de102cc2638d46c13d65d2d1 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Thu, 19 Feb 2026 15:56:27 -0500 Subject: [PATCH 77/97] Prefer latest Intune policy when filtering by name When multiple policies share the same displayName, choose the most recently modified one. Added Sort-Object -Property lastModifiedDateTime -Descending | Select-Object -First 1 to displayName/Name lookups across Get-CIPPIntunePolicy.ps1 (including Android/iOS bulk results and various template branches) so the function returns the latest matching policy instead of an arbitrary/older one or duplicates. --- .../CIPPCore/Public/Get-CIPPIntunePolicy.ps1 | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/Modules/CIPPCore/Public/Get-CIPPIntunePolicy.ps1 b/Modules/CIPPCore/Public/Get-CIPPIntunePolicy.ps1 index ba0cbda8cfaa..9d9143b63151 100644 --- a/Modules/CIPPCore/Public/Get-CIPPIntunePolicy.ps1 +++ b/Modules/CIPPCore/Public/Get-CIPPIntunePolicy.ps1 @@ -35,8 +35,8 @@ function Get-CIPPIntunePolicy { $iOSPolicies = ($BulkResults | Where-Object { $_.id -eq 'iOSPolicies' }).body.value if ($DisplayName) { - $androidPolicy = $androidPolicies | Where-Object -Property displayName -EQ $DisplayName - $iOSPolicy = $iOSPolicies | Where-Object -Property displayName -EQ $DisplayName + $androidPolicy = $androidPolicies | Where-Object -Property displayName -EQ $DisplayName | Sort-Object -Property lastModifiedDateTime -Descending | Select-Object -First 1 + $iOSPolicy = $iOSPolicies | Where-Object -Property displayName -EQ $DisplayName | Sort-Object -Property lastModifiedDateTime -Descending | Select-Object -First 1 # Return the matching policy (Android or iOS) - using full data from bulk request if ($androidPolicy) { @@ -92,7 +92,7 @@ function Get-CIPPIntunePolicy { if ($DisplayName) { $policies = New-GraphGETRequest -uri "https://graph.microsoft.com/beta/$PlatformType/$TemplateTypeURL" -tenantid $tenantFilter - $policy = $policies | Where-Object -Property displayName -EQ $DisplayName + $policy = $policies | Where-Object -Property displayName -EQ $DisplayName | Sort-Object -Property lastModifiedDateTime -Descending | Select-Object -First 1 if ($policy) { $policyDetails = New-GraphGETRequest -uri "https://graph.microsoft.com/beta/$PlatformType/$TemplateTypeURL('$($policy.id)')?`$expand=scheduledActionsForRule(`$expand=scheduledActionConfigurations)" -tenantid $tenantFilter $policyJson = ConvertTo-Json -InputObject $policyDetails -Depth 100 -Compress @@ -122,7 +122,7 @@ function Get-CIPPIntunePolicy { if ($DisplayName) { $policies = New-GraphGETRequest -uri "https://graph.microsoft.com/beta/$PlatformType/$TemplateTypeURL" -tenantid $tenantFilter - $policy = $policies | Where-Object -Property displayName -EQ $DisplayName + $policy = $policies | Where-Object -Property displayName -EQ $DisplayName | Sort-Object -Property lastModifiedDateTime -Descending | Select-Object -First 1 if ($policy) { $definitionValues = New-GraphGETRequest -uri "https://graph.microsoft.com/beta/$PlatformType/$TemplateTypeURL('$($policy.id)')/definitionValues" -tenantid $tenantFilter $policy | Add-Member -MemberType NoteProperty -Name 'definitionValues' -Value $definitionValues -Force @@ -237,7 +237,7 @@ function Get-CIPPIntunePolicy { if ($DisplayName) { $policies = New-GraphGETRequest -uri "https://graph.microsoft.com/beta/$PlatformType/$TemplateTypeURL" -tenantid $tenantFilter - $policy = $policies | Where-Object -Property displayName -EQ $DisplayName + $policy = $policies | Where-Object -Property displayName -EQ $DisplayName | Sort-Object -Property lastModifiedDateTime -Descending | Select-Object -First 1 if ($policy) { $policyDetails = New-GraphGETRequest -uri "https://graph.microsoft.com/beta/$PlatformType/$TemplateTypeURL('$($policy.id)')" -tenantid $tenantFilter $policyDetails = $policyDetails | Select-Object * -ExcludeProperty id, lastModifiedDateTime, '@odata.context', 'ScopeTagIds', 'supportsScopeTags', 'createdDateTime' @@ -270,7 +270,7 @@ function Get-CIPPIntunePolicy { if ($DisplayName) { $policies = New-GraphGETRequest -uri "https://graph.microsoft.com/beta/$PlatformType/$TemplateTypeURL" -tenantid $tenantFilter - $policy = $policies | Where-Object -Property Name -EQ $DisplayName + $policy = $policies | Where-Object -Property Name -EQ $DisplayName | Sort-Object -Property lastModifiedDateTime -Descending | Select-Object -First 1 if ($policy) { $policyDetails = New-GraphGETRequest -uri "https://graph.microsoft.com/beta/$PlatformType/$TemplateTypeURL('$($policy.id)')?`$expand=settings" -tenantid $tenantFilter $policyDetails = $policyDetails | Select-Object name, description, settings, platforms, technologies, templateReference @@ -303,7 +303,7 @@ function Get-CIPPIntunePolicy { if ($DisplayName) { $policies = New-GraphGETRequest -uri "https://graph.microsoft.com/beta/$PlatformType/$TemplateTypeURL" -tenantid $tenantFilter - $policy = $policies | Where-Object -Property displayName -EQ $DisplayName + $policy = $policies | Where-Object -Property displayName -EQ $DisplayName | Sort-Object -Property lastModifiedDateTime -Descending | Select-Object -First 1 if ($policy) { $policyDetails = New-GraphGETRequest -uri "https://graph.microsoft.com/beta/$PlatformType/$TemplateTypeURL('$($policy.id)')" -tenantid $tenantFilter $policyDetails = $policyDetails | Select-Object * -ExcludeProperty id, lastModifiedDateTime, '@odata.context', 'ScopeTagIds', 'supportsScopeTags', 'createdDateTime' @@ -336,7 +336,7 @@ function Get-CIPPIntunePolicy { if ($DisplayName) { $policies = New-GraphGETRequest -uri "https://graph.microsoft.com/beta/$PlatformType/$TemplateTypeURL" -tenantid $tenantFilter - $policy = $policies | Where-Object -Property displayName -EQ $DisplayName + $policy = $policies | Where-Object -Property displayName -EQ $DisplayName | Sort-Object -Property lastModifiedDateTime -Descending | Select-Object -First 1 if ($policy) { $policyDetails = New-GraphGETRequest -uri "https://graph.microsoft.com/beta/$PlatformType/$TemplateTypeURL('$($policy.id)')" -tenantid $tenantFilter $policyDetails = $policyDetails | Select-Object * -ExcludeProperty id, lastModifiedDateTime, '@odata.context', 'ScopeTagIds', 'supportsScopeTags', 'createdDateTime' @@ -369,7 +369,7 @@ function Get-CIPPIntunePolicy { if ($DisplayName) { $policies = New-GraphGETRequest -uri "https://graph.microsoft.com/beta/$PlatformType/$TemplateTypeURL" -tenantid $tenantFilter - $policy = $policies | Where-Object -Property displayName -EQ $DisplayName + $policy = $policies | Where-Object -Property displayName -EQ $DisplayName | Sort-Object -Property lastModifiedDateTime -Descending | Select-Object -First 1 if ($policy) { $policyDetails = New-GraphGETRequest -uri "https://graph.microsoft.com/beta/$PlatformType/$TemplateTypeURL('$($policy.id)')" -tenantid $tenantFilter $policyDetails = $policyDetails | Select-Object * -ExcludeProperty id, lastModifiedDateTime, '@odata.context', 'ScopeTagIds', 'supportsScopeTags', 'createdDateTime' @@ -402,7 +402,7 @@ function Get-CIPPIntunePolicy { if ($DisplayName) { $policies = New-GraphGETRequest -uri "https://graph.microsoft.com/beta/$PlatformType/$TemplateTypeURL" -tenantid $tenantFilter - $policy = $policies | Where-Object -Property displayName -EQ $DisplayName + $policy = $policies | Where-Object -Property displayName -EQ $DisplayName | Sort-Object -Property lastModifiedDateTime -Descending | Select-Object -First 1 if ($policy) { $policyDetails = New-GraphGETRequest -uri "https://graph.microsoft.com/beta/$PlatformType/$TemplateTypeURL('$($policy.id)')" -tenantid $tenantFilter $policyDetails = $policyDetails | Select-Object * -ExcludeProperty id, lastModifiedDateTime, '@odata.context', 'ScopeTagIds', 'supportsScopeTags', 'createdDateTime' From 46ec3ec56c6559509ce0eaf8f11a701d422f9352 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Thu, 19 Feb 2026 16:32:42 -0500 Subject: [PATCH 78/97] Validate LitigationHoldDuration input Only assign $Settings.days to the LitigationHoldDuration parameter if it is a positive integer or the string 'Unlimited'. Adds a TryParse check and conditional logic to avoid passing invalid/non-numeric values to the cmdlet, preventing erroneous requests. --- .../Standards/Invoke-CIPPStandardEnableLitigationHold.ps1 | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardEnableLitigationHold.ps1 b/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardEnableLitigationHold.ps1 index 35f68f3350d9..54a45b6c08c5 100644 --- a/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardEnableLitigationHold.ps1 +++ b/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardEnableLitigationHold.ps1 @@ -59,12 +59,14 @@ function Invoke-CIPPStandardEnableLitigationHold { } } if ($null -ne $Settings.days) { - $params.CmdletInput.Parameters['LitigationHoldDuration'] = $Settings.days + $Days = [int]::TryParse($Settings.days, [ref]$null) ? $Settings.days : $null + if ($Days -gt 0 -or $Settings.days -eq 'Unlimited') { + $params.CmdletInput.Parameters['LitigationHoldDuration'] = $Settings.days + } } $params } - $BatchResults = New-ExoBulkRequest -tenantid $Tenant -cmdletArray @($Request) foreach ($Result in $BatchResults) { if ($Result.error) { From dc0de25601b616f10695c14f246d0100916e3ce2 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Thu, 19 Feb 2026 22:08:55 -0500 Subject: [PATCH 79/97] fix casing for json comparison --- .../Public/Standards/Invoke-CIPPStandardOauthConsent.ps1 | 6 +++--- .../Standards/Invoke-CIPPStandardOauthConsentLowSec.ps1 | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardOauthConsent.ps1 b/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardOauthConsent.ps1 index bf86be5f502e..18ab63baafb8 100644 --- a/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardOauthConsent.ps1 +++ b/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardOauthConsent.ps1 @@ -47,7 +47,7 @@ function Invoke-CIPPStandardOauthConsent { Write-LogMessage -API 'Standards' -Tenant $Tenant -Message "Could not get the OauthConsent state for $Tenant. Error: $ErrorMessage" -Sev Error return } - $StateIsCorrect = if ($State.permissionGrantPolicyIdsAssignedToDefaultUserRole -eq 'managePermissionGrantsForSelf.cipp-consent-policy') { $true } else { $false } + $StateIsCorrect = if ($State.permissionGrantPolicyIdsAssignedToDefaultUserRole -eq 'ManagePermissionGrantsForSelf.cipp-consent-policy') { $true } else { $false } if ($Settings.remediate -eq $true) { $AllowedAppIdsForTenant = $settings.AllowedApps -split ',' | ForEach-Object { $_.Trim() } @@ -77,8 +77,8 @@ function Invoke-CIPPStandardOauthConsent { "Could not add exclusions, probably already exist: $($_)" } - if ($State.permissionGrantPolicyIdsAssignedToDefaultUserRole -notin @('managePermissionGrantsForSelf.cipp-consent-policy')) { - New-GraphPostRequest -tenantid $tenant -Uri 'https://graph.microsoft.com/beta/policies/authorizationPolicy/authorizationPolicy' -Type PATCH -Body '{"permissionGrantPolicyIdsAssignedToDefaultUserRole":["managePermissionGrantsForSelf.cipp-consent-policy"]}' -ContentType 'application/json' + if ($State.permissionGrantPolicyIdsAssignedToDefaultUserRole -notin @('ManagePermissionGrantsForSelf.cipp-consent-policy')) { + New-GraphPostRequest -tenantid $tenant -Uri 'https://graph.microsoft.com/beta/policies/authorizationPolicy/authorizationPolicy' -Type PATCH -Body '{"permissionGrantPolicyIdsAssignedToDefaultUserRole":["ManagePermissionGrantsForSelf.cipp-consent-policy"]}' -ContentType 'application/json' } Write-LogMessage -API 'Standards' -tenant $tenant -message 'Application Consent Mode has been enabled.' -sev Info diff --git a/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardOauthConsentLowSec.ps1 b/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardOauthConsentLowSec.ps1 index e28c87dc27e9..34d47a81ffee 100644 --- a/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardOauthConsentLowSec.ps1 +++ b/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardOauthConsentLowSec.ps1 @@ -49,7 +49,7 @@ function Invoke-CIPPStandardOauthConsentLowSec { $ConflictingStandard = $Standards | Where-Object -Property Standard -EQ 'OauthConsent' if ($Settings.remediate -eq $true) { - if ($State.permissionGrantPolicyIdsAssignedToDefaultUserRole -in @('managePermissionGrantsForSelf.microsoft-user-default-low')) { + if ($State.permissionGrantPolicyIdsAssignedToDefaultUserRole -in @('ManagePermissionGrantsForSelf.microsoft-user-default-low')) { Write-LogMessage -API 'Standards' -tenant $tenant -message 'Application Consent Mode(microsoft-user-default-low) is already enabled.' -sev Info } elseif ($ConflictingStandard -and $State.permissionGrantPolicyIdsAssignedToDefaultUserRole -contains 'ManagePermissionGrantsForSelf.cipp-consent-policy') { Write-LogMessage -API 'Standards' -tenant $tenant -message 'There is a conflicting OAuth Consent policy standard enabled for this tenant. Remove the Require admin consent for applications (Prevent OAuth phishing) standard from this tenant to apply the low security standard.' -sev Error @@ -60,7 +60,7 @@ function Invoke-CIPPStandardOauthConsentLowSec { Uri = 'https://graph.microsoft.com/beta/policies/authorizationPolicy/authorizationPolicy' Type = 'PATCH' Body = @{ - permissionGrantPolicyIdsAssignedToDefaultUserRole = @('managePermissionGrantsForSelf.microsoft-user-default-low') + permissionGrantPolicyIdsAssignedToDefaultUserRole = @('ManagePermissionGrantsForSelf.microsoft-user-default-low') } | ConvertTo-Json ContentType = 'application/json' } @@ -98,7 +98,7 @@ function Invoke-CIPPStandardOauthConsentLowSec { } if ($Settings.alert -eq $true) { - if ($State.permissionGrantPolicyIdsAssignedToDefaultUserRole -notin @('managePermissionGrantsForSelf.microsoft-user-default-low')) { + if ($State.permissionGrantPolicyIdsAssignedToDefaultUserRole -notin @('ManagePermissionGrantsForSelf.microsoft-user-default-low')) { Write-StandardsAlert -message 'Application Consent Mode(microsoft-user-default-low) is not enabled' -object $State -tenant $tenant -standardName 'OauthConsentLowSec' -standardId $Settings.standardId Write-LogMessage -API 'Standards' -tenant $tenant -message 'Application Consent Mode(microsoft-user-default-low) is not enabled.' -sev Info } else { From 115ab34eeb4102eff26565cb3217d23081c6401f Mon Sep 17 00:00:00 2001 From: John Duprey Date: Thu, 19 Feb 2026 22:38:43 -0500 Subject: [PATCH 80/97] Group PIM cache items under P2 section Move PIM-related cache entries into the Azure AD Premium P2 cache list and update the section heading. Removed RoleEligibilitySchedules, RoleManagementPolicies and RoleAssignmentScheduleInstances from the earlier list and added RoleEligibilitySchedules, RoleAssignmentSchedules and RoleManagementPolicies to the P2 cache functions. Also updated the region comment to "Identity Protection/PIM features" to reflect the grouping. --- .../Activity Triggers/Push-CIPPDBCacheData.ps1 | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Modules/CIPPCore/Public/Entrypoints/Activity Triggers/Push-CIPPDBCacheData.ps1 b/Modules/CIPPCore/Public/Entrypoints/Activity Triggers/Push-CIPPDBCacheData.ps1 index 9351980ef740..907f5ad3de9d 100644 --- a/Modules/CIPPCore/Public/Entrypoints/Activity Triggers/Push-CIPPDBCacheData.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/Activity Triggers/Push-CIPPDBCacheData.ps1 @@ -50,9 +50,6 @@ function Push-CIPPDBCacheData { 'SecureScore' 'PIMSettings' 'Domains' - 'RoleEligibilitySchedules' - 'RoleManagementPolicies' - 'RoleAssignmentScheduleInstances' 'B2BManagementPolicy' 'AuthenticationFlowsPolicy' 'DeviceRegistrationPolicy' @@ -130,13 +127,16 @@ function Push-CIPPDBCacheData { } #endregion Conditional Access Licensed - #region Azure AD Premium P2 - Identity Protection features + #region Azure AD Premium P2 - Identity Protection/PIM features if ($AzureADPremiumP2Capable) { $P2CacheFunctions = @( 'RiskyUsers' 'RiskyServicePrincipals' 'ServicePrincipalRiskDetections' 'RiskDetections' + 'RoleEligibilitySchedules' + 'RoleAssignmentSchedules' + 'RoleManagementPolicies' ) foreach ($CacheFunction in $P2CacheFunctions) { $Batch.Add(@{ From 7a33197177fd701ce5ee54166cbc3280d1a49222 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Thu, 19 Feb 2026 22:53:33 -0500 Subject: [PATCH 81/97] fix json body for webhooks --- Modules/CIPPCore/Public/Send-CIPPAlert.ps1 | 24 ++++++++++++++-------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/Modules/CIPPCore/Public/Send-CIPPAlert.ps1 b/Modules/CIPPCore/Public/Send-CIPPAlert.ps1 index 3451827a67d3..c26f0f2254e8 100644 --- a/Modules/CIPPCore/Public/Send-CIPPAlert.ps1 +++ b/Modules/CIPPCore/Public/Send-CIPPAlert.ps1 @@ -112,35 +112,41 @@ function Send-CIPPAlert { $Headers = $null } - $JSONBody = Get-CIPPTextReplacement -TenantFilter $TenantFilter -Text $JSONContent -EscapeForJson + $ReplacedContent = Get-CIPPTextReplacement -TenantFilter $TenantFilter -Text $JSONContent -EscapeForJson try { if (![string]::IsNullOrWhiteSpace($Config.webhook) -or ![string]::IsNullOrWhiteSpace($AltWebhook)) { if ($PSCmdlet.ShouldProcess($Config.webhook, 'Sending webhook')) { $webhook = if ($AltWebhook) { $AltWebhook } else { $Config.webhook } switch -wildcard ($webhook) { '*webhook.office.com*' { - $JSONBody = "{`"text`": `"You've setup your alert policies to be alerted whenever specific events happen. We've found some of these events in the log.

$JSONContent`"}" - Invoke-RestMethod -Uri $webhook -Method POST -ContentType 'Application/json' -Body $JSONBody + $TeamsBody = [PSCustomObject]@{ + text = "You've setup your alert policies to be alerted whenever specific events happen. We've found some of these events in the log.

$ReplacedContent" + } | ConvertTo-Json -Compress + Invoke-RestMethod -Uri $webhook -Method POST -ContentType 'Application/json' -Body $TeamsBody } '*discord.com*' { - $JSONBody = "{`"content`": `"You've setup your alert policies to be alerted whenever specific events happen. We've found some of these events in the log. $JSONContent`"}" - Invoke-RestMethod -Uri $webhook -Method POST -ContentType 'Application/json' -Body $JSONBody + $DiscordBody = [PSCustomObject]@{ + content = "You've setup your alert policies to be alerted whenever specific events happen. We've found some of these events in the log. ``````$ReplacedContent``````" + } | ConvertTo-Json -Compress + Invoke-RestMethod -Uri $webhook -Method POST -ContentType 'Application/json' -Body $DiscordBody } '*slack.com*' { $SlackBlocks = Get-SlackAlertBlocks -JSONBody $JSONContent if ($SlackBlocks.blocks) { - $JSONBody = $SlackBlocks | ConvertTo-Json -Depth 10 -Compress + $SlackBody = $SlackBlocks | ConvertTo-Json -Depth 10 -Compress } else { - $JSONBody = "{`"text`": `"You've setup your alert policies to be alerted whenever specific events happen. We've found some of these events in the log. $JSONContent`"}" + $SlackBody = [PSCustomObject]@{ + text = "You've setup your alert policies to be alerted whenever specific events happen. We've found some of these events in the log. ``````$ReplacedContent``````" + } | ConvertTo-Json -Compress } - Invoke-RestMethod -Uri $webhook -Method POST -ContentType 'Application/json' -Body $JSONBody + Invoke-RestMethod -Uri $webhook -Method POST -ContentType 'Application/json' -Body $SlackBody } default { $RestMethod = @{ Uri = $webhook Method = 'POST' ContentType = 'application/json' - Body = $JSONContent + Body = $ReplacedContent } if ($Headers) { $RestMethod['Headers'] = $Headers From 4fef64712f02368bd1aee83ce669fb599a7af73a Mon Sep 17 00:00:00 2001 From: John Duprey Date: Fri, 20 Feb 2026 00:37:32 -0500 Subject: [PATCH 82/97] remove logging --- Modules/CIPPCore/Public/Get-CIPPCalendarPermissionReport.ps1 | 2 -- 1 file changed, 2 deletions(-) diff --git a/Modules/CIPPCore/Public/Get-CIPPCalendarPermissionReport.ps1 b/Modules/CIPPCore/Public/Get-CIPPCalendarPermissionReport.ps1 index e6c66c284a25..b8a014d50656 100644 --- a/Modules/CIPPCore/Public/Get-CIPPCalendarPermissionReport.ps1 +++ b/Modules/CIPPCore/Public/Get-CIPPCalendarPermissionReport.ps1 @@ -31,8 +31,6 @@ function Get-CIPPCalendarPermissionReport { ) try { - Write-LogMessage -API 'CalendarPermissionReport' -tenant $TenantFilter -message 'Generating calendar permission report' -sev Info - # Handle AllTenants if ($TenantFilter -eq 'AllTenants') { # Get all tenants that have calendar data From 56f7e9b3136e6a0038c9c6831fa191742d555851 Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Fri, 20 Feb 2026 10:29:29 +0100 Subject: [PATCH 83/97] endREceivedDate --- .../Alerts/Get-CIPPAlertQuarantineReleaseRequests.ps1 | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertQuarantineReleaseRequests.ps1 b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertQuarantineReleaseRequests.ps1 index 81a1fecada71..58ed3b035407 100644 --- a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertQuarantineReleaseRequests.ps1 +++ b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertQuarantineReleaseRequests.ps1 @@ -29,7 +29,13 @@ } try { - $RequestedReleases = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-QuarantineMessage' -cmdParams @{ PageSize = 1000; ReleaseStatus = 'Requested'; StartReceivedDate = (Get-Date).AddHours(-6) } -ErrorAction Stop | Select-Object -ExcludeProperty *data.type* | Sort-Object -Property ReceivedTime + $cmdParams = @{ + PageSize = 1000 + ReleaseStatus = 'Requested' + StartReceivedDate = (Get-Date).AddHours(-6) + EndReceivedDate = (Get-Date).AddHours(0) + } + $RequestedReleases = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-QuarantineMessage' -cmdParams $cmdParams -ErrorAction Stop | Select-Object -ExcludeProperty *data.type* | Sort-Object -Property ReceivedTime if ($RequestedReleases) { # Get the CIPP URL for the Quarantine link From 3cfb562051c3b105410ccd31b3fd1dcc9c2fed47 Mon Sep 17 00:00:00 2001 From: rvdwegen Date: Fri, 20 Feb 2026 10:43:26 +0100 Subject: [PATCH 84/97] concept gdap trace --- .../CIPP/Settings/Invoke-ExecGDAPTrace.ps1 | 782 ++++++++++++++++++ 1 file changed, 782 insertions(+) create mode 100644 Modules/CIPPCore/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecGDAPTrace.ps1 diff --git a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecGDAPTrace.ps1 b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecGDAPTrace.ps1 new file mode 100644 index 000000000000..d138555690a5 --- /dev/null +++ b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecGDAPTrace.ps1 @@ -0,0 +1,782 @@ +function Invoke-ExecAccessTest { + <# + .SYNOPSIS + Tests the complete GDAP (Granular Delegated Admin Privileges) access path for a user. + + This function traces the access path from customer tenant → GDAP relationships → mapped security groups → user, + checking all 15 standard GDAP roles. It verifies whether a SAM user in the partner tenant has access to each + role through direct or nested group memberships across all active GDAP relationships for a customer tenant. + + The function returns a role-centric view showing: + - For each of the 15 GDAP roles: whether it's assigned, whether the user has access, and the complete path + - Complete traceability: Role → Relationship → Group → User (including nested group paths) + - Broken path detection: identifies roles assigned but user not a member of the required groups + + The output is structured as JSON suitable for diagram visualization, showing the complete access chain + regardless of which relationship provides each role. + + Very boilerplate AI code. Needs some simplification and cleanup. + Ridiculous amount of comments to explain the logic so I don't have to explain it to Claude on the frontend. - rvd + + .DESCRIPTION + GDAP Access Path Testing: + 1. Validates input parameters (TenantFilter and UPN) + 2. Retrieves customer tenant information + 3. Gets all active GDAP relationships for the customer tenant + 4. Locates the UPN in the partner tenant + 5. Gets user's transitive group memberships (handles nested groups automatically) + 6. For each GDAP relationship: + - Retrieves all access assignments (mapped security groups) + - For each group: checks user membership (direct or nested) and traces the path + - Maps roles to relationships and groups + 7. For each of the 15 GDAP roles: + - Finds all relationships/groups that have this role assigned + - Checks if user is a member of any group with this role + - Builds complete access path showing how user gets the role (if they do) + 8. Returns comprehensive JSON with role-centric view and complete path traces + + .FUNCTIONALITY + Entrypoint + .ROLE + CIPP.AppSettings.Read + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + # Initialize API logging + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers + Write-LogMessage -Headers $Headers -API $APIName -message 'Accessed this API' -Sev 'Debug' + + # Extract query parameters + # TenantFilter: The customer tenant ID or domain name to test access for + # UPN: The User Principal Name of the SAM user in the partner tenant whose access we're testing + $TenantFilter = $Request.Query.TenantFilter + $UPN = $Request.Query.UPN + + # Validate required input parameters + if ([string]::IsNullOrWhiteSpace($TenantFilter)) { + return [HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::BadRequest + Body = @{ Error = 'TenantFilter is required' } + } + } + + if ([string]::IsNullOrWhiteSpace($UPN)) { + return [HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::BadRequest + Body = @{ Error = 'UPN is required' } + } + } + + try { + # ============================================================================ + # STEP 1: Define all 15 standard GDAP roles + # ============================================================================ + # These are the roles that should be available through GDAP relationships. + # Each role has a unique roleDefinitionId (GUID) that Microsoft Graph uses + # to identify the role. We'll check if the user has access to each of these + # roles through any GDAP relationship, regardless of which relationship provides it. + # + # Note: The roleDefinitionId is the template ID used in Azure AD role definitions. + # These IDs are consistent across all tenants and are used in GDAP access assignments. + # ============================================================================ + + # Get these from the repo in future -rvd + $AllGDAPRoles = @( + @{ Name = 'Application Administrator'; Id = '9b895d92-2cd3-44c7-9d02-a6ac2d5ea5c3'; Description = 'Can create and manage all applications, service principals, app registration, enterprise apps, consent requests. Cannot manage directory roles, security groups.' }, + @{ Name = 'Authentication Policy Administrator'; Id = '0526716b-113d-4c15-b2c8-68e3c22b9f80'; Description = 'Configures authentication methods policy, MFA settings, manages Password Protection settings, creates/manages verifiable credentials, Azure support tickets. Restrictions on updating sensitive properties, deleting/restoring users, legacy MFA settings.' }, + @{ Name = 'Billing Administrator'; Id = 'b0f54661-2d74-4c50-afa3-1ec803f12efe'; Description = 'Can perform common billing related tasks like updating payment information.' }, + @{ Name = 'Cloud App Security Administrator'; Id = '892c5842-a9a6-463a-8041-72aa08ca3cf6'; Description = 'Manages all aspects of the Defender for Cloud App Security in Azure AD, including policies, alerts, and related configurations.' }, + @{ Name = 'Cloud Device Administrator'; Id = '7698a772-787b-4ac8-901f-60d6b08affd2'; Description = 'Enables, disables, deletes devices in Azure AD, reads Windows 10 BitLocker keys. Does not grant permissions to manage other properties on the device.' }, + @{ Name = 'Domain Name Administrator'; Id = '8329153a-20ed-4bf8-aa37-81242c6e8e01'; Description = 'Can manage domain names in cloud and on-premises.' }, + @{ Name = 'Exchange Administrator'; Id = '29232cdf-9323-42fd-ade2-1d097af3e4de'; Description = 'Manages all aspects of Exchange Online, including mailboxes, permissions, connectivity, and related settings. Limited access to related Exchange settings in Azure AD.' }, + @{ Name = 'Global Reader'; Id = 'f2ef992c-3afb-46b9-b7cf-a126ee74c451'; Description = 'Can read everything that a Global Administrator can but not update anything.' }, + @{ Name = 'Intune Administrator'; Id = '3a2c62db-5318-420d-8d74-23affee5d9d5'; Description = 'Manages all aspects of Intune, including all related resources, policies, configurations, and tasks.' }, + @{ Name = 'Privileged Authentication Administrator'; Id = '7be44c8a-adaf-4e2a-84d6-ab2649e08a13'; Description = 'Sets/resets authentication methods for all users (admin or non-admin), deletes/restores any users. Manages support tickets in Azure and Microsoft 365. Restrictions on managing per-user MFA in legacy MFA portal.' }, + @{ Name = 'Privileged Role Administrator'; Id = 'e8611ab8-c189-46e8-94e1-60213ab1f814'; Description = 'Manages role assignments in Azure AD, Azure AD Privileged Identity Management, creates/manages groups, manages all aspects of Privileged Identity Management, administrative units. Allows managing assignments for all Azure AD roles including Global Administrator.' }, + @{ Name = 'Security Administrator'; Id = '194ae4cb-b126-40b2-bd5b-6091b380977d'; Description = 'Can read security information and reports, and manages security-related features, including identity protection, security policies, device management, and threat management in Azure AD and Office 365.' }, + @{ Name = 'SharePoint Administrator'; Id = 'f28a1f50-f6e7-4571-818b-6a12f2af6b6c'; Description = 'Manages all aspects of SharePoint Online, Microsoft 365 groups, support tickets, service health. Scoped permissions for Microsoft Intune, SharePoint, and OneDrive resources.' }, + @{ Name = 'Teams Administrator'; Id = '69091246-20e8-4a56-aa4d-066075b2a7a8'; Description = 'Manages all aspects of Microsoft Teams, including telephony, messaging, meetings, teams, Microsoft 365 groups, support tickets, and service health.' }, + @{ Name = 'User Administrator'; Id = 'fe930be7-5e62-47db-91af-98c3a49a38b1'; Description = 'Manages all aspects of users, groups, registration, and resets passwords for limited admins. Cannot manage security-related policies or other configuration objects.' } + ) + + # ============================================================================ + # STEP 2: Get customer tenant information + # ============================================================================ + # The TenantFilter can be either a tenant ID (GUID) or a domain name. + # Get-Tenants will resolve it and return the tenant object with customerId and displayName. + # ============================================================================ + $Tenant = Get-Tenants -TenantFilter $TenantFilter + if (-not $Tenant) { + return [HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::NotFound + Body = @{ Error = "Tenant not found: $TenantFilter" } + } + } + + $CustomerTenantId = $Tenant.customerId + $CustomerTenantName = $Tenant.displayName + + # ============================================================================ + # STEP 3: Get all active GDAP relationships for the customer tenant + # ============================================================================ + # GDAP relationships are created in the partner tenant and link to customer tenants. + # We query from the partner tenant perspective ($env:TenantID) and filter for: + # - status eq 'active': Only relationships that are currently active + # - customer/tenantId eq '$CustomerTenantId': Only relationships for this specific customer + # + # A tenant can have multiple GDAP relationships, each potentially with different roles. + # We need to check all of them to see which roles are available through which relationships. + # ============================================================================ + $BaseUri = 'https://graph.microsoft.com/beta/tenantRelationships/delegatedAdminRelationships' + $FilterValue = "status eq 'active' and customer/tenantId eq '$CustomerTenantId'" + $RelationshipsUri = "$($BaseUri)?`$filter=$($FilterValue)" + $Relationships = New-GraphGetRequest -uri $RelationshipsUri -tenantid $env:TenantID -NoAuthCheck $true + + # If no active relationships exist, return early with an informative message + if (-not $Relationships -or $Relationships.Count -eq 0) { + return [HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::OK + Body = @{ + tenantId = $CustomerTenantId + tenantName = $CustomerTenantName + relationships = @() + error = "No active GDAP relationships found for tenant $CustomerTenantName" + } + } + } + + # ============================================================================ + # STEP 4: Get the SAM user in the partner tenant + # ============================================================================ + # The UPN provided is for a user in the PARTNER tenant (not the customer tenant). + # This is the SAM (Service Account Manager) user whose access we're testing. + # The user must be in the partner tenant because GDAP groups are in the partner tenant. + # + # We try two methods: + # 1. Filter query: More efficient if it works + # 2. Direct lookup: Fallback if filter query doesn't return results + # ============================================================================ + $User = $null + try { + # Filter didn't work, try direct lookup by UPN (works if UPN is unique identifier) + $User = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/users/$UPN" -tenantid $env:TenantID -NoAuthCheck $true + } catch { + Write-LogMessage -Headers $Headers -API $APIName -message "Could not find user $UPN in partner tenant: $($_.Exception.Message)" -Sev 'Warning' + } + + # If user not found, return error + if (-not $User) { + return [HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::NotFound + Body = @{ + tenantId = $CustomerTenantId + tenantName = $CustomerTenantName + relationships = @() + error = "User $UPN not found in partner tenant" + } + } + } + + $UserId = $User.id + $UserDisplayName = $User.displayName + + # ============================================================================ + # STEP 5: Get user's transitive group memberships + # ============================================================================ + # This is a critical step. We use transitiveMemberOf which automatically handles + # nested groups at any depth. This means: + # - If user is directly in Group A, they're included + # - If user is in Group B, and Group B is in Group A, they're included + # - If user is in Group C, Group C is in Group B, Group B is in Group A, they're included + # - And so on for any depth of nesting + # + # We build a hashtable (dictionary) for O(1) lookup performance when checking + # if the user is a member of a specific group later. + # + # We filter for only groups (@odata.type = '#microsoft.graph.group') because + # transitiveMemberOf can also return role assignments, which we don't need here. + # ============================================================================ + $UserGroupMemberships = @{} + try { + # Use AsApp=true to get all memberships regardless of current user context + $PartnerUserMemberships = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/users/$UserId/transitiveMemberOf?`$select=id,displayName" -tenantid $env:TenantID -NoAuthCheck $true -AsApp $true -ErrorAction SilentlyContinue + if ($PartnerUserMemberships) { + foreach ($Membership in $PartnerUserMemberships) { + # Only include groups, not role assignments + if ($Membership.'@odata.type' -eq '#microsoft.graph.group') { + # Store in hashtable for fast lookup: key = groupId, value = membership object + $UserGroupMemberships[$Membership.id] = $Membership + } + } + } + } catch { + Write-LogMessage -Headers $Headers -API $APIName -message "Could not get user group memberships: $($_.Exception.Message)" -Sev 'Warning' + } + + # ============================================================================ + # STEP 6: Collect all relationships, groups, and build role mapping + # ============================================================================ + # We need to: + # 1. For each relationship, get all access assignments (mapped groups) + # 2. Collect all unique group IDs from all assignments + # 3. Batch fetch all groups at once (more efficient than individual calls) + # 4. For each group, check if user is a member and trace the path + # 5. Build a map from roleId -> list of relationships/groups that have that role + # + # This allows us to later check each of the 15 roles and see: + # - Which relationships have this role + # - Which groups in those relationships have this role + # - Whether the user is a member of any of those groups + # ============================================================================ + $AllRelationshipData = [System.Collections.Generic.List[object]]::new() + # This map will store: roleId -> list of {relationship, group} objects that have this role assigned + $RoleToRelationshipsMap = @{} + # This map will store: roleId -> list of relationships that have this role available (but may not be assigned) + $RoleToAvailableRelationshipsMap = @{} + + # ======================================================================== + # PHASE 1: Collect all access assignments and extract unique group IDs + # ======================================================================== + # First, we'll collect all access assignments from all relationships + # and extract the unique group IDs. Then we'll fetch all groups in batch. + # Also track which roles are available in each relationship. + # ======================================================================== + $AllAccessAssignments = [System.Collections.Generic.List[object]]::new() + $RelationshipAssignmentMap = @{} # Maps relationshipId -> list of assignments + + foreach ($Relationship in $Relationships) { + $RelationshipId = $Relationship.id + $RelationshipName = $Relationship.displayName + $RelationshipStatus = $Relationship.status + + # Track roles available in this relationship (from accessDetails.unifiedRoles) + if ($Relationship.accessDetails -and $Relationship.accessDetails.unifiedRoles) { + foreach ($Role in $Relationship.accessDetails.unifiedRoles) { + $RoleId = $Role.roleDefinitionId + if ($RoleId) { + if (-not $RoleToAvailableRelationshipsMap.ContainsKey($RoleId)) { + $RoleToAvailableRelationshipsMap[$RoleId] = [System.Collections.Generic.List[object]]::new() + } + $RoleToAvailableRelationshipsMap[$RoleId].Add([PSCustomObject]@{ + relationshipId = $RelationshipId + relationshipName = $RelationshipName + relationshipStatus = $RelationshipStatus + }) + } + } + } + + # Get access assignments (mapped security groups) for this relationship + $AccessAssignments = @() + try { + $AccessAssignments = New-GraphGetRequest -Uri "https://graph.microsoft.com/beta/tenantRelationships/delegatedAdminRelationships/$RelationshipId/accessAssignments" -tenantid $env:TenantID -NoAuthCheck $true + + # Handle case where response might be a single object instead of array + if ($AccessAssignments -and -not ($AccessAssignments -is [System.Array])) { + $AccessAssignments = @($AccessAssignments) + } + + Write-LogMessage -Headers $Headers -API $APIName -message "Retrieved $($AccessAssignments.Count) access assignments for relationship ${RelationshipName}" -Sev 'Debug' + + # Store assignments for this relationship + $RelationshipAssignmentMap[$RelationshipId] = @{ + Relationship = $Relationship + Assignments = $AccessAssignments + } + + # Add to master list + foreach ($Assignment in $AccessAssignments) { + $AllAccessAssignments.Add(@{ + RelationshipId = $RelationshipId + RelationshipName = $RelationshipName + RelationshipStatus = $RelationshipStatus + Assignment = $Assignment + }) + } + } catch { + Write-LogMessage -Headers $Headers -API $APIName -message "Could not get access assignments for relationship ${RelationshipName}: $($_.Exception.Message)" -Sev 'Warning' + } + } + + # Extract all unique group IDs from all assignments + $AllGroupIds = [System.Collections.Generic.HashSet[string]]::new() + foreach ($AssignmentData in $AllAccessAssignments) { + $Assignment = $AssignmentData.Assignment + $GroupId = $null + + # Extract group ID from assignment + if ($Assignment.accessContainer) { + $GroupId = $Assignment.accessContainer.accessContainerId + } elseif ($Assignment.value -and $Assignment.value.accessContainer) { + $GroupId = $Assignment.value.accessContainer.accessContainerId + } + + if ($GroupId -and -not [string]::IsNullOrWhiteSpace($GroupId)) { + [void]$AllGroupIds.Add($GroupId) + } + } + + Write-LogMessage -Headers $Headers -API $APIName -message "Found $($AllGroupIds.Count) unique groups across all relationships" -Sev 'Debug' + + # ======================================================================== + # PHASE 2: Fetch all groups at once and filter in memory + # ======================================================================== + # Fetch all groups in a single request, then create a lookup dictionary + # for fast in-memory filtering when processing assignments + # ======================================================================== + $GroupLookup = @{} # Maps groupId -> group object + + try { + # Fetch all groups at once (similar to Set-CIPPDBCacheGroups) + $AllGroups = New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/groups?$top=999&$select=id,displayName' -tenantid $env:TenantID -NoAuthCheck $true -AsApp $true + + # Handle case where response might be a single object instead of array + if ($AllGroups -and -not ($AllGroups -is [System.Array])) { + $AllGroups = @($AllGroups) + } + + # Build lookup dictionary for O(1) access + foreach ($Group in $AllGroups) { + if ($Group.id) { + $GroupLookup[$Group.id] = $Group + } + } + + Write-LogMessage -Headers $Headers -API $APIName -message "Fetched $($AllGroups.Count) total groups, $($GroupLookup.Count) in lookup" -Sev 'Debug' + } catch { + Write-LogMessage -Headers $Headers -API $APIName -message "Could not fetch all groups: $($_.Exception.Message). Will use fallback for missing groups." -Sev 'Warning' + } + + # ======================================================================== + # PHASE 3: Process all assignments using the group lookup + # ======================================================================== + # Now that we have all groups, process each relationship's assignments + # ======================================================================== + foreach ($Relationship in $Relationships) { + $RelationshipId = $Relationship.id + $RelationshipName = $Relationship.displayName + $RelationshipStatus = $Relationship.status + + # Get assignments for this relationship + if (-not $RelationshipAssignmentMap.ContainsKey($RelationshipId)) { + # No assignments for this relationship, create empty groups list + $AllRelationshipData.Add([PSCustomObject]@{ + relationshipId = $RelationshipId + relationshipName = $RelationshipName + relationshipStatus = $RelationshipStatus + customerTenantId = $Relationship.customer.tenantId + customerTenantName = $Relationship.customer.displayName + groups = @() + }) + continue + } + + $AccessAssignments = $RelationshipAssignmentMap[$RelationshipId].Assignments + $RelationshipGroups = [System.Collections.Generic.List[object]]::new() + + Write-LogMessage -Headers $Headers -API $APIName -message "Processing $($AccessAssignments.Count) access assignments for relationship ${RelationshipName}" -Sev 'Debug' + + foreach ($Assignment in $AccessAssignments) { + # Extract the security group ID and roles from the assignment + $GroupId = $null + if ($Assignment.accessContainer) { + $GroupId = $Assignment.accessContainer.accessContainerId + } elseif ($Assignment.value -and $Assignment.value.accessContainer) { + $GroupId = $Assignment.value.accessContainer.accessContainerId + $Assignment = $Assignment.value + } else { + Write-LogMessage -Headers $Headers -API $APIName -message "Access assignment missing accessContainer: $($Assignment | ConvertTo-Json -Compress)" -Sev 'Warning' + continue + } + + if ([string]::IsNullOrWhiteSpace($GroupId)) { + Write-LogMessage -Headers $Headers -API $APIName -message "Access assignment has empty accessContainerId: $($Assignment | ConvertTo-Json -Compress)" -Sev 'Warning' + continue + } + + # Extract roles - handle both direct and nested structures + $Roles = $null + if ($Assignment.accessDetails -and $Assignment.accessDetails.unifiedRoles) { + $Roles = $Assignment.accessDetails.unifiedRoles + } elseif ($Assignment.unifiedRoles) { + $Roles = $Assignment.unifiedRoles + } + + if (-not $Roles -or $Roles.Count -eq 0) { + Write-LogMessage -Headers $Headers -API $APIName -message "Access assignment for group $GroupId has no roles assigned" -Sev 'Warning' + $Roles = @() + } + + # Get group from lookup (already fetched all groups at once) + $Group = $null + if ($GroupLookup.ContainsKey($GroupId)) { + $Group = $GroupLookup[$GroupId] + } else { + # Fallback: create minimal group object if not in lookup + # This can happen if the group was deleted or doesn't exist + $Group = [PSCustomObject]@{ + id = $GroupId + displayName = "Unknown Group ($GroupId)" + } + Write-LogMessage -Headers $Headers -API $APIName -message "Group $GroupId not found in lookup, using fallback" -Sev 'Warning' + } + + # Process the assignment even if group lookup failed - we still have the group ID and roles + if ($Group) { + # ================================================================ + # Check if user is a member of this group (direct or nested) + # ================================================================ + # We already have the user's transitive memberships, so we can + # quickly check if they're a member using our hashtable lookup. + # This is O(1) performance. + # ================================================================ + $IsMember = $UserGroupMemberships.ContainsKey($GroupId) + $MembershipPath = @() + $IsPathComplete = $false + + if ($IsMember) { + # ============================================================ + # User IS a member (either direct or nested) + # ============================================================ + # We know from transitiveMemberOf that the user is a member, + # but we need to determine if it's direct or nested, and if + # nested, try to find the path through intermediate groups. + # ============================================================ + $IsPathComplete = $true + # Start with assumption of direct membership + $MembershipPath = @( + @{ + groupId = $GroupId + groupName = $Group.displayName + membershipType = 'direct' + } + ) + + # ============================================================ + # Determine if membership is direct or nested + # ============================================================ + # We check the direct members of the group to see if the user + # is directly in it. If not, they must be nested (through + # another group that's a member of this group). + # ============================================================ + try { + # Get direct members of the target group + $DirectMembers = New-GraphGetRequest -Uri "https://graph.microsoft.com/beta/groups/$GroupId/members?`$select=id,displayName,userPrincipalName" -tenantid $env:TenantID -NoAuthCheck $true -AsApp $true + $IsDirectMember = $DirectMembers.value | Where-Object { $_.id -eq $UserId } + + if (-not $IsDirectMember) { + # ==================================================== + # User is nested - find the path through nested groups + # ==================================================== + # The user is not directly in this group, so they must + # be in a group that's a member of this group. + # We try to find which of the user's direct groups + # are members of this target group. + # ==================================================== + $MembershipPath[0].membershipType = 'nested' + + # Get groups the user is directly in (not nested) + $UserDirectGroups = New-GraphGetRequest -Uri "https://graph.microsoft.com/beta/users/$UserId/memberOf?`$select=id,displayName" -tenantid $env:TenantID -NoAuthCheck $true -AsApp $true -ErrorAction SilentlyContinue + if ($UserDirectGroups) { + $NestedGroups = @() + # Check each of the user's direct groups + foreach ($UserGroup in $UserDirectGroups) { + if ($UserGroup.'@odata.type' -eq '#microsoft.graph.group') { + try { + # Check if this user's direct group is a member of the target group + $GroupMembers = New-GraphGetRequest -Uri "https://graph.microsoft.com/beta/groups/$GroupId/members?`$select=id" -tenantid $env:TenantID -NoAuthCheck $true -AsApp $true -ErrorAction SilentlyContinue + if ($GroupMembers.value | Where-Object { $_.id -eq $UserGroup.id }) { + # Found it! This is the intermediate group + $NestedGroups += @{ + groupId = $UserGroup.id + groupName = $UserGroup.displayName + membershipType = 'direct' # User is direct member of this intermediate group + } + } + } catch { + # Skip if we can't check (permissions issue, etc.) + } + } + } + if ($NestedGroups.Count -gt 0) { + # Build the complete path: User → Intermediate Group → Target Group + # Add the target group to complete the path + $NestedGroups += @{ + groupId = $GroupId + groupName = $Group.displayName + membershipType = 'nested' # Intermediate group is nested in target group + } + $MembershipPath = $NestedGroups + } + } + } + # If IsDirectMember is true, membershipPath already shows 'direct' - we're done + } catch { + # If we can't check direct members (permissions, API error), assume nested + # This is a safe assumption - we know they're a member somehow + $MembershipPath[0].membershipType = 'nested' + } + } else { + # ============================================================ + # User is NOT a member of this group + # ============================================================ + # The group exists and has roles assigned, but the user isn't + # a member. This represents a broken path - the role is assigned + # but the user can't access it. + # ============================================================ + # Check if the group has any members at all (for diagnostic purposes) + $GroupHasMembers = $false + try { + $GroupMembers = New-GraphGetRequest -Uri "https://graph.microsoft.com/beta/groups/$GroupId/members?`$top=1" -tenantid $env:TenantID -NoAuthCheck $true -AsApp $true -ErrorAction SilentlyContinue + $GroupHasMembers = $GroupMembers.value.Count -gt 0 + } catch { + $GroupHasMembers = $false + } + + # Record the broken path + $MembershipPath = @( + @{ + groupId = $GroupId + groupName = $Group.displayName + membershipType = 'not_member' + groupHasMembers = $GroupHasMembers # Helps diagnose if group is empty + } + ) + } + + # ================================================================ + # Store group data for this relationship + # ================================================================ + # We store all the information about this group including: + # - Whether user is a member + # - The membership path (direct/nested/not_member) + # - All roles assigned to this group + # ================================================================ + $GroupData = [PSCustomObject]@{ + groupId = $GroupId + groupName = $Group.displayName + roles = $Roles # Array of role objects with roleDefinitionId + isMember = $IsMember + isPathComplete = $IsPathComplete # True if user can access this group + membershipPath = $MembershipPath # The path showing how user gets access (or why they don't) + assignmentStatus = $Assignment.status # Status of the access assignment + } + + $RelationshipGroups.Add($GroupData) + Write-LogMessage -Headers $Headers -API $APIName -message "Processed group $GroupDisplayName ($GroupId) with $($Roles.Count) roles for relationship ${RelationshipName}" -Sev 'Debug' + + # ================================================================ + # Map each role to this relationship/group combination + # ================================================================ + # This builds our role-to-relationships map that we'll use later + # to check each of the 15 GDAP roles. For each role, we'll know: + # - Which relationships have it + # - Which groups in those relationships have it + # - Whether the user is a member of those groups + # ================================================================ + if ($Roles -and $Roles.Count -gt 0) { + foreach ($Role in $Roles) { + # Handle both direct role objects and role objects with roleDefinitionId property + $RoleId = $null + if ($Role.roleDefinitionId) { + $RoleId = $Role.roleDefinitionId + } elseif ($Role -is [string]) { + $RoleId = $Role + } else { + Write-LogMessage -Headers $Headers -API $APIName -message "Role object missing roleDefinitionId: $($Role | ConvertTo-Json -Compress)" -Sev 'Warning' + continue + } + + if ([string]::IsNullOrWhiteSpace($RoleId)) { + Write-LogMessage -Headers $Headers -API $APIName -message "Role has empty roleDefinitionId for group $GroupId" -Sev 'Warning' + continue + } + + # Initialize list for this role if we haven't seen it before + if (-not $RoleToRelationshipsMap.ContainsKey($RoleId)) { + $RoleToRelationshipsMap[$RoleId] = [System.Collections.Generic.List[object]]::new() + } + # Add this relationship/group combination to the role's list + $RoleToRelationshipsMap[$RoleId].Add([PSCustomObject]@{ + relationshipId = $RelationshipId + relationshipName = $RelationshipName + relationshipStatus = $RelationshipStatus + groupId = $GroupId + groupName = $Group.displayName + groupData = $GroupData # Full group data including membership info + }) + } + } + } + } + + # Store relationship data for reference + $AllRelationshipData.Add([PSCustomObject]@{ + relationshipId = $RelationshipId + relationshipName = $RelationshipName + relationshipStatus = $RelationshipStatus + customerTenantId = $Relationship.customer.tenantId + customerTenantName = $Relationship.customer.displayName + groups = $RelationshipGroups + }) + } + + # ============================================================================ + # STEP 7: Trace each of the 15 GDAP roles to the user + # ============================================================================ + # This is the core logic - for each of the 15 standard GDAP roles, we: + # 1. Find all relationships/groups that have this role assigned + # 2. Check if the user is a member of any of those groups + # 3. Build the complete access path showing how the user gets the role (if they do) + # 4. Identify broken paths (role assigned but user not a member) + # + # The result is a role-centric view where each role shows: + # - Whether it's assigned in any relationship + # - Whether the user has access to it + # - All relationships/groups that have it + # - The complete path from role to user (if access exists) + # ============================================================================ + $RoleTraces = [System.Collections.Generic.List[object]]::new() + + # Check each of the 15 standard GDAP roles + foreach ($GDAPRole in $AllGDAPRoles) { + $RoleId = $GDAPRole.Id + $RoleName = $GDAPRole.Name + $RoleDescription = $GDAPRole.Description + + # ======================================================================== + # Find all relationships/groups that have this role assigned + # ======================================================================== + # We use the RoleToRelationshipsMap we built earlier. For each role, + # this map contains all relationship/group combinations that have + # this role assigned. + # ======================================================================== + $RelationshipsWithRole = @() + $UserHasAccess = $false + $AccessPaths = [System.Collections.Generic.List[object]]::new() + + if ($RoleToRelationshipsMap.ContainsKey($RoleId)) { + # This role exists in at least one relationship + foreach ($RoleRelationship in $RoleToRelationshipsMap[$RoleId]) { + $GroupData = $RoleRelationship.groupData + + # Record all relationships/groups that have this role (for reference) + $RelationshipsWithRole += [PSCustomObject]@{ + relationshipId = $RoleRelationship.relationshipId + relationshipName = $RoleRelationship.relationshipName + relationshipStatus = $RoleRelationship.relationshipStatus + groupId = $RoleRelationship.groupId + groupName = $RoleRelationship.groupName + isUserMember = $GroupData.isMember # Whether user is in this group + membershipPath = $GroupData.membershipPath # How user gets access (or why they don't) + } + + # ================================================================ + # Check if user has access through this group + # ================================================================ + # If the user is a member of this group (direct or nested), + # they have access to this role. We only need ONE path where + # the user is a member - if they're in any group with this role, + # they have access. + # ================================================================ + if ($GroupData.isMember) { + $UserHasAccess = $true + # Record the access path for this role + $AccessPaths.Add([PSCustomObject]@{ + relationshipId = $RoleRelationship.relationshipId + relationshipName = $RoleRelationship.relationshipName + groupId = $RoleRelationship.groupId + groupName = $RoleRelationship.groupName + membershipPath = $GroupData.membershipPath # Shows: User → Group (or User → Intermediate → Group) + }) + } + } + } + + # ======================================================================== + # Build the role trace object + # ======================================================================== + # This contains all information about this role: + # - roleExistsInRelationship: Role is available in at least one relationship (may not be assigned to any group) + # - isAssigned: Role is assigned to at least one group (must exist in relationship first) + # - isUserHasAccess: User is a member of at least one group with this role + # - relationshipsWithRole: All relationships/groups that have this role assigned + # - relationshipsWithRoleAvailable: All relationships where this role is available (but may not be assigned) + # - accessPaths: Only the paths where user actually has access (if any) + # ======================================================================== + $RoleExistsInRelationship = $RoleToAvailableRelationshipsMap.ContainsKey($RoleId) + $IsAssigned = $RelationshipsWithRole.Count -gt 0 + + # Get relationships where role is available but may not be assigned + $RelationshipsWithRoleAvailable = @() + if ($RoleToAvailableRelationshipsMap.ContainsKey($RoleId)) { + $RelationshipsWithRoleAvailable = $RoleToAvailableRelationshipsMap[$RoleId] + } + + $RoleTraces.Add([PSCustomObject]@{ + roleName = $RoleName + roleId = $RoleId + roleDescription = $RoleDescription + roleExistsInRelationship = $RoleExistsInRelationship # Role exists in at least one relationship + isAssigned = $IsAssigned # Role is assigned to at least one group + isUserHasAccess = $UserHasAccess + relationshipsWithRole = $RelationshipsWithRole # All places this role is assigned to groups + relationshipsWithRoleAvailable = $RelationshipsWithRoleAvailable # All relationships where role is available + accessPaths = $AccessPaths # Only paths where user has access + }) + } + + # ============================================================================ + # STEP 8: Build final result structure - role-centric view + # ============================================================================ + # The output is structured to be role-centric, making it easy to: + # - See which of the 15 roles the user has access to + # - See which roles are missing + # - See the complete path for each role (if access exists) + # - Identify broken paths (roles assigned but user not a member) + # + # The JSON structure is designed for diagram visualization, showing the + # complete chain: Role → Relationship → Group → User (with nested groups) + # ============================================================================ + + # Calculate summary statistics + $RolesWithAccess = ($RoleTraces | Where-Object { $_.isUserHasAccess -eq $true }).Count + $RolesAssignedButNoAccess = ($RoleTraces | Where-Object { ($_.isAssigned -eq $true) -and ($_.isUserHasAccess -eq $false) }).Count + $RolesInRelationshipButNotAssigned = ($RoleTraces | Where-Object { ($_.roleExistsInRelationship -eq $true) -and ($_.isAssigned -eq $false) }).Count + $RolesNotInAnyRelationship = ($RoleTraces | Where-Object { $_.roleExistsInRelationship -eq $false }).Count + + # Build the results object with role-centric view + $Results = [PSCustomObject]@{ + tenantId = $CustomerTenantId + tenantName = $CustomerTenantName + userUPN = $UPN + userId = $UserId + userDisplayName = $UserDisplayName + roles = $RoleTraces + relationships = $AllRelationshipData + summary = [PSCustomObject]@{ + totalRelationships = $Relationships.Count + totalRoles = $AllGDAPRoles.Count + rolesWithAccess = $RolesWithAccess + rolesAssignedButNoAccess = $RolesAssignedButNoAccess + rolesInRelationshipButNotAssigned = $RolesInRelationshipButNotAssigned + rolesNotInAnyRelationship = $RolesNotInAnyRelationship + } + } + + return [HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::OK + Body = $Results + } + + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -Headers $Headers -API $APIName -message "Failed to test GDAP access path: $($ErrorMessage.NormalizedError)" -Sev 'Error' -LogData $ErrorMessage + return [HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::InternalServerError + Body = @{ Error = $ErrorMessage.NormalizedError } + } + } +} From d2aecb2dad9490f614473d7afe4182a7b8f31fd1 Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Fri, 20 Feb 2026 11:07:56 +0100 Subject: [PATCH 85/97] minor update to fix grantControls --- Modules/CIPPCore/Public/New-CIPPCAPolicy.ps1 | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/Modules/CIPPCore/Public/New-CIPPCAPolicy.ps1 b/Modules/CIPPCore/Public/New-CIPPCAPolicy.ps1 index 1196ee1f7488..76fba584da6d 100644 --- a/Modules/CIPPCore/Public/New-CIPPCAPolicy.ps1 +++ b/Modules/CIPPCore/Public/New-CIPPCAPolicy.ps1 @@ -93,7 +93,11 @@ function New-CIPPCAPolicy { #Remove context as it does not belong in the payload. try { if ($JSONobj.grantControls) { - $JSONobj.grantControls.PSObject.Properties.Remove('authenticationStrength@odata.context') + try { + $JSONobj.grantControls.PSObject.Properties.Remove('authenticationStrength@odata.context') + } catch { + #did not need to remove because didn't exist. + } } $JSONobj.templateId ? $JSONobj.PSObject.Properties.Remove('templateId') : $null if ($JSONobj.conditions.users.excludeGuestsOrExternalUsers.externalTenants.Members) { @@ -428,7 +432,7 @@ function New-CIPPCAPolicy { # Preserve any exclusion groups named "Vacation Exclusion - " from existing policy try { $ExistingVacationGroup = New-GraphGETRequest -uri "https://graph.microsoft.com/beta/groups?`$filter=startsWith(displayName,'Vacation Exclusion')&`$select=id,displayName&`$top=999&`$count=true" -ComplexFilter -tenantid $TenantFilter -asApp $true | - Where-Object { $CheckExisting.conditions.users.excludeGroups -contains $_.id } + Where-Object { $CheckExisting.conditions.users.excludeGroups -contains $_.id } if ($ExistingVacationGroup) { if (-not ($JSONobj.conditions.users.PSObject.Properties.Name -contains 'excludeGroups')) { $JSONobj.conditions.users | Add-Member -NotePropertyName 'excludeGroups' -NotePropertyValue @() -Force From 25d325459ec9843412d99d361dff3742ee9e2c79 Mon Sep 17 00:00:00 2001 From: rvdwegen Date: Fri, 20 Feb 2026 13:22:32 +0100 Subject: [PATCH 86/97] UploadApplication changes --- .../Public/Add-CIPPPackagedApplication.ps1 | 77 ++++++ .../Public/Add-CIPPW32ScriptApplication.ps1 | 186 +++++++++++++++ .../Public/Add-CIPPWin32LobAppContent.ps1 | 152 ++++++++++++ Modules/CIPPCore/Public/Add-CIPPWinGetApp.ps1 | 24 ++ .../Applications/Push-UploadApplication.ps1 | 225 +++++++++++------- 5 files changed, 575 insertions(+), 89 deletions(-) create mode 100644 Modules/CIPPCore/Public/Add-CIPPPackagedApplication.ps1 create mode 100644 Modules/CIPPCore/Public/Add-CIPPW32ScriptApplication.ps1 create mode 100644 Modules/CIPPCore/Public/Add-CIPPWin32LobAppContent.ps1 create mode 100644 Modules/CIPPCore/Public/Add-CIPPWinGetApp.ps1 diff --git a/Modules/CIPPCore/Public/Add-CIPPPackagedApplication.ps1 b/Modules/CIPPCore/Public/Add-CIPPPackagedApplication.ps1 new file mode 100644 index 000000000000..0730a11e16a1 --- /dev/null +++ b/Modules/CIPPCore/Public/Add-CIPPPackagedApplication.ps1 @@ -0,0 +1,77 @@ +function Add-CIPPPackagedApplication { + <# + .SYNOPSIS + Adds a packaged Win32Lob application to Intune. + + .DESCRIPTION + Handles creation of Win32Lob apps with intunewin files and uploads the content. + + .PARAMETER AppBody + Hashtable or PSCustomObject containing the app configuration. + + .PARAMETER TenantFilter + Tenant ID or domain name for the Graph API call. + + .PARAMETER AppType + Type of app: 'Choco' or 'MSPApp'. + + .PARAMETER FilePath + Path to the intunewin file. + + .PARAMETER FileName + Name of the file from XML metadata. + + .PARAMETER UnencryptedSize + Unencrypted size of the file from XML metadata. + + .PARAMETER EncryptionInfo + Hashtable containing encryption information from XML. + + .PARAMETER DisplayName + Display name of the app for logging. + + .PARAMETER APIName + API name for logging (optional). + + .PARAMETER Headers + Request headers for logging (optional). + + .EXAMPLE + $AppBody = @{ '@odata.type' = '#microsoft.graph.win32LobApp'; displayName = 'My App' } + $EncryptionInfo = @{ EncryptionKey = '...'; MacKey = '...'; ... } + Add-CIPPPackagedApplication -AppBody $AppBody -TenantFilter 'contoso.com' -AppType 'Choco' -FilePath 'app.intunewin' -FileName 'app.intunewin' -UnencryptedSize 1024000 -EncryptionInfo $EncryptionInfo + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [object]$AppBody, + + [Parameter(Mandatory = $true)] + [string]$TenantFilter, + + [Parameter(Mandatory = $true)] + [string]$FilePath, + + [Parameter(Mandatory = $true)] + [string]$FileName, + + [Parameter(Mandatory = $true)] + [int64]$UnencryptedSize, + + [Parameter(Mandatory = $true)] + [hashtable]$EncryptionInfo, + + [Parameter(Mandatory = $false)] + [string]$DisplayName + ) + + $BaseUri = 'https://graph.microsoft.com/beta/deviceAppManagement/mobileApps' + + # Create the Win32Lob app + $NewApp = New-GraphPostRequest -Uri $BaseUri -Body ($AppBody | ConvertTo-Json) -Type POST -tenantid $TenantFilter + + # Upload intunewin content + Add-CIPPWin32LobAppContent -AppId $NewApp.id -FilePath $FilePath -FileName $FileName -UnencryptedSize $UnencryptedSize -EncryptionInfo $EncryptionInfo -TenantFilter $TenantFilter -APIName $APIName -Headers $Headers + + return $NewApp +} diff --git a/Modules/CIPPCore/Public/Add-CIPPW32ScriptApplication.ps1 b/Modules/CIPPCore/Public/Add-CIPPW32ScriptApplication.ps1 new file mode 100644 index 000000000000..add8ade366d2 --- /dev/null +++ b/Modules/CIPPCore/Public/Add-CIPPW32ScriptApplication.ps1 @@ -0,0 +1,186 @@ +function Add-CIPPW32ScriptApplication { + <# + .SYNOPSIS + Adds a Win32 app with PowerShell script installer to Intune. + + .DESCRIPTION + Creates a Win32 app using the PowerShell script installer feature. + Uploads an intunewin file and PowerShell scripts via the scripts endpoint. + + .PARAMETER TenantFilter + Tenant ID or domain name for the Graph API call. + + .PARAMETER Properties + PSCustomObject containing all Win32 app properties: + - displayName (required): Display name of the app + - description: Description of the app + - publisher: Publisher name + - installScript (required): PowerShell install script content (plaintext) + - uninstallScript: PowerShell uninstall script content (plaintext) + - detectionScript: PowerShell detection script content (plaintext) + - runAsAccount: 'system' or 'user' (default: 'system') + - deviceRestartBehavior: 'allow', 'suppress', or 'force' (default: 'suppress') + - runAs32Bit: Boolean, run scripts as 32-bit on 64-bit clients (default: false) + - enforceSignatureCheck: Boolean, enforce script signature validation (default: false) + + .PARAMETER FilePath + Path to the intunewin file. + + .PARAMETER FileName + Name of the file from XML metadata. + + .PARAMETER UnencryptedSize + Unencrypted size of the file from XML metadata. + + .PARAMETER EncryptionInfo + Hashtable containing encryption information from XML. + + .EXAMPLE + $Properties = @{ + displayName = 'My Script App' + installScript = 'Write-Host "Installing..."' + } + $EncryptionInfo = @{ EncryptionKey = '...'; MacKey = '...'; ... } + Add-CIPPW32ScriptApplication -TenantFilter 'contoso.com' -Properties $Properties -FilePath 'app.intunewin' -FileName 'app.intunewin' -UnencryptedSize 1024000 -EncryptionInfo $EncryptionInfo + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter, + + [Parameter(Mandatory = $true)] + [PSCustomObject]$Properties, + + [Parameter(Mandatory = $true)] + [string]$FilePath, + + [Parameter(Mandatory = $true)] + [string]$FileName, + + [Parameter(Mandatory = $true)] + [int64]$UnencryptedSize, + + [Parameter(Mandatory = $true)] + [hashtable]$EncryptionInfo + ) + + # Build Win32 app body + $intuneBody = @{ + '@odata.type' = '#microsoft.graph.win32LobApp' + displayName = $Properties.displayName + description = $Properties.description + publisher = $Properties.publisher + fileName = $FileName + setupFilePath = 'N/A' + minimumSupportedWindowsRelease = '1607' + returnCodes = @( + @{ returnCode = 0; type = 'success' } + @{ returnCode = 1707; type = 'success' } + @{ returnCode = 3010; type = 'softReboot' } + @{ returnCode = 1641; type = 'hardReboot' } + @{ returnCode = 1618; type = 'retry' } + ) + } + + # Add install experience + $intuneBody.installExperience = @{ + '@odata.type' = 'microsoft.graph.win32LobAppInstallExperience' + runAsAccount = if ($Properties.runAsAccount) { $Properties.runAsAccount } else { 'system' } + deviceRestartBehavior = if ($Properties.deviceRestartBehavior) { $Properties.deviceRestartBehavior } else { 'suppress' } + maxRunTimeInMinutes = 60 + } + + # Create the app + $Baseuri = 'https://graph.microsoft.com/beta/deviceAppManagement/mobileApps' + $NewApp = New-GraphPostRequest -Uri $Baseuri -Body ($intuneBody | ConvertTo-Json -Depth 10) -Type POST -tenantid $TenantFilter + Start-Sleep -Milliseconds 200 + + # Upload intunewin file using shared helper + Add-CIPPWin32LobAppContent -AppId $NewApp.id -FilePath $FilePath -FileName $FileName -UnencryptedSize $UnencryptedSize -EncryptionInfo $EncryptionInfo -TenantFilter $TenantFilter + + # Upload PowerShell scripts via the scripts endpoint + $RunAs32Bit = if ($null -ne $Properties.runAs32Bit) { [bool]$Properties.runAs32Bit } else { $false } + $EnforceSignatureCheck = if ($null -ne $Properties.enforceSignatureCheck) { [bool]$Properties.enforceSignatureCheck } else { $false } + + $InstallScriptId = $null + $UninstallScriptId = $null + + if ($Properties.installScript) { + $InstallScriptContent = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($Properties.installScript)) + $InstallScriptBody = @{ + '@odata.type' = '#microsoft.graph.win32LobAppInstallPowerShellScript' + displayName = 'install.ps1' + enforceSignatureCheck = $EnforceSignatureCheck + runAs32Bit = $RunAs32Bit + content = $InstallScriptContent + } | ConvertTo-Json + + $InstallScriptResponse = New-GraphPostRequest -Uri "$Baseuri/$($NewApp.id)/microsoft.graph.win32LobApp/contentVersions/1/scripts" -Body $InstallScriptBody -Type POST -tenantid $TenantFilter + $InstallScriptId = $InstallScriptResponse.id + + # Wait for script to be committed + do { + $ScriptState = New-GraphGetRequest -Uri "$Baseuri/$($NewApp.id)/microsoft.graph.win32LobApp/contentVersions/1/scripts/$InstallScriptId" -tenantid $TenantFilter + if ($ScriptState.state -like '*fail*') { + throw "Failed to commit install script: $($ScriptState.state)" + } + Start-Sleep -Milliseconds 300 + } while ($ScriptState.state -eq 'commitPending') + } + + if ($Properties.uninstallScript) { + $UninstallScriptContent = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($Properties.uninstallScript)) + $UninstallScriptBody = @{ + '@odata.type' = '#microsoft.graph.win32LobAppUninstallPowerShellScript' + displayName = 'uninstall.ps1' + enforceSignatureCheck = $EnforceSignatureCheck + runAs32Bit = $RunAs32Bit + content = $UninstallScriptContent + } | ConvertTo-Json + + $UninstallScriptResponse = New-GraphPostRequest -Uri "$Baseuri/$($NewApp.id)/microsoft.graph.win32LobApp/contentVersions/1/scripts" -Body $UninstallScriptBody -Type POST -tenantid $TenantFilter + $UninstallScriptId = $UninstallScriptResponse.id + + # Wait for script to be committed + do { + $ScriptState = New-GraphGetRequest -Uri "$Baseuri/$($NewApp.id)/microsoft.graph.win32LobApp/contentVersions/1/scripts/$UninstallScriptId" -tenantid $TenantFilter + if ($ScriptState.state -like '*fail*') { + throw "Failed to commit uninstall script: $($ScriptState.state)" + } + Start-Sleep -Milliseconds 300 + } while ($ScriptState.state -eq 'commitPending') + } + + # Build final commit body with active script references + $CommitBody = @{ + '@odata.type' = '#microsoft.graph.win32LobApp' + committedContentVersion = '1' + } + + if ($InstallScriptId) { + $CommitBody['activeInstallScript'] = @{ targetId = $InstallScriptId } + } + + if ($UninstallScriptId) { + $CommitBody['activeUninstallScript'] = @{ targetId = $UninstallScriptId } + } + + # Add detection rules if provided + if ($Properties.detectionScript) { + $DetectionScriptContent = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($Properties.detectionScript)) + $CommitBody['detectionRules'] = @( + @{ + '@odata.type' = '#microsoft.graph.win32LobAppPowerShellScriptDetection' + scriptContent = $DetectionScriptContent + enforceSignatureCheck = $EnforceSignatureCheck + runAs32Bit = $RunAs32Bit + } + ) + } + + # Commit the app with script references + $null = New-GraphPostRequest -Uri "$Baseuri/$($NewApp.id)" -tenantid $TenantFilter -Body ($CommitBody | ConvertTo-Json -Depth 10) -Type PATCH + + return $NewApp + +} diff --git a/Modules/CIPPCore/Public/Add-CIPPWin32LobAppContent.ps1 b/Modules/CIPPCore/Public/Add-CIPPWin32LobAppContent.ps1 new file mode 100644 index 000000000000..1b238870e622 --- /dev/null +++ b/Modules/CIPPCore/Public/Add-CIPPWin32LobAppContent.ps1 @@ -0,0 +1,152 @@ +function Add-CIPPWin32LobAppContent { + <# + .SYNOPSIS + Uploads intunewin file content to a Win32Lob app in Intune. + + .DESCRIPTION + This function handles the complete process of uploading an intunewin file to a Win32Lob app: + 1. Creates a content version file entry + 2. Waits for Azure Storage URI + 3. Uploads the file to Azure Storage in chunks + 4. Commits the file with encryption info + 5. Finalizes the content version + + .PARAMETER AppId + The ID of the Win32Lob app to upload content to. + + .PARAMETER FilePath + Path to the intunewin file to upload. + + .PARAMETER FileName + Name of the file (from XML metadata). + + .PARAMETER UnencryptedSize + Unencrypted size of the file (from XML metadata). + + .PARAMETER EncryptionInfo + Hashtable containing encryption information from XML: + - EncryptionKey + - MacKey + - InitializationVector + - Mac + - ProfileIdentifier + - FileDigest + - FileDigestAlgorithm + + .PARAMETER TenantFilter + Tenant ID or domain name for the Graph API call. + + .PARAMETER APIName + API name for logging (optional). + + .PARAMETER Headers + Request headers for logging (optional). + + .EXAMPLE + $EncryptionInfo = @{ + EncryptionKey = '...' + MacKey = '...' + InitializationVector = '...' + Mac = '...' + ProfileIdentifier = 'ProfileVersion1' + FileDigest = '...' + FileDigestAlgorithm = 'SHA256' + } + Add-CIPPWin32LobAppContent -AppId '12345' -FilePath 'C:\app.intunewin' -FileName 'app.intunewin' -UnencryptedSize 1024000 -EncryptionInfo $EncryptionInfo -TenantFilter 'contoso.com' + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$AppId, + + [Parameter(Mandatory = $true)] + [string]$FilePath, + + [Parameter(Mandatory = $true)] + [string]$FileName, + + [Parameter(Mandatory = $true)] + [int64]$UnencryptedSize, + + [Parameter(Mandatory = $true)] + [hashtable]$EncryptionInfo, + + [Parameter(Mandatory = $true)] + [string]$TenantFilter, + + [Parameter(Mandatory = $false)] + [string]$APIName = 'AppUpload', + + [Parameter(Mandatory = $false)] + [hashtable]$Headers + ) + + $BaseUri = 'https://graph.microsoft.com/beta/deviceAppManagement/mobileApps' + $FileInfo = Get-Item $FilePath + + # Create content version file entry + $ContentBody = ConvertTo-Json @{ + name = $FileName + size = $UnencryptedSize + sizeEncrypted = [int64]$FileInfo.Length + } + + $ContentReq = New-GraphPostRequest -Uri "$BaseUri/$AppId/microsoft.graph.win32lobapp/contentVersions/1/files/" -Body $ContentBody -Type POST -tenantid $TenantFilter + + # Wait for Azure Storage URI + do { + $AzFileUri = New-GraphGetRequest -Uri "$BaseUri/$AppId/microsoft.graph.win32lobapp/contentVersions/1/files/$($ContentReq.id)" -tenantid $TenantFilter + if ($AzFileUri.uploadState -like '*fail*') { + throw "Failed to get Azure Storage URI. Upload state: $($AzFileUri.uploadState)" + } + Start-Sleep -Milliseconds 300 + } while ($null -eq $AzFileUri.AzureStorageUri) + + if ($Headers) { + Write-LogMessage -Headers $Headers -API $APIName -message "Uploading file to $($AzFileUri.azureStorageUri)" -Sev 'Info' -tenant $TenantFilter + } else { + Write-Host "Uploading file to $($AzFileUri.azureStorageUri)" + } + + # Upload file to Azure Storage in chunks + $chunkSizeInBytes = 4mb + [byte[]]$bytes = [System.IO.File]::ReadAllBytes($FileInfo.FullName) + $chunks = [Math]::Ceiling($bytes.Length / $chunkSizeInBytes) + $id = [System.Convert]::ToBase64String([System.Text.Encoding]::ASCII.GetBytes($chunks.ToString('0000'))) + # For anyone that reads this, The maximum chunk size is 100MB for blob storage, so we can upload it as one part and just give it the single ID. Easy :) + $null = Invoke-RestMethod -Uri "$($AzFileUri.azureStorageUri)&comp=block&blockid=$id" -Method Put -Headers @{'x-ms-blob-type' = 'BlockBlob' } -InFile $FilePath -ContentType 'application/octet-stream' + $null = Invoke-RestMethod -Uri "$($AzFileUri.azureStorageUri)&comp=blocklist" -Method Put -Body "$id" -ContentType 'application/xml' + + # Commit the file with encryption info + $EncBody = @{ + fileEncryptionInfo = @{ + encryptionKey = $EncryptionInfo.EncryptionKey + macKey = $EncryptionInfo.MacKey + initializationVector = $EncryptionInfo.InitializationVector + mac = $EncryptionInfo.Mac + profileIdentifier = $EncryptionInfo.ProfileIdentifier + fileDigest = $EncryptionInfo.FileDigest + fileDigestAlgorithm = $EncryptionInfo.FileDigestAlgorithm + } + } | ConvertTo-Json + + $null = New-GraphPostRequest -Uri "$BaseUri/$AppId/microsoft.graph.win32lobapp/contentVersions/1/files/$($ContentReq.id)/commit" -Body $EncBody -Type POST -tenantid $TenantFilter + + # Wait for commit to complete + do { + $CommitStateReq = New-GraphGetRequest -Uri "$BaseUri/$AppId/microsoft.graph.win32lobapp/contentVersions/1/files/$($ContentReq.id)" -tenantid $TenantFilter + if ($CommitStateReq.uploadState -like '*fail*') { + $errorMsg = "Commit failed. Upload state: $($CommitStateReq.uploadState)" + if ($Headers) { + Write-LogMessage -Headers $Headers -API $APIName -message $errorMsg -Sev 'Warning' -tenant $TenantFilter + } + throw $errorMsg + } + Start-Sleep -Milliseconds 300 + } while ($CommitStateReq.uploadState -eq 'commitFilePending') + + # Finalize content version + $null = New-GraphPostRequest -Uri "$BaseUri/$AppId" -tenantid $TenantFilter -Body '{"@odata.type":"#microsoft.graph.win32lobapp","committedContentVersion":"1"}' -type PATCH + + return $true +} diff --git a/Modules/CIPPCore/Public/Add-CIPPWinGetApp.ps1 b/Modules/CIPPCore/Public/Add-CIPPWinGetApp.ps1 new file mode 100644 index 000000000000..df30b2db096c --- /dev/null +++ b/Modules/CIPPCore/Public/Add-CIPPWinGetApp.ps1 @@ -0,0 +1,24 @@ +function Add-CIPPWinGetApp { + <# + .SYNOPSIS + Creates a WinGet app in Intune. + + .DESCRIPTION + Creates a new WinGet app using the provided app body. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [object]$AppBody, + + [Parameter(Mandatory = $true)] + [string]$TenantFilter + ) + + $BaseUri = 'https://graph.microsoft.com/beta/deviceAppManagement/mobileApps' + + # Create the WinGet app + $NewApp = New-GraphPostRequest -Uri $BaseUri -Body ($AppBody | ConvertTo-Json -Compress) -Type POST -tenantid $TenantFilter + + return $NewApp +} diff --git a/Modules/CIPPCore/Public/Entrypoints/Activity Triggers/Applications/Push-UploadApplication.ps1 b/Modules/CIPPCore/Public/Entrypoints/Activity Triggers/Applications/Push-UploadApplication.ps1 index 5aae136f1ba4..e4c19265966b 100644 --- a/Modules/CIPPCore/Public/Entrypoints/Activity Triggers/Applications/Push-UploadApplication.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/Activity Triggers/Applications/Push-UploadApplication.ps1 @@ -14,118 +14,165 @@ function Push-UploadApplication { $CippRoot = (Get-Item $ModuleRoot).Parent.Parent Set-Location $CippRoot - $ChocoApp = (Get-CIPPAzDataTableEntity @Table -filter $Filter).JSON | ConvertFrom-Json - $intuneBody = $ChocoApp.IntuneBody - $tenants = if ($ChocoApp.tenant -eq 'AllTenants') { + $AppConfig = (Get-CIPPAzDataTableEntity @Table -filter $Filter).JSON | ConvertFrom-Json + $intuneBody = $AppConfig.IntuneBody + $tenants = if ($AppConfig.tenant -eq 'AllTenants') { (Get-Tenants -IncludeErrors).defaultDomainName } else { - $ChocoApp.tenant - } - if ($ChocoApp.type -eq 'MSPApp') { - [xml]$Intunexml = Get-Content "AddMSPApp\$($ChocoApp.MSPAppName).app.xml" - $intunewinFilesize = (Get-Item "AddMSPApp\$($ChocoApp.MSPAppName).intunewin") - $Infile = "AddMSPApp\$($ChocoApp.MSPAppName).intunewin" - } else { - [xml]$Intunexml = Get-Content 'AddChocoApp\Choco.App.xml' - $intunewinFilesize = (Get-Item 'AddChocoApp\IntunePackage.intunewin') - $Infile = "AddChocoApp\$($intunexml.ApplicationInfo.FileName)" - } - $assignTo = $ChocoApp.assignTo - $AssignToIntent = $ChocoApp.InstallationIntent - $Baseuri = 'https://graph.microsoft.com/beta/deviceAppManagement/mobileApps' - $ContentBody = ConvertTo-Json @{ - name = $intunexml.ApplicationInfo.FileName - size = [int64]$intunexml.ApplicationInfo.UnencryptedContentSize - sizeEncrypted = [int64]($intunewinFilesize).length + $AppConfig.tenant } + $assignTo = $AppConfig.assignTo + $AssignToIntent = $AppConfig.InstallationIntent $ClearRow = Get-CIPPAzDataTableEntity @Table -Filter $Filter - $RemoveCacheFile = if ($ChocoApp.tenant -ne 'AllTenants') { - Remove-AzDataTableEntity -Force @Table -Entity $clearRow + if ($AppConfig.tenant -ne 'AllTenants') { + $null = Remove-AzDataTableEntity -Force @Table -Entity $clearRow } else { $Table.Force = $true - Add-CIPPAzDataTableEntity @Table -Entity @{ - JSON = "$($ChocoApp | ConvertTo-Json)" + $null = Add-CIPPAzDataTableEntity @Table -Entity @{ + JSON = "$($AppConfig | ConvertTo-Json)" RowKey = "$($ClearRow.RowKey)" PartitionKey = 'apps' status = 'Deployed' } } - $EncBody = @{ - fileEncryptionInfo = @{ - encryptionKey = $intunexml.ApplicationInfo.EncryptionInfo.EncryptionKey - macKey = $intunexml.ApplicationInfo.EncryptionInfo.MacKey - initializationVector = $intunexml.ApplicationInfo.EncryptionInfo.InitializationVector - mac = $intunexml.ApplicationInfo.EncryptionInfo.Mac - profileIdentifier = $intunexml.ApplicationInfo.EncryptionInfo.ProfileIdentifier - fileDigest = $intunexml.ApplicationInfo.EncryptionInfo.FileDigest - fileDigestAlgorithm = $intunexml.ApplicationInfo.EncryptionInfo.FileDigestAlgorithm - } - } | ConvertTo-Json + # Determine app type (default to 'Choco' if not specified) + $AppType = if ($AppConfig.type) { $AppConfig.type } else { 'Choco' } + + # Load files based on app type (only for types that need them) + $Intunexml = $null + $Infile = $null + if ($AppType -eq 'MSPApp') { + [xml]$Intunexml = Get-Content "AddMSPApp\$($AppConfig.MSPAppName).app.xml" + $Infile = "AddMSPApp\$($AppConfig.MSPAppName).intunewin" + } elseif ($AppType -in @('Choco', 'Win32ScriptApp')) { + [xml]$Intunexml = Get-Content 'AddChocoApp\Choco.App.xml' + $Infile = "AddChocoApp\$($Intunexml.ApplicationInfo.FileName)" + } + + + $baseuri = 'https://graph.microsoft.com/beta/deviceAppManagement/mobileApps' foreach ($tenant in $tenants) { try { - $ApplicationList = New-GraphGetRequest -Uri $baseuri -tenantid $tenant | Where-Object { $_.DisplayName -eq $ChocoApp.Applicationname -and ($_.'@odata.type' -eq '#microsoft.graph.win32LobApp' -or $_.'@odata.type' -eq '#microsoft.graph.winGetApp') } + # Check if app already exists + $ApplicationList = New-GraphGetRequest -Uri $baseuri -tenantid $tenant | Where-Object { $_.DisplayName -eq $AppConfig.Applicationname -and ($_.'@odata.type' -eq '#microsoft.graph.win32LobApp' -or $_.'@odata.type' -eq '#microsoft.graph.winGetApp') } if ($ApplicationList.displayname.count -ge 1) { - Write-LogMessage -api 'AppUpload' -tenant $tenant -message "$($ChocoApp.Applicationname) exists. Skipping this application" -Sev 'Info' + Write-LogMessage -api 'AppUpload' -tenant $tenant -message "$($AppConfig.Applicationname) exists. Skipping this application" -Sev 'Info' continue } - if ($ChocoApp.type -eq 'WinGet') { - Write-Host 'Winget!' - Write-Host ($intuneBody | ConvertTo-Json -Compress) - $NewApp = New-GraphPostRequest -Uri $baseuri -Body ($intuneBody | ConvertTo-Json -Compress) -Type POST -tenantid $tenant - Start-Sleep -Milliseconds 200 - Write-LogMessage -api 'AppUpload' -tenant $tenant -message "$($ChocoApp.Applicationname) uploaded as WinGet app." -Sev 'Info' - if ($AssignTo -ne 'On') { - $intent = if ($AssignToIntent) { 'Uninstall' } else { 'Required' } - Set-CIPPAssignedApplication -ApplicationId $NewApp.Id -Intent $intent -TenantFilter $tenant -groupName "$AssignTo" -AppType 'WinGet' + + # Route to appropriate handler based on app type + $NewApp = $null + switch ($AppType) { + 'WinGet' { + $NewApp = Add-CIPPWinGetApp -AppBody $intuneBody -TenantFilter $tenant } - Write-LogMessage -api 'AppUpload' -tenant $tenant -message "$($ChocoApp.Applicationname) Successfully created" -Sev 'Info' - continue - } else { - $NewApp = New-GraphPostRequest -Uri $baseuri -Body ($intuneBody | ConvertTo-Json) -Type POST -tenantid $tenant + 'Choco' { + # Prepare encryption info from XML + $EncryptionInfo = @{ + EncryptionKey = $Intunexml.ApplicationInfo.EncryptionInfo.EncryptionKey + MacKey = $Intunexml.ApplicationInfo.EncryptionInfo.MacKey + InitializationVector = $Intunexml.ApplicationInfo.EncryptionInfo.InitializationVector + Mac = $Intunexml.ApplicationInfo.EncryptionInfo.Mac + ProfileIdentifier = $Intunexml.ApplicationInfo.EncryptionInfo.ProfileIdentifier + FileDigest = $Intunexml.ApplicationInfo.EncryptionInfo.FileDigest + FileDigestAlgorithm = $Intunexml.ApplicationInfo.EncryptionInfo.FileDigestAlgorithm + } - } - $ContentReq = New-GraphPostRequest -Uri "$($BaseURI)/$($NewApp.id)/microsoft.graph.win32lobapp/contentVersions/1/files/" -Body $ContentBody -Type POST -tenantid $tenant - do { - $AzFileUri = New-graphGetRequest -Uri "$($BaseURI)/$($NewApp.id)/microsoft.graph.win32lobapp/contentVersions/1/files/$($ContentReq.id)" -tenantid $tenant - if ($AZfileuri.uploadState -like '*fail*') { break } - Start-Sleep -Milliseconds 300 - } while ($AzFileUri.AzureStorageUri -eq $null) - Write-Host "Uploading file to $($AzFileUri.azureStorageUri)" - Write-Host "Complete AZ file uri data: $($AzFileUri | ConvertTo-Json -Depth 10)" - $chunkSizeInBytes = 4mb - [byte[]]$bytes = [System.IO.File]::ReadAllBytes($($intunewinFilesize.fullname)) - $chunks = [Math]::Ceiling($bytes.Length / $chunkSizeInBytes) - $id = [System.Convert]::ToBase64String([System.Text.Encoding]::ASCII.GetBytes($chunks.ToString('0000'))) - #For anyone that reads this, The maximum chunk size is 100MB for blob storage, so we can upload it as one part and just give it the single ID. Easy :) - $Upload = Invoke-RestMethod -Uri "$($AzFileUri.azureStorageUri)&comp=block&blockid=$id" -Method Put -Headers @{'x-ms-blob-type' = 'BlockBlob' } -InFile $inFile -ContentType 'application/octet-stream' - Write-Host "Upload data: $($Upload | ConvertTo-Json -Depth 10)" - $ConfirmUpload = Invoke-RestMethod -Uri "$($AzFileUri.azureStorageUri)&comp=blocklist" -Method Put -Body "$id" -ContentType 'application/xml' - Write-Host "Confirm Upload data: $($ConfirmUpload | ConvertTo-Json -Depth 10)" - $CommitReq = New-graphPostRequest -Uri "$($BaseURI)/$($NewApp.id)/microsoft.graph.win32lobapp/contentVersions/1/files/$($ContentReq.id)/commit" -Body $EncBody -Type POST -tenantid $tenant - Write-Host "Commit Request: $($CommitReq | ConvertTo-Json -Depth 10)" - - do { - $CommitStateReq = New-graphGetRequest -Uri "$($BaseURI)/$($NewApp.id)/microsoft.graph.win32lobapp/contentVersions/1/files/$($ContentReq.id)" -tenantid $tenant - Write-Host "Commit State Request: $($CommitStateReq | ConvertTo-Json -Depth 10)" - if ($CommitStateReq.uploadState -like '*fail*') { - Write-LogMessage -api 'AppUpload' -tenant $tenant -message "$($ChocoApp.Applicationname) Commit failed. Please check if app uploaded succesful" -Sev 'Warning' - break + # Build parameters dynamically + $Params = @{ + AppBody = $intuneBody + TenantFilter = $tenant + FilePath = $Infile + FileName = $Intunexml.ApplicationInfo.FileName + UnencryptedSize = [int64]$Intunexml.ApplicationInfo.UnencryptedContentSize + EncryptionInfo = $EncryptionInfo + } + if ($AppConfig.Applicationname) { $Params.DisplayName = $AppConfig.Applicationname } + + $NewApp = Add-CIPPPackagedApplication @Params + } + 'MSPApp' { + # Prepare encryption info from XML + $EncryptionInfo = @{ + EncryptionKey = $Intunexml.ApplicationInfo.EncryptionInfo.EncryptionKey + MacKey = $Intunexml.ApplicationInfo.EncryptionInfo.MacKey + InitializationVector = $Intunexml.ApplicationInfo.EncryptionInfo.InitializationVector + Mac = $Intunexml.ApplicationInfo.EncryptionInfo.Mac + ProfileIdentifier = $Intunexml.ApplicationInfo.EncryptionInfo.ProfileIdentifier + FileDigest = $Intunexml.ApplicationInfo.EncryptionInfo.FileDigest + FileDigestAlgorithm = $Intunexml.ApplicationInfo.EncryptionInfo.FileDigestAlgorithm + } + + # Build parameters dynamically + $Params = @{ + AppBody = $intuneBody + TenantFilter = $tenant + FilePath = $Infile + FileName = $Intunexml.ApplicationInfo.FileName + UnencryptedSize = [int64]$Intunexml.ApplicationInfo.UnencryptedContentSize + EncryptionInfo = $EncryptionInfo + } + if ($AppConfig.Applicationname) { $Params.DisplayName = $AppConfig.Applicationname } + + $NewApp = Add-CIPPPackagedApplication @Params + } + 'Win32ScriptApp' { + # Prepare encryption info from XML + $EncryptionInfo = @{ + EncryptionKey = $Intunexml.ApplicationInfo.EncryptionInfo.EncryptionKey + MacKey = $Intunexml.ApplicationInfo.EncryptionInfo.MacKey + InitializationVector = $Intunexml.ApplicationInfo.EncryptionInfo.InitializationVector + Mac = $Intunexml.ApplicationInfo.EncryptionInfo.Mac + ProfileIdentifier = $Intunexml.ApplicationInfo.EncryptionInfo.ProfileIdentifier + FileDigest = $Intunexml.ApplicationInfo.EncryptionInfo.FileDigest + FileDigestAlgorithm = $Intunexml.ApplicationInfo.EncryptionInfo.FileDigestAlgorithm + } + + # Build properties dynamically + $Properties = @{ + displayName = $AppConfig.Applicationname + installScript = $AppConfig.installScript + } + + # A few of these are probably mandatory + if ($AppConfig.description) { $Properties['description'] = $AppConfig.description } + if ($AppConfig.publisher) { $Properties['publisher'] = $AppConfig.publisher } + if ($AppConfig.uninstallScript) { $Properties['uninstallScript'] = $AppConfig.uninstallScript } + if ($AppConfig.detectionScript) { $Properties['detectionScript'] = $AppConfig.detectionScript } + if ($AppConfig.runAsAccount) { $Properties['runAsAccount'] = $AppConfig.runAsAccount } + if ($AppConfig.deviceRestartBehavior) { $Properties['deviceRestartBehavior'] = $AppConfig.deviceRestartBehavior } + if ($null -ne $AppConfig.runAs32Bit) { $Properties['runAs32Bit'] = $AppConfig.runAs32Bit } + if ($null -ne $AppConfig.enforceSignatureCheck) { $Properties['enforceSignatureCheck'] = $AppConfig.enforceSignatureCheck } + + $NewApp = Add-CIPPW32ScriptApplication -TenantFilter $tenant -Properties ([PSCustomObject]$Properties) -FilePath $Infile -FileName $Intunexml.ApplicationInfo.FileName -UnencryptedSize ([int64]$Intunexml.ApplicationInfo.UnencryptedContentSize) -EncryptionInfo $EncryptionInfo + } + 'WinGetNew' { + # I think we don't need a separate WinGetNew type, just use WinGet? } - Start-Sleep -Milliseconds 300 - } while ($CommitStateReq.uploadState -eq 'commitFilePending') - $CommitFinalizeReq = New-graphPostRequest -Uri "$($BaseURI)/$($NewApp.id)" -tenantid $tenant -Body '{"@odata.type":"#microsoft.graph.win32lobapp","committedContentVersion":"1"}' -type PATCH - Write-Host "Commit Finalize Request: $($CommitFinalizeReq | ConvertTo-Json -Depth 10)" - Write-LogMessage -api 'AppUpload' -tenant $tenant -message "Added Application $($ChocoApp.Applicationname)" -Sev 'Info' - if ($AssignTo -ne 'On') { - $intent = if ($AssignToIntent) { 'Uninstall' } else { 'Required' } - Set-CIPPAssignedApplication -ApplicationId $NewApp.Id -Intent $intent -TenantFilter $tenant -groupName "$AssignTo" -AppType 'Win32Lob' + default { + throw "Unsupported app type: $($AppConfig.type)" + } + } + # Log success and assign app if requested + if ($NewApp) { + Write-LogMessage -api 'AppUpload' -tenant $tenant -message "$($AppConfig.Applicationname) Successfully created" -Sev 'Info' + + if ($assignTo -and $assignTo -ne 'On') { + $intent = if ($AssignToIntent) { 'Uninstall' } else { 'Required' } + $AppTypeForAssignment = switch ($AppType) { + 'WinGet' { 'WinGet' } + 'WinGetNew' { 'WinGet' } + default { 'Win32Lob' } + } + Start-Sleep -Milliseconds 200 + Set-CIPPAssignedApplication -ApplicationId $NewApp.Id -TenantFilter $tenant -groupName $assignTo -Intent $intent -AppType $AppTypeForAssignment -APIName 'AppUpload' + } } - Write-LogMessage -api 'AppUpload' -tenant $tenant -message 'Successfully added Application' -Sev 'Info' } catch { - "Failed to add Application for $($Tenant): $($_.Exception.Message)" - Write-LogMessage -api 'AppUpload' -tenant $tenant -message "Failed adding Application $($ChocoApp.Applicationname). Error: $($_.Exception.Message)" -LogData (Get-CippException -Exception $_) -Sev 'Error' + "Failed to add Application for $tenant : $($_.Exception.Message)" + Write-LogMessage -api 'AppUpload' -tenant $tenant -message "Failed adding Application $($AppConfig.Applicationname). Error: $($_.Exception.Message)" -LogData (Get-CippException -Exception $_) -Sev 'Error' continue } } From bf9fbc5354f31d910bfe66587a59a2659910b400 Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Fri, 20 Feb 2026 13:22:59 +0100 Subject: [PATCH 87/97] remove text identitfier in case its multiple errors --- .../Identity/Administration/Users/Invoke-AddUser.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-AddUser.ps1 b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-AddUser.ps1 index f7637cfa04d8..d00439f6d36c 100644 --- a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-AddUser.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-AddUser.ps1 @@ -78,7 +78,7 @@ function Invoke-AddUser { $ErrorMessage = $_.TargetObject.Results -join ' ' $ErrorMessage = [string]::IsNullOrWhiteSpace($ErrorMessage) ? $_.Exception.Message : $ErrorMessage $body = [pscustomobject] @{ - 'Results' = @("$ErrorMessage") + 'Results' = @($ErrorMessage) } $StatusCode = [HttpStatusCode]::InternalServerError } From b0c42a2326dccbe0aa0d5045b4fdc58e0b81aad3 Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Fri, 20 Feb 2026 14:36:44 +0100 Subject: [PATCH 88/97] small changes to allow CIPPW32ScriptApplications. --- .../Public/Add-CIPPW32ScriptApplication.ps1 | 181 ++++++++++-------- 1 file changed, 105 insertions(+), 76 deletions(-) diff --git a/Modules/CIPPCore/Public/Add-CIPPW32ScriptApplication.ps1 b/Modules/CIPPCore/Public/Add-CIPPW32ScriptApplication.ps1 index add8ade366d2..370a70d5f762 100644 --- a/Modules/CIPPCore/Public/Add-CIPPW32ScriptApplication.ps1 +++ b/Modules/CIPPCore/Public/Add-CIPPW32ScriptApplication.ps1 @@ -1,11 +1,11 @@ function Add-CIPPW32ScriptApplication { <# .SYNOPSIS - Adds a Win32 app with PowerShell script installer to Intune. + Adds a Win32 app with PowerShell script installer to Intune using the standard Chocolatey package. .DESCRIPTION - Creates a Win32 app using the PowerShell script installer feature. - Uploads an intunewin file and PowerShell scripts via the scripts endpoint. + Creates a Win32 app that uses the standard Chocolatey intunewin package but with custom PowerShell scripts. + Always uploads the same Choco package, but uses user-provided scripts for install/uninstall commands. .PARAMETER TenantFilter Tenant ID or domain name for the Graph API call. @@ -17,31 +17,21 @@ function Add-CIPPW32ScriptApplication { - publisher: Publisher name - installScript (required): PowerShell install script content (plaintext) - uninstallScript: PowerShell uninstall script content (plaintext) - - detectionScript: PowerShell detection script content (plaintext) + - detectionPath (required): Full path to the file or folder to detect (e.g., 'C:\\Program Files\\MyApp') + - detectionFile: File name to detect (optional, for folder path detection) + - detectionType: 'exists', 'modifiedDate', 'createdDate', 'version', 'sizeInMB' (default: 'exists') + - check32BitOn64System: Boolean, check 32-bit registry/paths on 64-bit systems (default: false) - runAsAccount: 'system' or 'user' (default: 'system') - deviceRestartBehavior: 'allow', 'suppress', or 'force' (default: 'suppress') - - runAs32Bit: Boolean, run scripts as 32-bit on 64-bit clients (default: false) - - enforceSignatureCheck: Boolean, enforce script signature validation (default: false) - - .PARAMETER FilePath - Path to the intunewin file. - - .PARAMETER FileName - Name of the file from XML metadata. - - .PARAMETER UnencryptedSize - Unencrypted size of the file from XML metadata. - - .PARAMETER EncryptionInfo - Hashtable containing encryption information from XML. .EXAMPLE $Properties = @{ displayName = 'My Script App' installScript = 'Write-Host "Installing..."' + detectionPath = 'C:\\Program Files\\MyApp' + detectionFile = 'app.exe' } - $EncryptionInfo = @{ EncryptionKey = '...'; MacKey = '...'; ... } - Add-CIPPW32ScriptApplication -TenantFilter 'contoso.com' -Properties $Properties -FilePath 'app.intunewin' -FileName 'app.intunewin' -UnencryptedSize 1024000 -EncryptionInfo $EncryptionInfo + Add-CIPPW32ScriptApplication -TenantFilter 'contoso.com' -Properties $Properties #> [CmdletBinding()] param( @@ -49,59 +39,112 @@ function Add-CIPPW32ScriptApplication { [string]$TenantFilter, [Parameter(Mandatory = $true)] - [PSCustomObject]$Properties, + [PSCustomObject]$Properties + ) - [Parameter(Mandatory = $true)] - [string]$FilePath, + # Get the standard Chocolatey package location (relative to function app root) + $IntuneWinFile = 'AddChocoApp\IntunePackage.intunewin' + $ChocoXmlFile = 'AddChocoApp\Choco.App.xml' - [Parameter(Mandatory = $true)] - [string]$FileName, + if (-not (Test-Path $IntuneWinFile)) { + throw "Chocolatey IntunePackage.intunewin not found at: $IntuneWinFile (Current directory: $PWD)" + } - [Parameter(Mandatory = $true)] - [int64]$UnencryptedSize, + if (-not (Test-Path $ChocoXmlFile)) { + throw "Choco.App.xml not found at: $ChocoXmlFile (Current directory: $PWD)" + } - [Parameter(Mandatory = $true)] - [hashtable]$EncryptionInfo - ) + # Parse the Choco XML to get encryption info + [xml]$ChocoXml = Get-Content $ChocoXmlFile + $EncryptionInfo = @{ + EncryptionKey = $ChocoXml.ApplicationInfo.EncryptionInfo.EncryptionKey + MacKey = $ChocoXml.ApplicationInfo.EncryptionInfo.MacKey + InitializationVector = $ChocoXml.ApplicationInfo.EncryptionInfo.InitializationVector + Mac = $ChocoXml.ApplicationInfo.EncryptionInfo.Mac + ProfileIdentifier = $ChocoXml.ApplicationInfo.EncryptionInfo.ProfileIdentifier + FileDigest = $ChocoXml.ApplicationInfo.EncryptionInfo.FileDigest + FileDigestAlgorithm = $ChocoXml.ApplicationInfo.EncryptionInfo.FileDigestAlgorithm + } + + $FileName = $ChocoXml.ApplicationInfo.FileName + $UnencryptedSize = [int64]$ChocoXml.ApplicationInfo.UnencryptedContentSize + + # Build detection rules + if ($Properties.detectionPath) { + # Determine if this is a file or folder detection + $DetectionRule = @{ + '@odata.type' = '#microsoft.graph.win32LobAppFileSystemDetection' + check32BitOn64System = if ($null -ne $Properties.check32BitOn64System) { [bool]$Properties.check32BitOn64System } else { $false } + detectionType = if ($Properties.detectionType) { $Properties.detectionType } else { 'exists' } + } + + if ($Properties.detectionFile) { + # File detection (path + file) + $DetectionRule['path'] = $Properties.detectionPath + $DetectionRule['fileOrFolderName'] = $Properties.detectionFile + } else { + # Folder/File detection (full path) + # Split the path into directory and file/folder name + $PathItem = Split-Path $Properties.detectionPath -Leaf + $ParentPath = Split-Path $Properties.detectionPath -Parent + + if ([string]::IsNullOrEmpty($ParentPath)) { + throw "Invalid detection path: $($Properties.detectionPath). Must be a full path." + } - # Build Win32 app body - $intuneBody = @{ - '@odata.type' = '#microsoft.graph.win32LobApp' - displayName = $Properties.displayName - description = $Properties.description - publisher = $Properties.publisher - fileName = $FileName - setupFilePath = 'N/A' - minimumSupportedWindowsRelease = '1607' - returnCodes = @( + $DetectionRule['path'] = $ParentPath + $DetectionRule['fileOrFolderName'] = $PathItem + } + + $DetectionRules = @($DetectionRule) + } else { + # Default detection: Check for a marker file in ProgramData + $DetectionRules = @( + @{ + '@odata.type' = '#microsoft.graph.win32LobAppFileSystemDetection' + path = '%ProgramData%\CIPPApps' + fileOrFolderName = "$($Properties.displayName -replace '[^a-zA-Z0-9]', '_').txt" + check32BitOn64System = $false + detectionType = 'exists' + } + ) + } + + # Build the Win32 app body + $AppBody = @{ + '@odata.type' = '#microsoft.graph.win32LobApp' + displayName = $Properties.displayName + description = $Properties.description + publisher = if ($Properties.publisher) { $Properties.publisher } else { 'CIPP' } + fileName = $FileName + setupFilePath = 'N/A' + installCommandLine = 'powershell.exe -ExecutionPolicy Bypass -File install.ps1' + uninstallCommandLine = 'powershell.exe -ExecutionPolicy Bypass -File uninstall.ps1' + minimumSupportedWindowsRelease = '1607' + detectionRules = $DetectionRules + returnCodes = @( @{ returnCode = 0; type = 'success' } @{ returnCode = 1707; type = 'success' } @{ returnCode = 3010; type = 'softReboot' } @{ returnCode = 1641; type = 'hardReboot' } @{ returnCode = 1618; type = 'retry' } ) + installExperience = @{ + '@odata.type' = 'microsoft.graph.win32LobAppInstallExperience' + runAsAccount = if ($Properties.runAsAccount) { $Properties.runAsAccount } else { 'system' } + deviceRestartBehavior = if ($Properties.deviceRestartBehavior) { $Properties.deviceRestartBehavior } else { 'suppress' } + } } - # Add install experience - $intuneBody.installExperience = @{ - '@odata.type' = 'microsoft.graph.win32LobAppInstallExperience' - runAsAccount = if ($Properties.runAsAccount) { $Properties.runAsAccount } else { 'system' } - deviceRestartBehavior = if ($Properties.deviceRestartBehavior) { $Properties.deviceRestartBehavior } else { 'suppress' } - maxRunTimeInMinutes = 60 - } - - # Create the app + # Create the app first $Baseuri = 'https://graph.microsoft.com/beta/deviceAppManagement/mobileApps' - $NewApp = New-GraphPostRequest -Uri $Baseuri -Body ($intuneBody | ConvertTo-Json -Depth 10) -Type POST -tenantid $TenantFilter + $NewApp = New-GraphPostRequest -Uri $Baseuri -Body ($AppBody | ConvertTo-Json -Depth 10) -Type POST -tenantid $TenantFilter Start-Sleep -Milliseconds 200 - # Upload intunewin file using shared helper - Add-CIPPWin32LobAppContent -AppId $NewApp.id -FilePath $FilePath -FileName $FileName -UnencryptedSize $UnencryptedSize -EncryptionInfo $EncryptionInfo -TenantFilter $TenantFilter - - # Upload PowerShell scripts via the scripts endpoint - $RunAs32Bit = if ($null -ne $Properties.runAs32Bit) { [bool]$Properties.runAs32Bit } else { $false } - $EnforceSignatureCheck = if ($null -ne $Properties.enforceSignatureCheck) { [bool]$Properties.enforceSignatureCheck } else { $false } + # Upload the Chocolatey intunewin content + Add-CIPPWin32LobAppContent -AppId $NewApp.id -FilePath $IntuneWinFile -FileName $FileName -UnencryptedSize $UnencryptedSize -EncryptionInfo $EncryptionInfo -TenantFilter $TenantFilter + # Upload PowerShell scripts via the scripts endpoint (newer method) $InstallScriptId = $null $UninstallScriptId = $null @@ -110,8 +153,8 @@ function Add-CIPPW32ScriptApplication { $InstallScriptBody = @{ '@odata.type' = '#microsoft.graph.win32LobAppInstallPowerShellScript' displayName = 'install.ps1' - enforceSignatureCheck = $EnforceSignatureCheck - runAs32Bit = $RunAs32Bit + enforceSignatureCheck = $false + runAs32Bit = $false content = $InstallScriptContent } | ConvertTo-Json @@ -133,8 +176,8 @@ function Add-CIPPW32ScriptApplication { $UninstallScriptBody = @{ '@odata.type' = '#microsoft.graph.win32LobAppUninstallPowerShellScript' displayName = 'uninstall.ps1' - enforceSignatureCheck = $EnforceSignatureCheck - runAs32Bit = $RunAs32Bit + enforceSignatureCheck = $false + runAs32Bit = $false content = $UninstallScriptContent } | ConvertTo-Json @@ -153,8 +196,8 @@ function Add-CIPPW32ScriptApplication { # Build final commit body with active script references $CommitBody = @{ - '@odata.type' = '#microsoft.graph.win32LobApp' - committedContentVersion = '1' + '@odata.type' = '#microsoft.graph.win32LobApp' + committedContentVersion = '1' } if ($InstallScriptId) { @@ -165,22 +208,8 @@ function Add-CIPPW32ScriptApplication { $CommitBody['activeUninstallScript'] = @{ targetId = $UninstallScriptId } } - # Add detection rules if provided - if ($Properties.detectionScript) { - $DetectionScriptContent = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($Properties.detectionScript)) - $CommitBody['detectionRules'] = @( - @{ - '@odata.type' = '#microsoft.graph.win32LobAppPowerShellScriptDetection' - scriptContent = $DetectionScriptContent - enforceSignatureCheck = $EnforceSignatureCheck - runAs32Bit = $RunAs32Bit - } - ) - } - # Commit the app with script references $null = New-GraphPostRequest -Uri "$Baseuri/$($NewApp.id)" -tenantid $TenantFilter -Body ($CommitBody | ConvertTo-Json -Depth 10) -Type PATCH return $NewApp - } From ccc337b251e147aa0074a507ee146e5e14d8500c Mon Sep 17 00:00:00 2001 From: John Duprey Date: Fri, 20 Feb 2026 10:14:26 -0500 Subject: [PATCH 89/97] add default empty strings --- .../Standards/Invoke-CIPPStandardDeployMailContact.ps1 | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardDeployMailContact.ps1 b/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardDeployMailContact.ps1 index 0bc606b7adbf..e264f723eb36 100644 --- a/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardDeployMailContact.ps1 +++ b/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardDeployMailContact.ps1 @@ -105,15 +105,15 @@ function Invoke-CIPPStandardDeployMailContact { $ContactData = @{ DisplayName = $Settings.DisplayName ExternalEmailAddress = $Settings.ExternalEmailAddress - FirstName = $Settings.FirstName - LastName = $Settings.LastName + FirstName = $Settings.FirstName ?? '' + LastName = $Settings.LastName ?? '' } $CurrentValue = $ExistingContact | Select-Object DisplayName, ExternalEmailAddress, FirstName, LastName $currentValue = @{ DisplayName = $ExistingContact.displayName ExternalEmailAddress = ($ExistingContact.ExternalEmailAddress -replace 'SMTP:', '') - FirstName = $ExistingContact.firstName - LastName = $ExistingContact.lastName + FirstName = $ExistingContact.firstName ?? '' + LastName = $ExistingContact.lastName ?? '' } Add-CIPPBPAField -FieldName 'DeployMailContact' -FieldValue $ReportData -StoreAs json -Tenant $Tenant Set-CIPPStandardsCompareField -FieldName 'standards.DeployMailContact' -CurrentValue $CurrentValue -ExpectedValue $ReportData -Tenant $Tenant From ac2d462d2eb8d7c40c8031e696696e5fa9470255 Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Fri, 20 Feb 2026 16:44:51 +0100 Subject: [PATCH 90/97] custom apps --- .../Public/Add-CIPPW32ScriptApplication.ps1 | 2 +- .../Applications/Push-UploadApplication.ps1 | 25 +- .../Applications/Invoke-AddWin32ScriptApp.ps1 | 80 ++ openapi.json | 940 ++++++------------ 4 files changed, 394 insertions(+), 653 deletions(-) create mode 100644 Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/Applications/Invoke-AddWin32ScriptApp.ps1 diff --git a/Modules/CIPPCore/Public/Add-CIPPW32ScriptApplication.ps1 b/Modules/CIPPCore/Public/Add-CIPPW32ScriptApplication.ps1 index 370a70d5f762..5ef92c5a997a 100644 --- a/Modules/CIPPCore/Public/Add-CIPPW32ScriptApplication.ps1 +++ b/Modules/CIPPCore/Public/Add-CIPPW32ScriptApplication.ps1 @@ -54,7 +54,7 @@ function Add-CIPPW32ScriptApplication { throw "Choco.App.xml not found at: $ChocoXmlFile (Current directory: $PWD)" } - # Parse the Choco XML to get encryption info + # Parse the Choco XML to get encryption info. We need a wrapper around the application and this is a tiny intune file, perfect for our purpose. [xml]$ChocoXml = Get-Content $ChocoXmlFile $EncryptionInfo = @{ EncryptionKey = $ChocoXml.ApplicationInfo.EncryptionInfo.EncryptionKey diff --git a/Modules/CIPPCore/Public/Entrypoints/Activity Triggers/Applications/Push-UploadApplication.ps1 b/Modules/CIPPCore/Public/Entrypoints/Activity Triggers/Applications/Push-UploadApplication.ps1 index e4c19265966b..b643716861a1 100644 --- a/Modules/CIPPCore/Public/Entrypoints/Activity Triggers/Applications/Push-UploadApplication.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/Activity Triggers/Applications/Push-UploadApplication.ps1 @@ -81,12 +81,12 @@ function Push-UploadApplication { # Build parameters dynamically $Params = @{ - AppBody = $intuneBody - TenantFilter = $tenant - FilePath = $Infile - FileName = $Intunexml.ApplicationInfo.FileName + AppBody = $intuneBody + TenantFilter = $tenant + FilePath = $Infile + FileName = $Intunexml.ApplicationInfo.FileName UnencryptedSize = [int64]$Intunexml.ApplicationInfo.UnencryptedContentSize - EncryptionInfo = $EncryptionInfo + EncryptionInfo = $EncryptionInfo } if ($AppConfig.Applicationname) { $Params.DisplayName = $AppConfig.Applicationname } @@ -106,12 +106,12 @@ function Push-UploadApplication { # Build parameters dynamically $Params = @{ - AppBody = $intuneBody - TenantFilter = $tenant - FilePath = $Infile - FileName = $Intunexml.ApplicationInfo.FileName + AppBody = $intuneBody + TenantFilter = $tenant + FilePath = $Infile + FileName = $Intunexml.ApplicationInfo.FileName UnencryptedSize = [int64]$Intunexml.ApplicationInfo.UnencryptedContentSize - EncryptionInfo = $EncryptionInfo + EncryptionInfo = $EncryptionInfo } if ($AppConfig.Applicationname) { $Params.DisplayName = $AppConfig.Applicationname } @@ -139,13 +139,14 @@ function Push-UploadApplication { if ($AppConfig.description) { $Properties['description'] = $AppConfig.description } if ($AppConfig.publisher) { $Properties['publisher'] = $AppConfig.publisher } if ($AppConfig.uninstallScript) { $Properties['uninstallScript'] = $AppConfig.uninstallScript } - if ($AppConfig.detectionScript) { $Properties['detectionScript'] = $AppConfig.detectionScript } + if ($AppConfig.detectionPath) { $Properties['detectionPath'] = $AppConfig.detectionPath } + if ($AppConfig.detectionFile) { $Properties['detectionFile'] = $AppConfig.detectionFile } if ($AppConfig.runAsAccount) { $Properties['runAsAccount'] = $AppConfig.runAsAccount } if ($AppConfig.deviceRestartBehavior) { $Properties['deviceRestartBehavior'] = $AppConfig.deviceRestartBehavior } if ($null -ne $AppConfig.runAs32Bit) { $Properties['runAs32Bit'] = $AppConfig.runAs32Bit } if ($null -ne $AppConfig.enforceSignatureCheck) { $Properties['enforceSignatureCheck'] = $AppConfig.enforceSignatureCheck } - $NewApp = Add-CIPPW32ScriptApplication -TenantFilter $tenant -Properties ([PSCustomObject]$Properties) -FilePath $Infile -FileName $Intunexml.ApplicationInfo.FileName -UnencryptedSize ([int64]$Intunexml.ApplicationInfo.UnencryptedContentSize) -EncryptionInfo $EncryptionInfo + $NewApp = Add-CIPPW32ScriptApplication -TenantFilter $tenant -Properties ([PSCustomObject]$Properties) } 'WinGetNew' { # I think we don't need a separate WinGetNew type, just use WinGet? diff --git a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/Applications/Invoke-AddWin32ScriptApp.ps1 b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/Applications/Invoke-AddWin32ScriptApp.ps1 new file mode 100644 index 000000000000..bdb797cbdd88 --- /dev/null +++ b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/Applications/Invoke-AddWin32ScriptApp.ps1 @@ -0,0 +1,80 @@ +function Invoke-AddWin32ScriptApp { + <# + .FUNCTIONALITY + Entrypoint,AnyTenant + .ROLE + Endpoint.Application.ReadWrite + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers + + $Win32ScriptApp = $Request.Body + $AssignTo = $Win32ScriptApp.AssignTo -eq 'customGroup' ? $Win32ScriptApp.CustomGroup : $Win32ScriptApp.AssignTo + + # Validate required fields + if ([string]::IsNullOrEmpty($Win32ScriptApp.ApplicationName) -and [string]::IsNullOrEmpty($Win32ScriptApp.applicationName)) { + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::BadRequest + Body = @{ Results = @('Application name is required') } + }) + } + + if ([string]::IsNullOrEmpty($Win32ScriptApp.installScript)) { + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::BadRequest + Body = @{ Results = @('Install script is required') } + }) + } + + # Use whichever case was provided + $AppName = if ($Win32ScriptApp.ApplicationName) { $Win32ScriptApp.ApplicationName } else { $Win32ScriptApp.applicationName } + + $AllowedTenants = Test-CIPPAccess -Request $Request -TenantList + $Tenants = ($Request.Body.selectedTenants | Where-Object { $AllowedTenants -contains $_.customerId -or $AllowedTenants -contains 'AllTenants' }).defaultDomainName + + $Results = foreach ($Tenant in $Tenants) { + try { + $CompleteObject = [PSCustomObject]@{ + tenant = $Tenant + Applicationname = $AppName + assignTo = $AssignTo + InstallationIntent = $Win32ScriptApp.InstallationIntent + type = 'Win32ScriptApp' + description = $Win32ScriptApp.description + publisher = $Win32ScriptApp.publisher + installScript = $Win32ScriptApp.installScript + uninstallScript = $Win32ScriptApp.uninstallScript + detectionPath = $Win32ScriptApp.detectionPath + detectionFile = $Win32ScriptApp.detectionFile + runAsAccount = if ($Win32ScriptApp.InstallAsSystem) { 'system' } else { 'user' } + deviceRestartBehavior = if ($Win32ScriptApp.DisableRestart) { 'suppress' } else { 'allow' } + runAs32Bit = [bool]$Win32ScriptApp.runAs32Bit + enforceSignatureCheck = [bool]$Win32ScriptApp.enforceSignatureCheck + } | ConvertTo-Json -Depth 15 + + $Table = Get-CippTable -tablename 'apps' + $Table.Force = $true + Add-CIPPAzDataTableEntity @Table -Entity @{ + JSON = "$CompleteObject" + RowKey = "$((New-Guid).GUID)" + PartitionKey = 'apps' + status = 'Not Deployed yet' + } + "Successfully added Win32 Script App for $($Tenant) to queue." + Write-LogMessage -headers $Headers -API $APIName -tenant $Tenant -message "Successfully added Win32 Script App $AppName to queue" -Sev 'Info' + } catch { + Write-LogMessage -headers $Headers -API $APIName -tenant $Tenant -message "Failed to add Win32 Script App $AppName to queue. Error: $($_.Exception.Message)" -Sev 'Error' + "Failed to add Win32 Script App for $($Tenant) to queue: $($_.Exception.Message)" + } + } + + $body = [PSCustomObject]@{ 'Results' = $Results } + + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::OK + Body = $body + }) +} diff --git a/openapi.json b/openapi.json index 013d331ba238..194c1d67d499 100644 --- a/openapi.json +++ b/openapi.json @@ -13,9 +13,7 @@ "post": { "description": "AddGroup", "summary": "AddGroup", - "tags": [ - "POST" - ], + "tags": ["POST"], "parameters": [ { "required": true, @@ -53,9 +51,7 @@ "post": { "description": "AddChocoApp", "summary": "AddChocoApp", - "tags": [ - "POST" - ], + "tags": ["POST"], "parameters": [ { "required": true, @@ -89,13 +85,65 @@ } } }, + "/AddWin32ScriptApp": { + "post": { + "description": "AddWin32ScriptApp", + "summary": "AddWin32ScriptApp", + "tags": ["POST"], + "parameters": [ + { + "required": true, + "schema": { + "type": "string" + }, + "name": "ApplicationName", + "in": "body" + }, + { + "required": true, + "schema": { + "type": "string" + }, + "name": "installScript", + "in": "body" + }, + { + "required": true, + "schema": { + "type": "string" + }, + "name": "AssignTo", + "in": "body" + }, + { + "required": false, + "schema": { + "type": "string" + }, + "name": "InstallationIntent", + "in": "body" + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": {}, + "type": "object" + } + } + }, + "description": "Successful operation" + } + } + } + }, "/RemoveUser": { "get": { "description": "RemoveUser", "summary": "RemoveUser", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -133,9 +181,7 @@ "get": { "description": "ListTeams", "summary": "ListTeams", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -181,9 +227,7 @@ "get": { "description": "ExecGroupsDelete", "summary": "ExecGroupsDelete", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -237,9 +281,7 @@ "get": { "description": "ListRoles", "summary": "ListRoles", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -269,9 +311,7 @@ "get": { "description": "ListUserMailboxRules", "summary": "ListUserMailboxRules", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -317,9 +357,7 @@ "get": { "description": "ExecBECCheck", "summary": "ExecBECCheck", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -365,9 +403,7 @@ "get": { "description": "ListCalendarPermissions", "summary": "ListCalendarPermissions", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -405,9 +441,7 @@ "get": { "description": "ExecAddSPN", "summary": "ExecAddSPN", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -437,9 +471,7 @@ "get": { "description": "ListLicenses", "summary": "ListLicenses", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -469,9 +501,7 @@ "post": { "description": "AddCATemplate", "summary": "AddCATemplate", - "tags": [ - "POST" - ], + "tags": ["POST"], "parameters": [ { "required": true, @@ -509,9 +539,7 @@ "get": { "description": "ExecIncidentsList", "summary": "ExecIncidentsList", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -541,9 +569,7 @@ "post": { "description": "AddSharedMailbox", "summary": "AddSharedMailbox", - "tags": [ - "POST" - ], + "tags": ["POST"], "parameters": [ { "required": true, @@ -573,9 +599,7 @@ "get": { "description": "ListApps", "summary": "ListApps", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -605,9 +629,7 @@ "get": { "description": "ListSharepointSettings", "summary": "ListSharepointSettings", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -653,9 +675,7 @@ "get": { "description": "ExecSendOrgMessage", "summary": "ExecSendOrgMessage", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -717,9 +737,7 @@ "post": { "description": "AddSpamFilterTemplate", "summary": "AddSpamFilterTemplate", - "tags": [ - "POST" - ], + "tags": ["POST"], "parameters": [ { "required": true, @@ -757,9 +775,7 @@ "get": { "description": "ListGroupTemplates", "summary": "ListGroupTemplates", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -789,9 +805,7 @@ "get": { "description": "ListAutopilotconfig", "summary": "ListAutopilotconfig", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -837,9 +851,7 @@ "get": { "description": "ExecSetSecurityIncident", "summary": "ExecSetSecurityIncident", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -917,9 +929,7 @@ "get": { "description": "EditExConnector", "summary": "EditExConnector", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -973,9 +983,7 @@ "post": { "description": "AddUser", "summary": "AddUser", - "tags": [ - "POST" - ], + "tags": ["POST"], "parameters": [ { "required": true, @@ -1005,9 +1013,7 @@ "get": { "description": "ListUserPhoto", "summary": "ListUserPhoto", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -1045,9 +1051,7 @@ "get": { "description": "ListConditionalAccessPolicies", "summary": "ListConditionalAccessPolicies", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -1077,9 +1081,7 @@ "get": { "description": "ListBasicAuth", "summary": "ListBasicAuth", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -1109,9 +1111,7 @@ "get": { "description": "RemoveSpamfilterTemplate", "summary": "RemoveSpamfilterTemplate", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -1141,9 +1141,7 @@ "post": { "description": "ExecGDAPInvite", "summary": "ExecGDAPInvite", - "tags": [ - "POST" - ], + "tags": ["POST"], "parameters": [ { "required": true, @@ -1173,9 +1171,7 @@ "get": { "description": "ListUserSigninLogs", "summary": "ListUserSigninLogs", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -1213,9 +1209,7 @@ "get": { "description": "EditCAPolicy", "summary": "EditCAPolicy", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -1261,9 +1255,7 @@ "get": { "description": "ListDomainHealth", "summary": "ListDomainHealth", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -1333,9 +1325,7 @@ "get": { "description": "ExecUniversalSearch", "summary": "ExecUniversalSearch", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -1365,9 +1355,7 @@ "get": { "description": "ListMailboxCAS", "summary": "ListMailboxCAS", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -1397,9 +1385,7 @@ "get": { "description": "RemoveExConnectorTemplate", "summary": "RemoveExConnectorTemplate", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -1429,9 +1415,7 @@ "get": { "description": "ListUserCounts", "summary": "ListUserCounts", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -1461,9 +1445,7 @@ "get": { "description": "ExecGetLocalAdminPassword", "summary": "ExecGetLocalAdminPassword", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -1501,9 +1483,7 @@ "get": { "description": "ListOrg", "summary": "ListOrg", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -1533,9 +1513,7 @@ "post": { "description": "ExecExcludeLicenses", "summary": "ExecExcludeLicenses", - "tags": [ - "POST" - ], + "tags": ["POST"], "parameters": [ { "required": true, @@ -1603,9 +1581,7 @@ "get": { "description": "ExecExcludeLicenses", "summary": "ExecExcludeLicenses", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -1675,9 +1651,7 @@ "get": { "description": "ExecDeleteGDAPRelationship", "summary": "ExecDeleteGDAPRelationship", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -1707,9 +1681,7 @@ "post": { "description": "AddMSPApp", "summary": "AddMSPApp", - "tags": [ - "POST" - ], + "tags": ["POST"], "parameters": [ { "required": true, @@ -1747,9 +1719,7 @@ "post": { "description": "EditPolicy", "summary": "EditPolicy", - "tags": [ - "POST" - ], + "tags": ["POST"], "parameters": [ { "required": true, @@ -1811,9 +1781,7 @@ "post": { "description": "ExecDeviceAction", "summary": "ExecDeviceAction", - "tags": [ - "POST" - ], + "tags": ["POST"], "parameters": [ { "required": true, @@ -1865,9 +1833,7 @@ "get": { "description": "ExecDeviceAction", "summary": "ExecDeviceAction", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -1921,9 +1887,7 @@ "post": { "description": "AddWinGetApp", "summary": "AddWinGetApp", - "tags": [ - "POST" - ], + "tags": ["POST"], "parameters": [ { "required": true, @@ -1961,9 +1925,7 @@ "get": { "description": "RemovePolicy", "summary": "RemovePolicy", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -2009,9 +1971,7 @@ "post": { "description": "AddExConnector", "summary": "AddExConnector", - "tags": [ - "POST" - ], + "tags": ["POST"], "parameters": [ { "required": true, @@ -2041,9 +2001,7 @@ "get": { "description": "ListTeamsVoice", "summary": "ListTeamsVoice", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -2073,9 +2031,7 @@ "get": { "description": "ListTeamsActivity", "summary": "ListTeamsActivity", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -2113,9 +2069,7 @@ "get": { "description": "ListSpamFilterTemplates", "summary": "ListSpamFilterTemplates", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -2145,9 +2099,7 @@ "get": { "description": "ExecCopyForSent", "summary": "ExecCopyForSent", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -2193,9 +2145,7 @@ "get": { "description": "ListAzureADConnectStatus", "summary": "ListAzureADConnectStatus", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -2233,9 +2183,7 @@ "get": { "description": "ExecEnableArchive", "summary": "ExecEnableArchive", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -2273,9 +2221,7 @@ "get": { "description": "ExecGetRecoveryKey", "summary": "ExecGetRecoveryKey", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -2313,9 +2259,7 @@ "get": { "description": "ListSharedMailboxStatistics", "summary": "ListSharedMailboxStatistics", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -2345,9 +2289,7 @@ "post": { "description": "ListPotentialApps", "summary": "ListPotentialApps", - "tags": [ - "POST" - ], + "tags": ["POST"], "parameters": [ { "required": true, @@ -2385,9 +2327,7 @@ "get": { "description": "ExecCPVPermissions", "summary": "ExecCPVPermissions", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -2417,9 +2357,7 @@ "get": { "description": "ListSharepointQuota", "summary": "ListSharepointQuota", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -2449,9 +2387,7 @@ "get": { "description": "ListDefenderTVM", "summary": "ListDefenderTVM", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -2481,9 +2417,7 @@ "post": { "description": "ExecEditMailboxPermissions", "summary": "ExecEditMailboxPermissions", - "tags": [ - "POST" - ], + "tags": ["POST"], "parameters": [ { "required": true, @@ -2569,9 +2503,7 @@ "post": { "description": "AddOfficeApp", "summary": "AddOfficeApp", - "tags": [ - "POST" - ], + "tags": ["POST"], "parameters": [ { "required": true, @@ -2657,9 +2589,7 @@ "get": { "description": "EditSpamFilter", "summary": "EditSpamFilter", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -2705,9 +2635,7 @@ "get": { "description": "ListSignIns", "summary": "ListSignIns", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -2753,9 +2681,7 @@ "get": { "description": "ExecDnsConfig", "summary": "ExecDnsConfig", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -2801,9 +2727,7 @@ "post": { "description": "ExecEmailForward", "summary": "ExecEmailForward", - "tags": [ - "POST" - ], + "tags": ["POST"], "parameters": [ { "required": true, @@ -2873,9 +2797,7 @@ "post": { "description": "ExecBECRemediate", "summary": "ExecBECRemediate", - "tags": [ - "POST" - ], + "tags": ["POST"], "parameters": [ { "required": true, @@ -2913,9 +2835,7 @@ "get": { "description": "ListPartnerRelationships", "summary": "ListPartnerRelationships", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -2945,9 +2865,7 @@ "post": { "description": "ListAppsRepository", "summary": "ListAppsRepository", - "tags": [ - "POST" - ], + "tags": ["POST"], "parameters": [ { "required": true, @@ -2985,9 +2903,7 @@ "get": { "description": "ExecClrImmId", "summary": "ExecClrImmId", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -3025,9 +2941,7 @@ "post": { "description": "AddGroupTemplate", "summary": "AddGroupTemplate", - "tags": [ - "POST" - ], + "tags": ["POST"], "parameters": [ { "required": true, @@ -3097,9 +3011,7 @@ "post": { "description": "Standards_IntuneTemplate", "summary": "Standards_IntuneTemplate", - "tags": [ - "POST" - ], + "tags": ["POST"], "parameters": [ { "required": true, @@ -3161,9 +3073,7 @@ "get": { "description": "ListIntuneTemplates", "summary": "ListIntuneTemplates", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -3193,9 +3103,7 @@ "get": { "description": "ExecResetPass", "summary": "ExecResetPass", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -3249,9 +3157,7 @@ "get": { "description": "ExecAlertsList", "summary": "ExecAlertsList", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -3281,9 +3187,7 @@ "get": { "description": "ExecQuarantineManagement", "summary": "ExecQuarantineManagement", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -3337,9 +3241,7 @@ "get": { "description": "ExecRestoreDeleted", "summary": "ExecRestoreDeleted", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -3377,9 +3279,7 @@ "get": { "description": "ListUserGroups", "summary": "ListUserGroups", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -3417,9 +3317,7 @@ "get": { "description": "RemoveStandard", "summary": "RemoveStandard", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -3449,9 +3347,7 @@ "get": { "description": "ListUserConditionalAccessPolicies", "summary": "ListUserConditionalAccessPolicies", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -3489,9 +3385,7 @@ "get": { "description": "ListCAtemplates", "summary": "ListCAtemplates", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -3521,9 +3415,7 @@ "get": { "description": "ListContacts", "summary": "ListContacts", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -3644,9 +3536,7 @@ "get": { "description": "ListContactPermissions - Retrieves contact folder permissions for a specified user", "summary": "ListContactPermissions", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -3729,20 +3619,14 @@ "post": { "description": "ExecModifyContactPerms - Modifies contact folder permissions for a specified user", "summary": "ExecModifyContactPerms", - "tags": [ - "POST" - ], + "tags": ["POST"], "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", - "required": [ - "userID", - "tenantFilter", - "permissions" - ], + "required": ["userID", "tenantFilter", "permissions"], "properties": { "userID": { "type": "string", @@ -3801,11 +3685,7 @@ "default": false } }, - "required": [ - "PermissionLevel", - "Modification", - "UserID" - ] + "required": ["PermissionLevel", "Modification", "UserID"] } } } @@ -3897,9 +3777,7 @@ "get": { "description": "ListMailboxRules", "summary": "ListMailboxRules", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -3929,9 +3807,7 @@ "get": { "description": "ListSites", "summary": "ListSites", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -3977,9 +3853,7 @@ "post": { "description": "ExecSetOoO", "summary": "ExecSetOoO", - "tags": [ - "POST" - ], + "tags": ["POST"], "parameters": [ { "required": true, @@ -4033,9 +3907,7 @@ "post": { "description": "AddExConnectorTemplate", "summary": "AddExConnectorTemplate", - "tags": [ - "POST" - ], + "tags": ["POST"], "parameters": [ { "required": true, @@ -4073,9 +3945,7 @@ "post": { "description": "ExecOffboardUser", "summary": "ExecOffboardUser", - "tags": [ - "POST" - ], + "tags": ["POST"], "parameters": [ { "required": true, @@ -4153,9 +4023,7 @@ "get": { "description": "ListTenantDetails", "summary": "ListTenantDetails", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -4185,9 +4053,7 @@ "get": { "description": "ExecConverttoSharedMailbox", "summary": "ExecConverttoSharedMailbox", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -4233,9 +4099,7 @@ "get": { "description": "ExecGraphRequest", "summary": "ExecGraphRequest", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -4281,9 +4145,7 @@ "get": { "description": "ListDefenderState", "summary": "ListDefenderState", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -4313,9 +4175,7 @@ "get": { "description": "ListMailQuarantine", "summary": "ListMailQuarantine", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -4345,9 +4205,7 @@ "get": { "description": "ListIntunePolicy", "summary": "ListIntunePolicy", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -4393,9 +4251,7 @@ "get": { "description": "ExecRevokeSessions", "summary": "ExecRevokeSessions", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -4433,9 +4289,7 @@ "get": { "description": "ListmailboxPermissions", "summary": "ListmailboxPermissions", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -4473,9 +4327,7 @@ "post": { "description": "ExecExtensionsConfig", "summary": "ExecExtensionsConfig", - "tags": [ - "POST" - ], + "tags": ["POST"], "parameters": [ { "required": true, @@ -4505,9 +4357,7 @@ "get": { "description": "ListDevices", "summary": "ListDevices", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -4537,9 +4387,7 @@ "get": { "description": "RemoveCATemplate", "summary": "RemoveCATemplate", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -4569,9 +4417,7 @@ "get": { "description": "RemoveQueuedApp", "summary": "RemoveQueuedApp", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -4601,9 +4447,7 @@ "get": { "description": "DomainAnalyser_List", "summary": "DomainAnalyser_List", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -4633,9 +4477,7 @@ "post": { "description": "AddNamedLocation", "summary": "AddNamedLocation", - "tags": [ - "POST" - ], + "tags": ["POST"], "parameters": [ { "required": true, @@ -4713,9 +4555,7 @@ "get": { "description": "ListTransportRulesTemplates", "summary": "ListTransportRulesTemplates", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -4745,9 +4585,7 @@ "get": { "description": "ExecEditCalendarPermissions", "summary": "ExecEditCalendarPermissions", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -4817,9 +4655,7 @@ "get": { "description": "ExecDisableUser", "summary": "ExecDisableUser", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -4865,9 +4701,7 @@ "post": { "description": "AddTransportRule", "summary": "AddTransportRule", - "tags": [ - "POST" - ], + "tags": ["POST"], "parameters": [ { "required": true, @@ -4897,9 +4731,7 @@ "get": { "description": "ListTenants", "summary": "ListTenants", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -4953,9 +4785,7 @@ "get": { "description": "ExecResetMFA", "summary": "ExecResetMFA", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -4993,9 +4823,7 @@ "get": { "description": "ListIntuneIntents", "summary": "ListIntuneIntents", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -5025,9 +4853,7 @@ "get": { "description": "ListStandards", "summary": "ListStandards", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -5057,9 +4883,7 @@ "get": { "description": "ListDeletedItems", "summary": "ListDeletedItems", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -5089,9 +4913,7 @@ "get": { "description": "ExecGroupsHideFromGAL", "summary": "ExecGroupsHideFromGAL", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -5145,9 +4967,7 @@ "get": { "description": "ExecSendPush", "summary": "ExecSendPush", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -5185,9 +5005,7 @@ "post": { "description": "ExecExtensionMapping", "summary": "ExecExtensionMapping", - "tags": [ - "POST" - ], + "tags": ["POST"], "parameters": [ { "required": true, @@ -5231,9 +5049,7 @@ "get": { "description": "ExecExtensionMapping", "summary": "ExecExtensionMapping", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -5279,9 +5095,7 @@ "get": { "description": "ListAppStatus", "summary": "ListAppStatus", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -5319,9 +5133,7 @@ "get": { "description": "GetVersion", "summary": "GetVersion", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -5351,9 +5163,7 @@ "get": { "description": "ExecMailboxMobileDevices", "summary": "ExecMailboxMobileDevices", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -5415,9 +5225,7 @@ "get": { "description": "RemoveApp", "summary": "RemoveApp", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -5455,9 +5263,7 @@ "post": { "description": "ExecPasswordConfig", "summary": "ExecPasswordConfig", - "tags": [ - "POST" - ], + "tags": ["POST"], "parameters": [ { "required": true, @@ -5493,9 +5299,7 @@ "get": { "description": "ExecPasswordConfig", "summary": "ExecPasswordConfig", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -5533,9 +5337,7 @@ "get": { "description": "ExecHideFromGAL", "summary": "ExecHideFromGAL", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -5581,9 +5383,7 @@ "post": { "description": "EditUser", "summary": "EditUser", - "tags": [ - "POST" - ], + "tags": ["POST"], "parameters": [ { "required": true, @@ -5613,9 +5413,7 @@ "get": { "description": "ListOAuthApps", "summary": "ListOAuthApps", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -5645,9 +5443,7 @@ "get": { "description": "ListDeviceDetails", "summary": "ListDeviceDetails", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -5701,9 +5497,7 @@ "get": { "description": "ListLogs", "summary": "ListLogs", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -5765,9 +5559,7 @@ "get": { "description": "ListAllTenantDeviceCompliance", "summary": "ListAllTenantDeviceCompliance", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -5797,9 +5589,7 @@ "get": { "description": "ListUsers", "summary": "ListUsers", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -5853,9 +5643,7 @@ "get": { "description": "ListMessageTrace", "summary": "ListMessageTrace", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -5925,9 +5713,7 @@ "get": { "description": "ListPhishPolicies", "summary": "ListPhishPolicies", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -5957,9 +5743,7 @@ "get": { "description": "RemoveSpamfilter", "summary": "RemoveSpamfilter", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -5997,9 +5781,7 @@ "post": { "description": "ExecNotificationConfig", "summary": "ExecNotificationConfig", - "tags": [ - "POST" - ], + "tags": ["POST"], "parameters": [ { "required": true, @@ -6069,9 +5851,7 @@ "post": { "description": "ExecSAMSetup", "summary": "ExecSAMSetup", - "tags": [ - "POST" - ], + "tags": ["POST"], "parameters": [ { "required": true, @@ -6195,9 +5975,7 @@ "get": { "description": "ExecSAMSetup", "summary": "ExecSAMSetup", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -6323,9 +6101,7 @@ "get": { "description": "ListUserMailboxDetails", "summary": "ListUserMailboxDetails", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -6363,9 +6139,7 @@ "get": { "description": "ListExConnectorTemplates", "summary": "ListExConnectorTemplates", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -6395,9 +6169,7 @@ "post": { "description": "AddDefenderDeployment", "summary": "AddDefenderDeployment", - "tags": [ - "POST" - ], + "tags": ["POST"], "parameters": [ { "required": true, @@ -6459,9 +6231,7 @@ "get": { "description": "ListGDAPInvite", "summary": "ListGDAPInvite", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -6491,9 +6261,7 @@ "post": { "description": "AddIntuneTemplate", "summary": "AddIntuneTemplate", - "tags": [ - "POST" - ], + "tags": ["POST"], "parameters": [ { "required": true, @@ -6569,9 +6337,7 @@ "get": { "description": "AddIntuneTemplate", "summary": "AddIntuneTemplate", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -6649,9 +6415,7 @@ "post": { "description": "AddAPDevice", "summary": "AddAPDevice", - "tags": [ - "POST" - ], + "tags": ["POST"], "parameters": [ { "required": true, @@ -6697,9 +6461,7 @@ "get": { "description": "RemoveTransportRuleTemplate", "summary": "RemoveTransportRuleTemplate", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -6729,9 +6491,7 @@ "get": { "description": "ListMailboxMobileDevices", "summary": "ListMailboxMobileDevices", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -6769,9 +6529,7 @@ "post": { "description": "ExecExcludeTenant", "summary": "ExecExcludeTenant", - "tags": [ - "POST" - ], + "tags": ["POST"], "parameters": [ { "required": true, @@ -6839,9 +6597,7 @@ "get": { "description": "ExecExcludeTenant", "summary": "ExecExcludeTenant", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -6911,9 +6667,7 @@ "get": { "description": "ExecSetSecurityAlert", "summary": "ExecSetSecurityAlert", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -6975,9 +6729,7 @@ "post": { "description": "ExecAddGDAPRole", "summary": "ExecAddGDAPRole", - "tags": [ - "POST" - ], + "tags": ["POST"], "parameters": [ { "required": true, @@ -7015,9 +6767,7 @@ "get": { "description": "ListExchangeConnectors", "summary": "ListExchangeConnectors", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -7047,9 +6797,7 @@ "post": { "description": "AddTransportTemplate", "summary": "AddTransportTemplate", - "tags": [ - "POST" - ], + "tags": ["POST"], "parameters": [ { "required": true, @@ -7087,9 +6835,7 @@ "get": { "description": "ExecCreateTAP", "summary": "ExecCreateTAP", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -7127,9 +6873,7 @@ "get": { "description": "RemoveCAPolicy", "summary": "RemoveCAPolicy", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -7167,9 +6911,7 @@ "get": { "description": "RemoveTransportRule", "summary": "RemoveTransportRule", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -7207,9 +6949,7 @@ "get": { "description": "RemoveAPDevice", "summary": "RemoveAPDevice", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -7247,9 +6987,7 @@ "post": { "description": "AddPolicy", "summary": "AddPolicy", - "tags": [ - "POST" - ], + "tags": ["POST"], "parameters": [ { "required": true, @@ -7319,9 +7057,7 @@ "post": { "description": "AddContact", "summary": "AddContact", - "tags": [ - "POST" - ], + "tags": ["POST"], "parameters": [ { "required": true, @@ -7351,9 +7087,7 @@ "get": { "description": "PublicScripts", "summary": "PublicScripts", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -7383,9 +7117,7 @@ "get": { "description": "RemoveContact", "summary": "RemoveContact", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -7423,9 +7155,7 @@ "get": { "description": "EditTransportRule", "summary": "EditTransportRule", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -7471,9 +7201,7 @@ "post": { "description": "AddSpamFilter", "summary": "AddSpamFilter", - "tags": [ - "POST" - ], + "tags": ["POST"], "parameters": [ { "required": true, @@ -7503,9 +7231,7 @@ "get": { "description": "RemoveIntuneTemplate", "summary": "RemoveIntuneTemplate", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -7535,9 +7261,7 @@ "get": { "description": "ExecGroupsDeliveryManagement", "summary": "ExecGroupsDeliveryManagement", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -7591,9 +7315,7 @@ "get": { "description": "ListUserDevices", "summary": "ListUserDevices", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -7631,9 +7353,7 @@ "post": { "description": "EditTenant", "summary": "EditTenant", - "tags": [ - "POST" - ], + "tags": ["POST"], "parameters": [ { "required": true, @@ -7687,9 +7407,7 @@ "get": { "description": "ExecAppApproval", "summary": "ExecAppApproval", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -7727,9 +7445,7 @@ "get": { "description": "ListInactiveAccounts", "summary": "ListInactiveAccounts", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -7759,9 +7475,7 @@ "post": { "description": "AddAlert", "summary": "AddAlert", - "tags": [ - "POST" - ], + "tags": ["POST"], "parameters": [ { "required": true, @@ -7927,9 +7641,7 @@ "get": { "description": "ListMailboxes", "summary": "ListMailboxes", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -7959,9 +7671,7 @@ "get": { "description": "ListMFAUsers", "summary": "ListMFAUsers", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -7991,9 +7701,7 @@ "get": { "description": "RemoveGroupTemplate", "summary": "RemoveGroupTemplate", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -8023,9 +7731,7 @@ "get": { "description": "ExecRunBackup", "summary": "ExecRunBackup", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -8055,9 +7761,7 @@ "get": { "description": "ListTransportRules", "summary": "ListTransportRules", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -8087,9 +7791,7 @@ "get": { "description": "ExecMaintenanceScripts", "summary": "ExecMaintenanceScripts", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -8127,9 +7829,7 @@ "get": { "description": "GetCippAlerts", "summary": "GetCippAlerts", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -8159,9 +7859,7 @@ "post": { "description": "ExecGDAPMigration", "summary": "ExecGDAPMigration", - "tags": [ - "POST" - ], + "tags": ["POST"], "parameters": [ { "required": true, @@ -8199,9 +7897,7 @@ "get": { "description": "ListGroups", "summary": "ListGroups", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -8255,9 +7951,7 @@ "get": { "description": "ListDomains", "summary": "ListDomains", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -8287,9 +7981,7 @@ "get": { "description": "ListExternalTenantInfo", "summary": "ListExternalTenantInfo", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -8319,9 +8011,7 @@ "get": { "description": "ListAPDevices", "summary": "ListAPDevices", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -8359,9 +8049,7 @@ "post": { "description": "AddAutopilotConfig", "summary": "AddAutopilotConfig", - "tags": [ - "POST" - ], + "tags": ["POST"], "parameters": [ { "required": true, @@ -8407,9 +8095,7 @@ "post": { "description": "AddCAPolicy", "summary": "AddCAPolicy", - "tags": [ - "POST" - ], + "tags": ["POST"], "parameters": [ { "required": true, @@ -8439,9 +8125,7 @@ "get": { "description": "ListMailboxStatistics", "summary": "ListMailboxStatistics", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -8471,9 +8155,7 @@ "get": { "description": "ExecAssignApp", "summary": "ExecAssignApp", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -8519,9 +8201,7 @@ "get": { "description": "ExecExtensionTest", "summary": "ExecExtensionTest", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -8551,9 +8231,7 @@ "post": { "description": "ExecSetMailboxQuota", "summary": "ExecSetMailboxQuota", - "tags": [ - "POST" - ], + "tags": ["POST"], "parameters": [ { "required": true, @@ -8623,9 +8301,7 @@ "get": { "description": "ListNamedLocations", "summary": "ListNamedLocations", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -8655,9 +8331,7 @@ "get": { "description": "ListMFAUsersAllTenants", "summary": "ListMFAUsersAllTenants", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -8687,9 +8361,7 @@ "get": { "description": "RemoveQueuedAlert", "summary": "RemoveQueuedAlert", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -8719,9 +8391,7 @@ "post": { "description": "ExecAccessChecks", "summary": "ExecAccessChecks", - "tags": [ - "POST" - ], + "tags": ["POST"], "parameters": [ { "required": true, @@ -8765,9 +8435,7 @@ "get": { "description": "ExecAccessChecks", "summary": "ExecAccessChecks", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -8813,9 +8481,7 @@ "get": { "description": "ListSharedMailboxAccountEnabled", "summary": "ListSharedMailboxAccountEnabled", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -8845,9 +8511,7 @@ "get": { "description": "ListSpamfilter", "summary": "ListSpamfilter", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -8877,9 +8541,7 @@ "get": { "description": "ListQuarantinePolicy", "summary": "ListQuarantinePolicy", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -8917,9 +8579,7 @@ "post": { "description": "AddQuarantinePolicy", "summary": "AddQuarantinePolicy", - "tags": [ - "POST" - ], + "tags": ["POST"], "parameters": [ { "required": true, @@ -9013,9 +8673,7 @@ "post": { "description": "EditQuarantinePolicy", "summary": "EditQuarantinePolicy", - "tags": [ - "POST" - ], + "tags": ["POST"], "parameters": [ { "required": true, @@ -9141,9 +8799,7 @@ "get": { "description": "RemoveExConnector", "summary": "RemoveExConnector", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -9189,9 +8845,7 @@ "get": { "description": "RemoveQuarantinePolicy", "summary": "RemoveQuarantinePolicy", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -9238,9 +8892,7 @@ "get": { "description": "ExecDeleteSafeLinksPolicy", "summary": "ExecDeleteSafeLinksPolicy", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -9286,9 +8938,7 @@ "post": { "description": "EditSafeLinksPolicy", "summary": "EditSafeLinksPolicy", - "tags": [ - "POST" - ], + "tags": ["POST"], "parameters": [ { "required": true, @@ -9434,9 +9084,7 @@ "get": { "description": "ListSafeLinksPolicyDetails", "summary": "ListSafeLinksPolicyDetails", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -9594,9 +9242,7 @@ "post": { "description": "Create a new SafeLinks policy and associated rule", "summary": "Create SafeLinks Policy and Rule Configuration", - "tags": [ - "POST" - ], + "tags": ["POST"], "parameters": [ { "required": true, @@ -9791,9 +9437,7 @@ "get": { "description": "List SafeLinks Policy Templates", "summary": "List SafeLinks Policy Templates", - "tags": [ - "GET" - ], + "tags": ["GET"], "responses": { "200": { "content": { @@ -9816,9 +9460,7 @@ "get": { "description": "Remove SafeLinks Policy Template", "summary": "Remove SafeLinks Policy Template", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -9848,9 +9490,7 @@ "post": { "description": "Add SafeLinks Policy Template", "summary": "Add SafeLinks Policy Template", - "tags": [ - "POST" - ], + "tags": ["POST"], "requestBody": { "content": { "application/json": { @@ -9880,9 +9520,7 @@ "post": { "description": "Deploy SafeLinks Policy From Template", "summary": "Deploy SafeLinks Policy From Template", - "tags": [ - "POST" - ], + "tags": ["POST"], "requestBody": { "content": { "application/json": { @@ -9912,9 +9550,7 @@ "get": { "description": "List retention policies or get a specific retention policy by name", "summary": "Get Retention Policies", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -9996,9 +9632,7 @@ "post": { "description": "Create, modify, or delete retention policies", "summary": "Manage Retention Policies", - "tags": [ - "POST" - ], + "tags": ["POST"], "parameters": [ { "required": true, @@ -10134,9 +9768,7 @@ "get": { "description": "List retention tags or get a specific retention tag by name", "summary": "Get Retention Tags", - "tags": [ - "GET" - ], + "tags": ["GET"], "parameters": [ { "required": true, @@ -10224,9 +9856,7 @@ "post": { "description": "Create, modify, or delete retention tags", "summary": "Manage Retention Tags", - "tags": [ - "POST" - ], + "tags": ["POST"], "parameters": [ { "required": true, @@ -10254,12 +9884,39 @@ }, "Type": { "type": "string", - "enum": ["All", "Inbox", "SentItems", "DeletedItems", "Drafts", "Outbox", "JunkEmail", "Journal", "SyncIssues", "ConversationHistory", "Personal", "RecoverableItems", "NonIpmRoot", "LegacyArchiveJournals", "Clutter", "Calendar", "Notes", "Tasks", "Contacts", "RssSubscriptions", "ManagedCustomFolder"], + "enum": [ + "All", + "Inbox", + "SentItems", + "DeletedItems", + "Drafts", + "Outbox", + "JunkEmail", + "Journal", + "SyncIssues", + "ConversationHistory", + "Personal", + "RecoverableItems", + "NonIpmRoot", + "LegacyArchiveJournals", + "Clutter", + "Calendar", + "Notes", + "Tasks", + "Contacts", + "RssSubscriptions", + "ManagedCustomFolder" + ], "description": "Type of the retention tag" }, "RetentionAction": { "type": "string", - "enum": ["DeleteAndAllowRecovery", "PermanentlyDelete", "MoveToArchive", "MarkAsPastRetentionLimit"], + "enum": [ + "DeleteAndAllowRecovery", + "PermanentlyDelete", + "MoveToArchive", + "MarkAsPastRetentionLimit" + ], "description": "Action to take when retention period expires" }, "AgeLimitForRetention": { @@ -10304,7 +9961,12 @@ }, "RetentionAction": { "type": "string", - "enum": ["DeleteAndAllowRecovery", "PermanentlyDelete", "MoveToArchive", "MarkAsPastRetentionLimit"], + "enum": [ + "DeleteAndAllowRecovery", + "PermanentlyDelete", + "MoveToArchive", + "MarkAsPastRetentionLimit" + ], "description": "Action to take when retention period expires" }, "AgeLimitForRetention": { @@ -10393,9 +10055,7 @@ "post": { "description": "Apply a retention policy to one or more mailboxes", "summary": "Set Mailbox Retention Policies", - "tags": [ - "POST" - ], + "tags": ["POST"], "parameters": [ { "required": true, From fb7a409cf30eee39397e5a7bcdb9c312f33c8ea3 Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Fri, 20 Feb 2026 17:44:15 +0100 Subject: [PATCH 91/97] version up --- version_latest.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/version_latest.txt b/version_latest.txt index 82f3d338cfb6..4149c39eec6f 100644 --- a/version_latest.txt +++ b/version_latest.txt @@ -1 +1 @@ -10.0.9 +10.1.0 From d37f630ea0c9c525a0fb2794f7a490ec9e4a03c5 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Fri, 20 Feb 2026 15:01:33 -0500 Subject: [PATCH 92/97] Support missing CIDR prefix and dedupe maxBits calc If the supplied range omits a CIDR prefix (e.g. "10.0.0.0"), default the prefix to the address-family max bits (32 for IPv4, 128 for IPv6). Move the $maxBits calculation before prefix parsing so the default can be applied, and remove the duplicate $maxBits assignment later in the function. This also ensures consistent mask computation for both IPv4 and IPv6. --- Modules/CIPPCore/Public/Authentication/Test-IpInRange.ps1 | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Modules/CIPPCore/Public/Authentication/Test-IpInRange.ps1 b/Modules/CIPPCore/Public/Authentication/Test-IpInRange.ps1 index 2279b20ce110..f7103fbca239 100644 --- a/Modules/CIPPCore/Public/Authentication/Test-IpInRange.ps1 +++ b/Modules/CIPPCore/Public/Authentication/Test-IpInRange.ps1 @@ -31,7 +31,8 @@ function Test-IpInRange { $IP = [System.Net.IPAddress]::Parse($IPAddress) $rangeParts = $Range -split '/' $networkAddr = [System.Net.IPAddress]::Parse($rangeParts[0]) - $prefix = [int]$rangeParts[1] + $maxBits = if ($networkAddr.AddressFamily -eq 'InterNetworkV6') { 128 } else { 32 } + $prefix = if ($rangeParts.Count -gt 1) { [int]$rangeParts[1] } else { $maxBits } if ($networkAddr.AddressFamily -ne $IP.AddressFamily) { return $false @@ -39,7 +40,6 @@ function Test-IpInRange { $ipBig = ConvertIpToBigInteger $IP $netBig = ConvertIpToBigInteger $networkAddr - $maxBits = if ($networkAddr.AddressFamily -eq 'InterNetworkV6') { 128 } else { 32 } $shift = $maxBits - $prefix $mask = [System.Numerics.BigInteger]::Pow(2, $shift) - [System.Numerics.BigInteger]::One $invertedMask = [System.Numerics.BigInteger]::MinusOne -bxor $mask From f674c9225bf10cfb910c854af226cd797641b8ac Mon Sep 17 00:00:00 2001 From: John Duprey Date: Fri, 20 Feb 2026 17:32:25 -0500 Subject: [PATCH 93/97] Normalize default tenant groups JSON Update Invoke-ExecCreateDefaultGroups.ps1 to adjust the $DefaultGroups JSON payload. The Business Premium group's DynamicRules were consolidated into a single object with a value array (now including GUIDs for license entries) and several redundant @type fields were simplified for more consistent JSON parsing when creating default tenant groups. --- .../CIPP/Settings/Invoke-ExecCreateDefaultGroups.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecCreateDefaultGroups.ps1 b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecCreateDefaultGroups.ps1 index bd2a682429ac..4fc1b2f5575c 100644 --- a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecCreateDefaultGroups.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecCreateDefaultGroups.ps1 @@ -16,7 +16,7 @@ function Invoke-ExecCreateDefaultGroups { $Table = Get-CippTable -tablename 'TenantGroups' $Results = [System.Collections.Generic.List[object]]::new() $ExistingGroups = Get-CIPPAzDataTableEntity @Table -Filter "PartitionKey eq 'TenantGroup' and Type eq 'dynamic'" - $DefaultGroups = '[{"PartitionKey":"TenantGroup","RowKey":"369d985e-0fba-48f9-844f-9f793b10a12c","Description":"This group does not have a license for intune, nor a license for Entra ID Premium","Description@type":null,"DynamicRules":"[{\"property\":\"availableServicePlan\",\"operator\":\"notIn\",\"value\":[{\"label\":\"Microsoft Intune\",\"value\":\"INTUNE_A\",\"id\":\"c1ec4a95-1f05-45b3-a911-aa3fa01094f5\"}]},{\"property\":\"availableServicePlan\",\"operator\":\"notIn\",\"value\":[{\"label\":\"Microsoft Entra ID P1\",\"value\":\"AAD_PREMIUM\",\"id\":\"41781fb2-bc02-4b7c-bd55-b576c07bb09d\"}]}]","DynamicRules@type":null,"GroupType":"dynamic","GroupType@type":null,"RuleLogic":"and","RuleLogic@type":null,"Name":"Not Intune and Entra Premium Capable","Name@type":null},{"PartitionKey":"TenantGroup","RowKey":"4dbca08b-7dc5-4e0f-bc25-14a90c8e0941","Description":"This group has atleast one Business Premium License available","Description@type":null,"DynamicRules":"[{\"property\":\"availableLicense\",\"operator\":\"in\",\"value\":[{\"label\":\"Microsoft 365 Business Premium\",\"value\":\"SPB\"}]},{\"property\":\"availableLicense\",\"operator\":\"in\",\"value\":[{\"label\":\"Microsoft 365 Business Premium (no Teams)\",\"value\":\"Microsoft_365_ Business_ Premium_(no Teams)\"}]},{\"property\":\"availableLicense\",\"operator\":\"in\",\"value\":[{\"label\":\"Microsoft 365 Business Premium Donation\",\"value\":\"Microsoft_365_Business_Premium_Donation_(Non_Profit_Pricing)\"}]},{\"property\":\"availableLicense\",\"operator\":\"in\",\"value\":[{\"label\":\"Microsoft 365 Business Premium EEA (no Teams)\",\"value\":\"Office_365_w\/o_Teams_Bundle_Business_Premium\"}]}]","DynamicRules@type":null,"GroupType":"dynamic","GroupType@type":null,"RuleLogic":"or","RuleLogic@type":null,"Name":"Business Premium License available","Name@type":null},{"PartitionKey":"TenantGroup","RowKey":"703c0e69-84a8-4dcf-a1c2-4986d2ccc850","Description":"This group does have a license for Entra Premium but does not have a license for Intune","Description@type":null,"DynamicRules":"[{\"property\":\"availableServicePlan\",\"operator\":\"in\",\"value\":[{\"label\":\"Microsoft Entra ID P1\",\"value\":\"AAD_PREMIUM\",\"id\":\"41781fb2-bc02-4b7c-bd55-b576c07bb09d\"}]},{\"property\":\"availableServicePlan\",\"operator\":\"notIn\",\"value\":[{\"label\":\"Microsoft Intune\",\"value\":\"INTUNE_A\",\"id\":\"c1ec4a95-1f05-45b3-a911-aa3fa01094f5\"}]}]","DynamicRules@type":null,"GroupType":"dynamic","GroupType@type":null,"RuleLogic":"and","RuleLogic@type":null,"Name":"Entra Premium Capable, Not Intune Capable","Name@type":null},{"PartitionKey":"TenantGroup","RowKey":"c1dadbc0-f0b4-448c-a2e6-e1938ba102e0","Description":"This group has Intune and Entra ID Premium available","Description@type":null,"DynamicRules":"{\"property\":\"availableServicePlan\",\"operator\":\"in\",\"value\":[{\"label\":\"Microsoft Intune\",\"value\":\"INTUNE_A\"},{\"label\":\"Microsoft Entra ID P1\",\"value\":\"AAD_PREMIUM\"}]}","DynamicRules@type":null,"GroupType":"dynamic","GroupType@type":null,"RuleLogic":"and","RuleLogic@type":null,"Name":"Entra ID Premium and Intune Capable","Name@type":null}]' | ConvertFrom-Json + $DefaultGroups = '[{"PartitionKey":"TenantGroup","RowKey":"369d985e-0fba-48f9-844f-9f793b10a12c","Description":"This group does not have a license for intune, nor a license for Entra ID Premium","Description@type":null,"DynamicRules":"[{\"property\":\"availableServicePlan\",\"operator\":\"notIn\",\"value\":[{\"label\":\"Microsoft Intune\",\"value\":\"INTUNE_A\",\"id\":\"c1ec4a95-1f05-45b3-a911-aa3fa01094f5\"}]},{\"property\":\"availableServicePlan\",\"operator\":\"notIn\",\"value\":[{\"label\":\"Microsoft Entra ID P1\",\"value\":\"AAD_PREMIUM\",\"id\":\"41781fb2-bc02-4b7c-bd55-b576c07bb09d\"}]}]","DynamicRules@type":null,"GroupType":"dynamic","GroupType@type":null,"RuleLogic":"and","RuleLogic@type":null,"Name":"Not Intune and Entra Premium Capable","Name@type":null},{"PartitionKey":"TenantGroup","RowKey":"4dbca08b-7dc5-4e0f-bc25-14a90c8e0941","Description":"This group has atleast one Business Premium License available","DynamicRules":"{\"property\":\"availableLicense\",\"operator\":\"in\",\"value\":[{\"label\":\"Microsoft 365 Business Premium\",\"value\":\"SPB\",\"guid\":\"cbdc14ab-d96c-4c30-b9f4-6ada7cdc1d46\"},{\"label\":\"Microsoft 365 Business Premium (no Teams)\",\"value\":\"Microsoft_365_ Business_ Premium_(no Teams)\",\"guid\":\"00e1ec7b-e4a3-40d1-9441-b69b597ab222\"},{\"label\":\"Microsoft 365 Business Premium Donation\",\"value\":\"Microsoft_365_Business_Premium_Donation_(Non_Profit_Pricing)\",\"guid\":\"24c35284-d768-4e53-84d9-b7ae73dddf69\"},{\"label\":\"Microsoft 365 Business Premium EEA (no Teams)\",\"value\":\"Office_365_w/o_Teams_Bundle_Business_Premium\",\"guid\":\"a3f586b6-8cce-4d9b-99d6-55238397f77a\"}]}","GroupType":"dynamic","Name":"Business Premium License available","RuleLogic":"or"},{"PartitionKey":"TenantGroup","RowKey":"703c0e69-84a8-4dcf-a1c2-4986d2ccc850","Description":"This group does have a license for Entra Premium but does not have a license for Intune","Description@type":null,"DynamicRules":"[{\"property\":\"availableServicePlan\",\"operator\":\"in\",\"value\":[{\"label\":\"Microsoft Entra ID P1\",\"value\":\"AAD_PREMIUM\",\"id\":\"41781fb2-bc02-4b7c-bd55-b576c07bb09d\"}]},{\"property\":\"availableServicePlan\",\"operator\":\"notIn\",\"value\":[{\"label\":\"Microsoft Intune\",\"value\":\"INTUNE_A\",\"id\":\"c1ec4a95-1f05-45b3-a911-aa3fa01094f5\"}]}]","DynamicRules@type":null,"GroupType":"dynamic","GroupType@type":null,"RuleLogic":"and","RuleLogic@type":null,"Name":"Entra Premium Capable, Not Intune Capable","Name@type":null},{"PartitionKey":"TenantGroup","RowKey":"c1dadbc0-f0b4-448c-a2e6-e1938ba102e0","Description":"This group has Intune and Entra ID Premium available","Description@type":null,"DynamicRules":"{\"property\":\"availableServicePlan\",\"operator\":\"in\",\"value\":[{\"label\":\"Microsoft Intune\",\"value\":\"INTUNE_A\"},{\"label\":\"Microsoft Entra ID P1\",\"value\":\"AAD_PREMIUM\"}]}","DynamicRules@type":null,"GroupType":"dynamic","GroupType@type":null,"RuleLogic":"and","RuleLogic@type":null,"Name":"Entra ID Premium and Intune Capable","Name@type":null}]' | ConvertFrom-Json foreach ($Group in $DefaultGroups) { From b6e89c2a551895c2f6d2463204db54487c37ef47 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Fri, 20 Feb 2026 18:14:01 -0500 Subject: [PATCH 94/97] Add log entry to Invoke-AddAlert Add a Write-LogMessage call to Invoke-AddAlert to record alert additions (API='AddAlert') with message, severity Info, LogData and request headers for telemetry/troubleshooting. Also normalize the function keyword casing from 'Function' to 'function' for consistency. --- .../Tenant/Administration/Alerts/Invoke-AddAlert.ps1 | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Tenant/Administration/Alerts/Invoke-AddAlert.ps1 b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Tenant/Administration/Alerts/Invoke-AddAlert.ps1 index a683b153c508..df6818654545 100644 --- a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Tenant/Administration/Alerts/Invoke-AddAlert.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Tenant/Administration/Alerts/Invoke-AddAlert.ps1 @@ -1,4 +1,4 @@ -Function Invoke-AddAlert { +function Invoke-AddAlert { <# .FUNCTIONALITY Entrypoint @@ -27,6 +27,7 @@ Function Invoke-AddAlert { $WebhookTable = Get-CippTable -TableName 'WebhookRules' Add-CIPPAzDataTableEntity @WebhookTable -Entity $CompleteObject -Force $Results = "Added Audit Log Alert for $($Tenants.count) tenants. It may take up to four hours before Microsoft starts delivering these alerts." + Write-LogMessage -API 'AddAlert' -message $Results -sev Info -LogData $CompleteObject -headers $Request.Headers return ([HttpResponseContext]@{ StatusCode = [HttpStatusCode]::OK From 5f240c15f99694725f84223fb895bf9ba9f43479 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Fri, 20 Feb 2026 18:23:21 -0500 Subject: [PATCH 95/97] fix quarantine return --- .../Public/Alerts/Get-CIPPAlertQuarantineReleaseRequests.ps1 | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertQuarantineReleaseRequests.ps1 b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertQuarantineReleaseRequests.ps1 index 58ed3b035407..b20096d59020 100644 --- a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertQuarantineReleaseRequests.ps1 +++ b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertQuarantineReleaseRequests.ps1 @@ -14,7 +14,7 @@ #Add rerun protection: This Monitor can only run once every hour. $Rerun = Test-CIPPRerun -TenantFilter $TenantFilter -Type 'ExchangeMonitor' -API 'Get-CIPPAlertQuarantineReleaseRequests' if ($Rerun) { - return $true + return } $HasLicense = Test-CIPPStandardLicense -StandardName 'QuarantineReleaseRequests' -TenantFilter $TenantFilter -RequiredCapabilities @( 'EXCHANGE_S_STANDARD', @@ -25,7 +25,7 @@ ) if (-not $HasLicense) { - return $true + return } try { From e9a01a9c4ecd0e8b57f276b2d19292069a763f21 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Fri, 20 Feb 2026 19:31:11 -0500 Subject: [PATCH 96/97] Add cleanup rule and use OData timestamp filters Invoke server-side OData timestamp filtering and add a table cleanup rule for quarantine messages. - Invoke-ListMailQuarantine.ps1: replace client-side Where-Object Timestamp check with an OData filter that uses a UTC datetime string (yyyy-MM-ddTHH:mm:ssZ). This constructs the timestamp ($30MinutesAgo) via ToUniversalTime and embeds it in the Table query to avoid fetching then filtering locally. - Start-TableCleanup.ps1: add a CleanupRule entry for the cacheQuarantineMessages table to delete QuarantineMessage rows older than 1 day (uses an OData lt datetime filter). The rule requests up to 10000 rows and returns PartitionKey/RowKey/ETag for deletion. These changes move time-based filtering into the Azure Table query to reduce data transfer and add automated cleanup for quarantine messages. --- .../Spamfilter/Invoke-ListMailQuarantine.ps1 | 5 +++-- .../Entrypoints/Timer Functions/Start-TableCleanup.ps1 | 10 ++++++++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Email-Exchange/Spamfilter/Invoke-ListMailQuarantine.ps1 b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Email-Exchange/Spamfilter/Invoke-ListMailQuarantine.ps1 index d785b015f940..7780f90588eb 100644 --- a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Email-Exchange/Spamfilter/Invoke-ListMailQuarantine.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Email-Exchange/Spamfilter/Invoke-ListMailQuarantine.ps1 @@ -16,8 +16,9 @@ function Invoke-ListMailQuarantine { } else { $Table = Get-CIPPTable -TableName cacheQuarantineMessages $PartitionKey = 'QuarantineMessage' - $Filter = "PartitionKey eq '$PartitionKey'" - $Rows = Get-CIPPAzDataTableEntity @Table -filter $Filter | Where-Object -Property Timestamp -GT (Get-Date).AddMinutes(-30) + $30MinutesAgo = (Get-Date).AddMinutes(-30).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') + $Filter = "PartitionKey eq '$PartitionKey' and Timestamp gt datetime'$30MinutesAgo'" + $Rows = Get-CIPPAzDataTableEntity @Table -filter $Filter $QueueReference = '{0}-{1}' -f $TenantFilter, $PartitionKey $RunningQueue = Invoke-ListCippQueue -Reference $QueueReference | Where-Object { $_.Status -notmatch 'Completed' -and $_.Status -notmatch 'Failed' } # If a queue is running, we will not start a new one diff --git a/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-TableCleanup.ps1 b/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-TableCleanup.ps1 index 74f620e133e1..771e9d66363c 100644 --- a/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-TableCleanup.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-TableCleanup.ps1 @@ -75,6 +75,16 @@ function Start-TableCleanup { Property = @('PartitionKey', 'RowKey', 'ETag') } } + @{ + FunctionName = 'TableCleanupTask' + Type = 'CleanupRule' + TableName = 'cacheQuarantineMessages' + DataTableProps = @{ + Filter = "PartitionKey eq 'QuarantineMessage' and Timestamp lt datetime'$((Get-Date).AddDays(-1).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ'))'" + First = 10000 + Property = @('PartitionKey', 'RowKey', 'ETag') + } + } @{ FunctionName = 'TableCleanupTask' Type = 'DeleteTable' From 80c4477632674cff5a02259bf3d82343dc1dac26 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Fri, 20 Feb 2026 22:12:41 -0500 Subject: [PATCH 97/97] cleanup logging --- .../Activity Triggers/Push-ExecScheduledCommand.ps1 | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Modules/CIPPCore/Public/Entrypoints/Activity Triggers/Push-ExecScheduledCommand.ps1 b/Modules/CIPPCore/Public/Entrypoints/Activity Triggers/Push-ExecScheduledCommand.ps1 index 22607cb0841b..a7744b0a1af0 100644 --- a/Modules/CIPPCore/Public/Entrypoints/Activity Triggers/Push-ExecScheduledCommand.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/Activity Triggers/Push-ExecScheduledCommand.ps1 @@ -317,13 +317,12 @@ function Push-ExecScheduledCommand { } Write-LogMessage -API 'Scheduler_UserTasks' -tenant $Tenant -tenantid $TenantInfo.customerId -message "Failed to execute task $($task.Name): $errorMessage" -sev Error -LogData (Get-CippExceptionData -Exception $_.Exception) } - Write-Information 'Sending task results to target. Updating the task state.' # For orchestrator-based commands, skip post-execution alerts as they will be handled by the orchestrator's post-execution function if ($Results -and $Item.Command -notin $OrchestratorBasedCommands) { + Write-Information "Sending task results to post execution target(s): $($Task.PostExecution -join ', ')." Send-CIPPScheduledTaskAlert -Results $Results -TaskInfo $task -TenantFilter $Tenant -TaskType $TaskType } - Write-Information 'Sent the results to the target. Updating the task state.' try { # For orchestrator-based commands, skip task state update as it will be handled by post-execution