From 7b3c838227c2cfa5c30849c67a73a4ea18e5184c Mon Sep 17 00:00:00 2001 From: Integrated Solutions Date: Wed, 15 Oct 2025 16:36:44 +1000 Subject: [PATCH 001/150] It's LAPS not LAPs :P --- Modules/CIPPCore/Public/PermissionsTranslator.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Modules/CIPPCore/Public/PermissionsTranslator.json b/Modules/CIPPCore/Public/PermissionsTranslator.json index 74fbcebe7cbe..f8b7ab74d4b5 100644 --- a/Modules/CIPPCore/Public/PermissionsTranslator.json +++ b/Modules/CIPPCore/Public/PermissionsTranslator.json @@ -5328,12 +5328,12 @@ "value": "AllSites.FullControl" }, { - "description": "Allows to read the LAPs passwords.", - "displayName": "Manage LAPs passwords", + "description": "Allows to read the LAPS passwords.", + "displayName": "Manage LAPS passwords", "id": "280b3b69-0437-44b1-bc20-3b2fca1ee3e9", "Origin": "Delegated", - "userConsentDescription": "Allows to read the LAPs passwords.", - "userConsentDisplayName": "Manage LAPs passwords", + "userConsentDescription": "Allows to read the LAPS passwords.", + "userConsentDisplayName": "Manage LAPS passwords", "value": "DeviceLocalCredential.Read.All" }, { From 0dee9b1b7e4ade406b9a0ae7c3572a88dff43fdb Mon Sep 17 00:00:00 2001 From: Logan Cook <2997336+MWG-Logan@users.noreply.github.com> Date: Sat, 3 Jan 2026 20:01:44 -0500 Subject: [PATCH 002/150] feat(api): add Intune reusable settings endpoints and tests feat(api): add reusable settings template endpoints and tests feat(api): add reusable settings template standard and tests feat(intune): enhance reusable settings handling in templates adds reusable setting template reference from within intune templates. Attempts to acquire a template match by reusable setting disaplayname and references the discovered template if found. if not, creates a new template and references that. This also enhances the standards experience to allow for simply deploying your intune policy. Everything else is automatic. fix: Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> fix(standards): change impact from High to Low refactor(api): extract reusable setting sync to helper Update Tests/Standards/Invoke-CIPPStandardReusableSettingsTemplate.Tests.ps1 Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> test(tests): update assertions for New-GraphPOSTRequest feat(api): add support for reusable settings in Intune policy refactor(api): extract metadata removal function into helper refactor(api): implement reusable settings discovery helper chore(api): update added date for reusable settings template refactor(api): remove package field from reusable setting templates fix(api): undo over-zealous changes on existing file refactor(api): enhance reusable settings discovery logic refactor(api): remove unused reusable settings assignment refactor(api): rename normalization function to approved verb fix(api): change impact level from high to low test(api): add metadata cleanup function to tests refactor(api): ensure string serialization for RawJSON refactor(api): optimize ReusableSettings initialization fix(api): move helper functions into public moving helper functions into public as that seems to be where the bulk of existing ones actually live fix(api): clean up spacing and foreach childResults refactor(api): optimize array handling in metadata removal refactor(api): replace Write-Information with Write-Verbose for ReusableSettings logging fix(tests): remove unused package field from test data Update Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardIntuneTemplate.ps1 Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- Config/standards.json | 40 ++++ .../Public/Compare-CIPPIntuneObject.ps1 | 12 +- .../MEM/Invoke-AddIntuneReusableSetting.ps1 | 108 +++++++++++ ...nvoke-AddIntuneReusableSettingTemplate.ps1 | 92 +++++++++ .../Endpoint/MEM/Invoke-AddIntuneTemplate.ps1 | 40 ++-- .../Endpoint/MEM/Invoke-AddPolicy.ps1 | 51 ++++- ...oke-ListIntuneReusableSettingTemplates.ps1 | 44 +++++ .../MEM/Invoke-ListIntuneReusableSettings.ps1 | 72 ++++++++ .../MEM/Invoke-ListIntuneTemplates.ps1 | 2 + .../Invoke-RemoveIntuneReusableSetting.ps1 | 51 +++++ ...ke-RemoveIntuneReusableSettingTemplate.ps1 | 38 ++++ .../Get-CIPPReusableSettingsFromPolicy.ps1 | 157 ++++++++++++++++ .../Remove-CIPPReusableSettingMetadata.ps1 | 23 +++ .../CIPPCore/Public/Set-CIPPIntunePolicy.ps1 | 11 +- .../Invoke-CIPPStandardIntuneTemplate.ps1 | 13 +- ...e-CIPPStandardReusableSettingsTemplate.ps1 | 174 ++++++++++++++++++ .../Sync-CIPPReusablePolicySettings.ps1 | 78 ++++++++ .../Invoke-AddIntuneReusableSetting.Tests.ps1 | 86 +++++++++ ...AddIntuneReusableSettingTemplate.Tests.ps1 | 75 ++++++++ ...stIntuneReusableSettingTemplates.Tests.ps1 | 66 +++++++ ...nvoke-ListIntuneReusableSettings.Tests.ps1 | 66 +++++++ ...voke-RemoveIntuneReusableSetting.Tests.ps1 | 64 +++++++ ...oveIntuneReusableSettingTemplate.Tests.ps1 | 53 ++++++ ...StandardReusableSettingsTemplate.Tests.ps1 | 152 +++++++++++++++ 24 files changed, 1535 insertions(+), 33 deletions(-) create mode 100644 Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-AddIntuneReusableSetting.ps1 create mode 100644 Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-AddIntuneReusableSettingTemplate.ps1 create mode 100644 Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ListIntuneReusableSettingTemplates.ps1 create mode 100644 Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ListIntuneReusableSettings.ps1 create mode 100644 Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-RemoveIntuneReusableSetting.ps1 create mode 100644 Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-RemoveIntuneReusableSettingTemplate.ps1 create mode 100644 Modules/CIPPCore/Public/Get-CIPPReusableSettingsFromPolicy.ps1 create mode 100644 Modules/CIPPCore/Public/Remove-CIPPReusableSettingMetadata.ps1 create mode 100644 Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardReusableSettingsTemplate.ps1 create mode 100644 Modules/CIPPCore/Public/Sync-CIPPReusablePolicySettings.ps1 create mode 100644 Tests/Endpoint/Invoke-AddIntuneReusableSetting.Tests.ps1 create mode 100644 Tests/Endpoint/Invoke-AddIntuneReusableSettingTemplate.Tests.ps1 create mode 100644 Tests/Endpoint/Invoke-ListIntuneReusableSettingTemplates.Tests.ps1 create mode 100644 Tests/Endpoint/Invoke-ListIntuneReusableSettings.Tests.ps1 create mode 100644 Tests/Endpoint/Invoke-RemoveIntuneReusableSetting.Tests.ps1 create mode 100644 Tests/Endpoint/Invoke-RemoveIntuneReusableSettingTemplate.Tests.ps1 create mode 100644 Tests/Standards/Invoke-CIPPStandardReusableSettingsTemplate.Tests.ps1 diff --git a/Config/standards.json b/Config/standards.json index 3c40463ff676..245f7bae2466 100644 --- a/Config/standards.json +++ b/Config/standards.json @@ -4835,6 +4835,46 @@ } ] }, + { + "name": "standards.ReusableSettingsTemplate", + "cat": "Templates", + "label": "Reusable Settings Template", + "multiple": true, + "disabledFeatures": { + "report": false, + "warn": false, + "remediate": false + }, + "impact": "Low Impact", + "impactColour": "info", + "addedDate": "2026-01-11", + "helpText": "Deploy and maintain Intune reusable settings templates that can be referenced by multiple policies.", + "executiveText": "Creates and keeps reusable Intune settings templates consistent so common firewall and configuration blocks can be reused across many policies.", + "addedComponent": [ + { + "type": "autoComplete", + "multiple": true, + "creatable": false, + "required": true, + "name": "TemplateList", + "label": "Select Reusable Settings Template", + "api": { + "queryKey": "ListIntuneReusableSettingTemplates", + "url": "/api/ListIntuneReusableSettingTemplates", + "labelField": "displayName", + "valueField": "GUID", + "showRefresh": true, + "templateView": { + "title": "Reusable Settings", + "property": "RawJSON", + "type": "intune" + } + } + } + ], + "powershellEquivalent": "", + "recommendedBy": [] + }, { "name": "standards.TransportRuleTemplate", "label": "Transport Rule Template", diff --git a/Modules/CIPPCore/Public/Compare-CIPPIntuneObject.ps1 b/Modules/CIPPCore/Public/Compare-CIPPIntuneObject.ps1 index 20f5464d1bfa..b61e0dd093c2 100644 --- a/Modules/CIPPCore/Public/Compare-CIPPIntuneObject.ps1 +++ b/Modules/CIPPCore/Public/Compare-CIPPIntuneObject.ps1 @@ -166,7 +166,7 @@ function Compare-CIPPIntuneObject { if ($isObj1Array -or $isObj2Array) { return } - + # Safely get property names - ensure objects are not arrays before accessing PSObject.Properties $allPropertyNames = @() try { @@ -202,7 +202,7 @@ function Compare-CIPPIntuneObject { if ($prop1Exists -and $prop2Exists) { try { # Double-check arrays before accessing properties - if (($Object1 -is [Array] -or $Object1 -is [System.Collections.IList]) -or + if (($Object1 -is [Array] -or $Object1 -is [System.Collections.IList]) -or ($Object2 -is [Array] -or $Object2 -is [System.Collections.IList])) { continue } @@ -297,7 +297,7 @@ function Compare-CIPPIntuneObject { foreach ($groupValue in $child.groupSettingCollectionValue) { if ($groupValue.children) { $nestedResults = Process-GroupSettingChildren -Children $groupValue.children -Source $Source -IntuneCollection $IntuneCollection - $results.AddRange($nestedResults) + foreach ($nr in $nestedResults) { $results.Add($nr) } } } } @@ -383,7 +383,7 @@ function Compare-CIPPIntuneObject { # Also process any children within choice setting values if ($child.choiceSettingValue?.children) { $nestedResults = Process-GroupSettingChildren -Children $child.choiceSettingValue.children -Source $Source -IntuneCollection $IntuneCollection - $results.AddRange($nestedResults) + foreach ($nr in $nestedResults) { $results.Add($nr) } } } @@ -401,7 +401,7 @@ function Compare-CIPPIntuneObject { foreach ($groupValue in $settingInstance.groupSettingCollectionValue) { if ($groupValue.children -is [System.Array]) { $childResults = Process-GroupSettingChildren -Children $groupValue.children -Source 'Reference' -IntuneCollection $intuneCollection - $groupResults.AddRange($childResults) + foreach ($cr in $childResults) { $groupResults.Add($cr) } } } # Return the results from the recursive processing @@ -473,7 +473,7 @@ function Compare-CIPPIntuneObject { foreach ($groupValue in $settingInstance.groupSettingCollectionValue) { if ($groupValue.children -is [System.Array]) { $childResults = Process-GroupSettingChildren -Children $groupValue.children -Source 'Difference' -IntuneCollection $intuneCollection - $groupResults.AddRange($childResults) + foreach ($cr in $childResults) { $groupResults.Add($cr) } } } # Return the results from the recursive processing diff --git a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-AddIntuneReusableSetting.ps1 b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-AddIntuneReusableSetting.ps1 new file mode 100644 index 000000000000..e13b0d5039a3 --- /dev/null +++ b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-AddIntuneReusableSetting.ps1 @@ -0,0 +1,108 @@ +function Invoke-AddIntuneReusableSetting { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Endpoint.MEM.ReadWrite + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers + + $Tenant = $Request.Query.tenantFilter ?? $Request.Body.tenantFilter + $TemplateId = $Request.Body.TemplateId ?? $Request.Body.TemplateList?.value ?? $Request.Body.TemplateList ?? $Request.Query.TemplateId + + # Normalize tenant filter (UI sends an array of objects with value/defaultDomainName) + if ($Tenant -is [System.Collections.IEnumerable] -and -not ($Tenant -is [string])) { + $Tenant = @($Tenant)[0] + } + + $Tenant = $Tenant.value ?? $Tenant.addedFields?.defaultDomainName ?? $Tenant + + if (-not $Tenant) { + return ([HttpResponseContext]@{ + StatusCode = [System.Net.HttpStatusCode]::BadRequest + Body = @{ Results = 'tenantFilter is required' } + }) + } + + if (-not $TemplateId) { + return ([HttpResponseContext]@{ + StatusCode = [System.Net.HttpStatusCode]::BadRequest + Body = @{ Results = 'TemplateId is required' } + }) + } + + try { + $Table = Get-CippTable -tablename 'templates' + $Filter = "PartitionKey eq 'IntuneReusableSettingTemplate' and RowKey eq '$TemplateId'" + $TemplateEntity = Get-CIPPAzDataTableEntity @Table -Filter $Filter + if (-not $TemplateEntity) { + return ([HttpResponseContext]@{ + StatusCode = [System.Net.HttpStatusCode]::NotFound + Body = @{ Results = "Template $TemplateId not found" } + }) + } + + $TemplateJson = $TemplateEntity.RawJSON + if (-not $TemplateJson) { + $ParsedEntity = $TemplateEntity.JSON | ConvertFrom-Json -Depth 200 -ErrorAction SilentlyContinue + $TemplateJson = $ParsedEntity.RawJSON + } + if (-not $TemplateJson) { throw "Template $TemplateId has no RawJSON" } + + try { + $BodyObject = $TemplateJson | ConvertFrom-Json -ErrorAction Stop + } catch { + throw "Template JSON is invalid: $($_.Exception.Message)" + } + + $displayName = $BodyObject.displayName ?? $TemplateId + + $ExistingSettings = New-GraphGETRequest -Uri 'https://graph.microsoft.com/beta/deviceManagement/reusablePolicySettings' -tenantid $Tenant + $ExistingMatch = @($ExistingSettings) | Where-Object { $_.displayName -eq $displayName } | Select-Object -First 1 + + $compare = $null + if ($ExistingMatch) { + try { + $ExistingSanitized = $ExistingMatch | Select-Object -Property * -ExcludeProperty id, createdDateTime, lastModifiedDateTime, version, '@odata.context' + $compare = Compare-CIPPIntuneObject -ReferenceObject $BodyObject -DifferenceObject $ExistingSanitized -compareType 'ReusablePolicySetting' -ErrorAction SilentlyContinue + } catch { + $compare = $null + } + } + + if ($ExistingMatch -and -not $compare) { + $message = "Reusable setting '$displayName' is already compliant." + Write-LogMessage -headers $Headers -API $APIName -message $message -Sev 'Info' + return ([HttpResponseContext]@{ + StatusCode = [System.Net.HttpStatusCode]::OK + Body = @{ Results = $message; Id = $ExistingMatch.id } + }) + } + + if ($ExistingMatch) { + $null = New-GraphPOSTRequest -Uri "https://graph.microsoft.com/beta/deviceManagement/reusablePolicySettings/$($ExistingMatch.id)" -tenantid $Tenant -type PUT -body $TemplateJson + $Result = "Updated reusable setting '$displayName' in tenant $Tenant" + } else { + $Create = New-GraphPOSTRequest -Uri 'https://graph.microsoft.com/beta/deviceManagement/reusablePolicySettings' -tenantid $Tenant -type POST -body $TemplateJson + $Result = "Created reusable setting '$displayName' in tenant $Tenant" + } + + Write-LogMessage -headers $Headers -API $APIName -message $Result -Sev 'Info' + return ([HttpResponseContext]@{ + StatusCode = [System.Net.HttpStatusCode]::OK + Body = @{ Results = $Result } + }) + } catch { + $ErrorMessage = Get-CippException -Exception $_ + $logMessage = "Reusable settings deployment failed: $($ErrorMessage.NormalizedError)" + Write-LogMessage -headers $Headers -API $APIName -message $logMessage -Sev Error -LogData $ErrorMessage + return ([HttpResponseContext]@{ + StatusCode = [System.Net.HttpStatusCode]::InternalServerError + Body = @{ Results = $logMessage } + }) + } +} diff --git a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-AddIntuneReusableSettingTemplate.ps1 b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-AddIntuneReusableSettingTemplate.ps1 new file mode 100644 index 000000000000..c5d96910299a --- /dev/null +++ b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-AddIntuneReusableSettingTemplate.ps1 @@ -0,0 +1,92 @@ +function Invoke-AddIntuneReusableSettingTemplate { + <# + .FUNCTIONALITY + Entrypoint,AnyTenant + .ROLE + Endpoint.MEM.ReadWrite + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers + + $GUID = $Request.Body.GUID ?? (New-Guid).GUID + + function Format-ReusableSettingCollections { + param($InputObject) + + if ($null -eq $InputObject) { return } + + if ($InputObject -is [System.Collections.IEnumerable] -and -not ($InputObject -is [string])) { + foreach ($item in $InputObject) { Format-ReusableSettingCollections -InputObject $item } + return + } + + if ($InputObject -is [psobject]) { + foreach ($prop in $InputObject.PSObject.Properties) { + if ($prop.Name -ieq 'children' -and $null -eq $prop.Value) { + # Graph requires children to be an array; null collections must be normalized. + $prop.Value = @() + continue + } + + Format-ReusableSettingCollections -InputObject $prop.Value + } + } + } + + try { + $displayName = $Request.Body.displayName ?? $Request.Body.DisplayName ?? $Request.Body.displayname + if (-not $displayName) { throw 'You must enter a displayName' } + + $description = $Request.Body.description ?? $Request.Body.Description + $rawJsonInput = $Request.Body.rawJSON ?? $Request.Body.RawJSON ?? $Request.Body.json + + if (-not $rawJsonInput) { throw 'You must provide RawJSON for the reusable setting' } + + try { + $parsed = $rawJsonInput | ConvertFrom-Json -Depth 100 -ErrorAction Stop + } catch { + throw "RawJSON is not valid JSON: $($_.Exception.Message)" + } + + # Normalize required collections and deep-clean Graph metadata/nulls before storing + Format-ReusableSettingCollections -InputObject $parsed + $cleanParsed = Remove-CIPPReusableSettingMetadata -InputObject $parsed + $sanitizedJson = ($cleanParsed | ConvertTo-Json -Depth 100 -Compress) + + $entity = [pscustomobject]@{ + DisplayName = $displayName + Description = $description + RawJSON = $sanitizedJson + GUID = $GUID + } | ConvertTo-Json -Depth 100 -Compress + + $Table = Get-CippTable -tablename 'templates' + $Table.Force = $true + Add-CIPPAzDataTableEntity @Table -Force -Entity @{ + JSON = "$entity" + RowKey = "$GUID" + PartitionKey = 'IntuneReusableSettingTemplate' + GUID = "$GUID" + DisplayName = $displayName + Description = $description + RawJSON = "$sanitizedJson" # ensure string serialization for table storage + } + + Write-LogMessage -headers $Headers -API $APINAME -message "Created Intune reusable setting template named $displayName with GUID $GUID" -Sev 'Debug' + $body = [pscustomobject]@{ Results = 'Successfully added reusable setting template' } + $StatusCode = [System.Net.HttpStatusCode]::OK + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -headers $Headers -API $APINAME -message "Reusable Settings Template creation failed: $($ErrorMessage.NormalizedError)" -Sev 'Error' -LogData $ErrorMessage + $body = [pscustomobject]@{ Results = "Reusable Settings Template creation failed: $($ErrorMessage.NormalizedError)" } + $StatusCode = [System.Net.HttpStatusCode]::InternalServerError + } + + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = $body + }) +} diff --git a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-AddIntuneTemplate.ps1 b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-AddIntuneTemplate.ps1 index 22c029fb5b16..ad2c5d7c5466 100644 --- a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-AddIntuneTemplate.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-AddIntuneTemplate.ps1 @@ -17,20 +17,23 @@ function Invoke-AddIntuneTemplate { if (!$Request.Body.displayName) { throw 'You must enter a displayName' } if ($null -eq ($Request.Body.RawJSON | ConvertFrom-Json)) { throw 'the JSON is invalid' } - + $reusableTemplateRefs = @() $object = [PSCustomObject]@{ - Displayname = $Request.Body.displayName - Description = $Request.Body.description - RAWJson = $Request.Body.RawJSON - Type = $Request.Body.TemplateType - GUID = $GUID + Displayname = $Request.Body.displayName + Description = $Request.Body.description + RAWJson = $Request.Body.RawJSON + Type = $Request.Body.TemplateType + GUID = $GUID + ReusableSettings = $reusableTemplateRefs } | ConvertTo-Json $Table = Get-CippTable -tablename 'templates' $Table.Force = $true Add-CIPPAzDataTableEntity @Table -Entity @{ - JSON = "$object" - RowKey = "$GUID" - PartitionKey = 'IntuneTemplate' + JSON = "$object" + ReusableSettingsCount = $reusableTemplateRefs.Count + RowKey = "$GUID" + PartitionKey = 'IntuneTemplate' + GUID = "$GUID" } Write-LogMessage -headers $Headers -API $APIName -message "Created intune policy template named $($Request.Body.displayName) with GUID $GUID" -Sev 'Debug' @@ -42,14 +45,19 @@ function Invoke-AddIntuneTemplate { $ID = $Request.Body.ID ?? $Request.Query.ID $ODataType = $Request.Body.ODataType ?? $Request.Query.ODataType $Template = New-CIPPIntuneTemplate -TenantFilter $TenantFilter -URLName $URLName -ID $ID -ODataType $ODataType - Write-Host "Template: $Template" + + $reusableResult = Get-CIPPReusableSettingsFromPolicy -PolicyJson $Template.TemplateJson -Tenant $TenantFilter -Headers $Headers -APIName $APIName + $reusableTemplateRefs = $reusableResult.ReusableSettings + $object = [PSCustomObject]@{ - Displayname = $Template.DisplayName - Description = $Template.Description - RAWJson = $Template.TemplateJson - Type = $Template.Type - GUID = $GUID - } | ConvertTo-Json + Displayname = $Template.DisplayName + Description = $Template.Description + RAWJson = $Template.TemplateJson + Type = $Template.Type + GUID = $GUID + ReusableSettings = $reusableTemplateRefs + } + $Table = Get-CippTable -tablename 'templates' $Table.Force = $true Add-CIPPAzDataTableEntity @Table -Entity @{ 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 5aaf4692faac..92dfb409d341 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 @@ -24,18 +24,51 @@ function Invoke-AddPolicy { if ($Request.Body.replacemap.$Tenant) { ([pscustomobject]$Request.Body.replacemap.$Tenant).PSObject.Properties | ForEach-Object { $RawJSON = $RawJSON -replace $_.name, $_.value } } + + $reusableSettings = $Request.Body.ReusableSettings ?? $Request.Body.reusableSettings + if (-not $reusableSettings -or $reusableSettings.Count -eq 0) { + try { + $templatesTable = Get-CippTable -tablename 'templates' + $templateEntity = Get-CIPPAzDataTableEntity @templatesTable -Filter "PartitionKey eq 'IntuneTemplate' and RowKey eq '$($Request.Body.TemplateID ?? $Request.Body.TemplateId ?? $Request.Body.TemplateGuid ?? $Request.Body.TemplateGUID)'" | Select-Object -First 1 + if (-not $templateEntity -and $DisplayName) { + $templateEntity = Get-CIPPAzDataTableEntity @templatesTable -Filter "PartitionKey eq 'IntuneTemplate'" | Where-Object { ($_.JSON | ConvertFrom-Json -ErrorAction SilentlyContinue).Displayname -eq $DisplayName } | Select-Object -First 1 + } + if ($templateEntity) { + $templateObj = $templateEntity.JSON | ConvertFrom-Json -ErrorAction SilentlyContinue + if ($templateObj.ReusableSettings) { $reusableSettings = $templateObj.ReusableSettings } + if ($templateObj.RAWJson) { $RawJSON = $templateObj.RAWJson } + } + } catch {} + } + + if (-not $reusableSettings -and $RawJSON) { + try { + # Discover referenced reusable settings from the policy JSON when none were supplied + $reusableResult = Get-CIPPReusableSettingsFromPolicy -PolicyJson $RawJSON -Tenant $Tenant -Headers $Headers -APIName $APIName + if ($reusableResult.ReusableSettings) { $reusableSettings = $reusableResult.ReusableSettings } + } catch {} + } + + $reusableSettingsForSet = $reusableSettings + if ($Request.Body.TemplateType -eq 'Catalog') { + $syncResult = Sync-CIPPReusablePolicySettings -TemplateInfo ([pscustomobject]@{ RawJSON = $RawJSON; ReusableSettings = $reusableSettings }) -Tenant $Tenant + if ($syncResult.RawJSON) { $RawJSON = $syncResult.RawJSON } + $reusableSettingsForSet = $null # helper already created/updated reusable settings and rewrote JSON + } + try { Write-Host 'Calling Adding policy' $params = @{ - TemplateType = $Request.Body.TemplateType - Description = $description - DisplayName = $DisplayName - RawJSON = $RawJSON - AssignTo = $AssignTo - ExcludeGroup = $ExcludeGroup - tenantFilter = $Tenant - Headers = $Headers - APIName = $APIName + TemplateType = $Request.Body.TemplateType + Description = $description + DisplayName = $DisplayName + RawJSON = $RawJSON + ReusableSettings = $reusableSettingsForSet + AssignTo = $AssignTo + ExcludeGroup = $ExcludeGroup + tenantFilter = $Tenant + Headers = $Headers + APIName = $APIName } Set-CIPPIntunePolicy @params } catch { diff --git a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ListIntuneReusableSettingTemplates.ps1 b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ListIntuneReusableSettingTemplates.ps1 new file mode 100644 index 000000000000..941142e43fc7 --- /dev/null +++ b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ListIntuneReusableSettingTemplates.ps1 @@ -0,0 +1,44 @@ +function Invoke-ListIntuneReusableSettingTemplates { + <# + .FUNCTIONALITY + Entrypoint,AnyTenant + .ROLE + Endpoint.MEM.Read + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $Table = Get-CippTable -tablename 'templates' + $Filter = "PartitionKey eq 'IntuneReusableSettingTemplate'" + $RawTemplates = Get-CIPPAzDataTableEntity @Table -Filter $Filter + + $Templates = foreach ($Item in $RawTemplates) { + $Parsed = $null + if ($Item.JSON) { + $Parsed = $Item.JSON | ConvertFrom-Json -Depth 100 -ErrorAction SilentlyContinue + } + + $DisplayName = $Parsed.DisplayName ?? $Parsed.displayName ?? $Item.DisplayName ?? $Item.RowKey + $Description = $Parsed.Description ?? $Parsed.description ?? $Item.Description + $RawJSON = $Parsed.RawJSON ?? $Item.RawJSON + [PSCustomObject]@{ + displayName = $DisplayName + description = $Description + GUID = $Item.RowKey + RawJSON = $RawJSON + isSynced = -not [string]::IsNullOrEmpty($Item.SHA) + } + } + + $Templates = $Templates | Sort-Object -Property displayName + + if ($Request.query.ID) { + $Templates = $Templates | Where-Object -Property GUID -EQ $Request.query.ID + } + + return ([HttpResponseContext]@{ + StatusCode = [System.Net.HttpStatusCode]::OK + Body = @($Templates) + }) + +} diff --git a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ListIntuneReusableSettings.ps1 b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ListIntuneReusableSettings.ps1 new file mode 100644 index 000000000000..319a79b8956c --- /dev/null +++ b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ListIntuneReusableSettings.ps1 @@ -0,0 +1,72 @@ +function Invoke-ListIntuneReusableSettings { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Endpoint.MEM.Read + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers + + $TenantFilter = $Request.Query.tenantFilter + $SettingId = $Request.Query.ID + + if (-not $TenantFilter) { + return ([HttpResponseContext]@{ + StatusCode = [System.Net.HttpStatusCode]::BadRequest + Body = @{ Results = 'tenantFilter is required' } + }) + } + + try { + $baseUri = 'https://graph.microsoft.com/beta/deviceManagement/reusablePolicySettings' + $selectFields = @( + 'id' + 'settingInstance' + 'displayName' + 'description' + 'settingDefinitionId' + 'version' + 'referencingConfigurationPolicyCount' + 'createdDateTime' + 'lastModifiedDateTime' + ) + $selectQuery = '?$select=' + ($selectFields -join ',') + $uri = if ($SettingId) { "$baseUri/$SettingId$selectQuery" } else { "$baseUri$selectQuery" } + + $Settings = New-GraphGetRequest -uri $uri -tenantid $TenantFilter + if (-not $Settings) { $Settings = @() } + + $Settings = @($Settings) | Where-Object { $_ } | ForEach-Object { + $setting = $_ + + $rawJson = $null + try { + $rawJson = $setting | ConvertTo-Json -Depth 50 -Compress -ErrorAction Stop + } catch { + $rawJson = $null + } + + $setting | Add-Member -NotePropertyName 'RawJSON' -NotePropertyValue $rawJson -Force -PassThru + } + $StatusCode = [System.Net.HttpStatusCode]::OK + } catch { + $ErrorMessage = Get-CippException -Exception $_ + $logMessage = "Failed to retrieve reusable policy settings: $($ErrorMessage.NormalizedError)" + Write-LogMessage -headers $Headers -API $APIName -message $logMessage -Sev Error -LogData $ErrorMessage + $Settings = @() + $StatusCode = [System.Net.HttpStatusCode]::InternalServerError + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = @{ Results = $logMessage } + }) + } + + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = @($Settings) + }) +} diff --git a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ListIntuneTemplates.ps1 b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ListIntuneTemplates.ps1 index a64228f7c2f6..f1f117dfe453 100644 --- a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ListIntuneTemplates.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ListIntuneTemplates.ps1 @@ -41,6 +41,7 @@ function Invoke-ListIntuneTemplates { $data | Add-Member -NotePropertyName 'package' -NotePropertyValue $_.Package -Force $data | Add-Member -NotePropertyName 'isSynced' -NotePropertyValue (![string]::IsNullOrEmpty($_.SHA)) -Force $data | Add-Member -NotePropertyName 'source' -NotePropertyValue $_.Source -Force + $data | Add-Member -NotePropertyName 'reusableSettings' -NotePropertyValue $JSONData.ReusableSettings -Force $data } catch { @@ -68,6 +69,7 @@ function Invoke-ListIntuneTemplates { $data | Add-Member -NotePropertyName 'package' -NotePropertyValue $_.Package -Force $data | Add-Member -NotePropertyName 'source' -NotePropertyValue $_.Source -Force $data | Add-Member -NotePropertyName 'isSynced' -NotePropertyValue (![string]::IsNullOrEmpty($_.SHA)) -Force + $data | Add-Member -NotePropertyName 'reusableSettings' -NotePropertyValue $JSONData.ReusableSettings -Force $data } catch { diff --git a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-RemoveIntuneReusableSetting.ps1 b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-RemoveIntuneReusableSetting.ps1 new file mode 100644 index 000000000000..03b42a4c6c2a --- /dev/null +++ b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-RemoveIntuneReusableSetting.ps1 @@ -0,0 +1,51 @@ +function Invoke-RemoveIntuneReusableSetting { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Endpoint.MEM.ReadWrite + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers + + $TenantFilter = $Request.Body.tenantFilter ?? $Request.Query.tenantFilter + $ID = $Request.Body.ID ?? $Request.Query.ID + $DisplayName = $Request.Body.DisplayName ?? $Request.Query.DisplayName + + if (-not $TenantFilter) { + return ([HttpResponseContext]@{ + StatusCode = [System.Net.HttpStatusCode]::BadRequest + Body = @{ Results = 'tenantFilter is required' } + }) + } + + if (-not $ID) { + return ([HttpResponseContext]@{ + StatusCode = [System.Net.HttpStatusCode]::BadRequest + Body = @{ Results = 'ID is required' } + }) + } + + try { + $uri = "https://graph.microsoft.com/beta/deviceManagement/reusablePolicySettings/$ID" + $null = New-GraphPOSTRequest -uri $uri -type DELETE -tenantid $TenantFilter + + $name = if ($DisplayName) { $DisplayName } else { $ID } + $Result = "Deleted Intune reusable setting '$name' ($ID)" + Write-LogMessage -headers $Headers -API $APIName -message $Result -Sev 'Info' + $StatusCode = [System.Net.HttpStatusCode]::OK + } catch { + $ErrorMessage = Get-CippException -Exception $_ + $Result = "Failed to delete Intune reusable setting $($ID): $($ErrorMessage.NormalizedError)" + Write-LogMessage -headers $Headers -API $APIName -message $Result -Sev 'Error' -LogData $ErrorMessage + $StatusCode = [System.Net.HttpStatusCode]::InternalServerError + } + + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = @{ Results = $Result } + }) +} diff --git a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-RemoveIntuneReusableSettingTemplate.ps1 b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-RemoveIntuneReusableSettingTemplate.ps1 new file mode 100644 index 000000000000..50cf3154e2ea --- /dev/null +++ b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-RemoveIntuneReusableSettingTemplate.ps1 @@ -0,0 +1,38 @@ +function Invoke-RemoveIntuneReusableSettingTemplate { + <# + .FUNCTIONALITY + Entrypoint,AnyTenant + .ROLE + Endpoint.MEM.ReadWrite + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers + + $ID = $Request.Query.ID ?? $Request.Body.ID + + try { + if (-not $ID) { throw 'You must supply an ID' } + + $Table = Get-CippTable -tablename 'templates' + $Filter = "PartitionKey eq 'IntuneReusableSettingTemplate' and RowKey eq '$ID'" + $Row = Get-CIPPAzDataTableEntity @Table -Filter $Filter -Property PartitionKey, RowKey + Remove-AzDataTableEntity -Force @Table -Entity $Row + + $Result = "Removed Intune reusable setting template with ID $ID" + Write-LogMessage -Headers $Headers -API $APIName -message $Result -Sev 'Info' + $StatusCode = [System.Net.HttpStatusCode]::OK + } catch { + $ErrorMessage = Get-CippException -Exception $_ + $Result = "Failed to remove Intune reusable setting template $($ID): $($ErrorMessage.NormalizedError)" + Write-LogMessage -Headers $Headers -API $APIName -message $Result -Sev 'Error' -LogData $ErrorMessage + $StatusCode = [System.Net.HttpStatusCode]::InternalServerError + } + + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = @{'Results' = $Result } + }) +} diff --git a/Modules/CIPPCore/Public/Get-CIPPReusableSettingsFromPolicy.ps1 b/Modules/CIPPCore/Public/Get-CIPPReusableSettingsFromPolicy.ps1 new file mode 100644 index 000000000000..8e331a484f00 --- /dev/null +++ b/Modules/CIPPCore/Public/Get-CIPPReusableSettingsFromPolicy.ps1 @@ -0,0 +1,157 @@ +function Get-CIPPReusableSettingsFromPolicy { + param( + [string]$PolicyJson, + [string]$Tenant, + $Headers, + [string]$APIName + ) + + $result = [pscustomobject]@{ + ReusableSettings = [System.Collections.Generic.List[psobject]]::new() + } + + if (-not $PolicyJson) { return $result } + + try { + $policyObject = $PolicyJson | ConvertFrom-Json -Depth 300 -ErrorAction Stop + } catch { + Write-LogMessage -headers $Headers -API $APIName -message "Reusable settings discovery failed: policy JSON invalid ($($_.Exception.Message))" -Sev 'Warn' + return $result + } + + function Get-ReusableSettingIds { + param( + [Parameter(Mandatory = $true)] + $PolicyObject + ) + + $ids = [System.Collections.Generic.List[string]]::new() + + function Get-ReusableSettingIdsFromValue { + param( + $Value, + [string]$ParentName = '' + ) + + if ($null -eq $Value) { return } + + if ($Value -is [System.Collections.IEnumerable] -and $Value -isnot [string]) { + foreach ($item in $Value) { Get-ReusableSettingIdsFromValue -Value $item -ParentName $ParentName } + return + } + + if ($Value -is [psobject]) { + if ($Value.'@odata.type' -like '*ReferenceSettingValue' -and $Value.value -match '^[0-9a-fA-F-]{36}$') { + $ids.Add($Value.value) + } + + if ($ParentName -eq 'simpleSettingCollectionValue' -and $Value.value -is [string] -and $Value.value -match '^[0-9a-fA-F-]{36}$') { + $ids.Add($Value.value) + } + + foreach ($prop in $Value.PSObject.Properties) { + $name = $prop.Name + $propValue = $prop.Value + + if ($name -match 'reusableSetting') { + if ($propValue -is [string] -and $propValue -match '^[0-9a-fA-F-]{36}$') { $ids.Add($propValue) } + elseif ($propValue -is [psobject] -and $propValue.id -match '^[0-9a-fA-F-]{36}$') { $ids.Add($propValue.id) } + elseif ($propValue -is [System.Collections.IEnumerable]) { + foreach ($entry in $propValue) { + if ($entry -is [string] -and $entry -match '^[0-9a-fA-F-]{36}$') { $ids.Add($entry) } + elseif ($entry -is [psobject] -and $entry.id -match '^[0-9a-fA-F-]{36}$') { $ids.Add($entry.id) } + } + } + } + + Get-ReusableSettingIdsFromValue -Value $propValue -ParentName $name + } + } + } + + Get-ReusableSettingIdsFromValue -Value $PolicyObject + return $ids | Select-Object -Unique + } + + $referencedReusableIds = Get-ReusableSettingIds -PolicyObject $policyObject + Write-Information "ReusableSettings discovery: found $($referencedReusableIds.Count) ids -> $($referencedReusableIds -join ',')" + + if (-not $referencedReusableIds) { return $result } + + $templatesTable = Get-CippTable -tablename 'templates' + $templatesTableForAdd = @{} + $templatesTable + $templatesTableForAdd.Force = $true + + $existingReusableTemplates = @(Get-CIPPAzDataTableEntity @templatesTable -Filter "PartitionKey eq 'IntuneReusableSettingTemplate'") + $existingReusableByName = @{} + foreach ($templateEntry in $existingReusableTemplates) { + $name = $templateEntry.DisplayName + if (-not $name) { + $parsed = $templateEntry.JSON | ConvertFrom-Json -ErrorAction SilentlyContinue + $name = $parsed.DisplayName + } + if ($name -and -not $existingReusableByName.ContainsKey($name)) { + $existingReusableByName[$name] = $templateEntry + } + } + + foreach ($settingId in $referencedReusableIds) { + try { + $setting = New-GraphGETRequest -Uri "https://graph.microsoft.com/beta/deviceManagement/reusablePolicySettings/$settingId" -tenantid $Tenant + if (-not $setting) { + Write-LogMessage -headers $Headers -API $APIName -message "Reusable setting $settingId not returned from Graph" -Sev 'Warn' + continue + } + + $settingDisplayName = $setting.displayName + if (-not $settingDisplayName) { + Write-LogMessage -headers $Headers -API $APIName -message "Reusable setting $settingId missing displayName" -Sev 'Warn' + continue + } + + $matchedTemplate = $existingReusableByName[$settingDisplayName] + $templateGuid = $matchedTemplate.RowKey + + if (-not $templateGuid) { + $cleanSetting = Remove-CIPPReusableSettingMetadata -InputObject $setting + $sanitizedJson = $cleanSetting | ConvertTo-Json -Depth 100 -Compress + $templateGuid = (New-Guid).Guid + $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 + } + + $existingReusableByName[$settingDisplayName] = [pscustomobject]@{ + RowKey = $templateGuid + DisplayName = $settingDisplayName + JSON = $reusableEntity + } + + 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' + } + + $result.ReusableSettings.Add([pscustomobject]@{ + displayName = $settingDisplayName + templateId = $templateGuid + sourceId = $settingId + }) + } catch { + Write-LogMessage -headers $Headers -API $APIName -message "Failed to link reusable setting $settingId for template creation: $($_.Exception.Message)" -Sev 'Warn' + } + } + + Write-LogMessage -headers $Headers -API $APIName -message "Reusable settings mapped: $($result.ReusableSettings.Count) -> $($result.ReusableSettings.displayName -join ', ')" -Sev 'Info' + return $result +} diff --git a/Modules/CIPPCore/Public/Remove-CIPPReusableSettingMetadata.ps1 b/Modules/CIPPCore/Public/Remove-CIPPReusableSettingMetadata.ps1 new file mode 100644 index 000000000000..3a6fcba20866 --- /dev/null +++ b/Modules/CIPPCore/Public/Remove-CIPPReusableSettingMetadata.ps1 @@ -0,0 +1,23 @@ +function Remove-CIPPReusableSettingMetadata { + param($InputObject) + + if ($null -eq $InputObject) { return $null } + + 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 + } + + 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 + } + return [pscustomobject]$output + } + + return $InputObject +} diff --git a/Modules/CIPPCore/Public/Set-CIPPIntunePolicy.ps1 b/Modules/CIPPCore/Public/Set-CIPPIntunePolicy.ps1 index b0518e1d5d5a..63895588ee49 100644 --- a/Modules/CIPPCore/Public/Set-CIPPIntunePolicy.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPIntunePolicy.ps1 @@ -11,7 +11,8 @@ function Set-CIPPIntunePolicy { $APIName = 'Set-CIPPIntunePolicy', $TenantFilter, $AssignmentFilterName, - $AssignmentFilterType = 'include' + $AssignmentFilterType = 'include', + [array]$ReusableSettings ) $RawJSON = Get-CIPPTextReplacement -TenantFilter $TenantFilter -Text $RawJSON @@ -133,6 +134,14 @@ function Set-CIPPIntunePolicy { $PlatformType = 'deviceManagement' $TemplateTypeURL = 'configurationPolicies' $DisplayName = ($RawJSON | ConvertFrom-Json).Name + if ($ReusableSettings) { + Write-Verbose "Catalog: ReusableSettings count $($ReusableSettings.Count)" + Write-Verbose ("Catalog: ReusableSettings detail " + ($ReusableSettings | ConvertTo-Json -Depth 5 -Compress)) + $syncResult = Sync-CIPPReusablePolicySettings -TemplateInfo ([pscustomobject]@{ RawJSON = $RawJSON; ReusableSettings = $ReusableSettings }) -Tenant $TenantFilter + if ($syncResult.RawJSON) { $RawJSON = $syncResult.RawJSON } + } else { + Write-Verbose "Catalog: No ReusableSettings provided" + } $Template = $RawJSON | ConvertFrom-Json if ($Template.templateReference.templateId) { diff --git a/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardIntuneTemplate.ps1 b/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardIntuneTemplate.ps1 index 4ba8ebbc353d..595e536c0714 100644 --- a/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardIntuneTemplate.ps1 +++ b/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardIntuneTemplate.ps1 @@ -36,6 +36,7 @@ function Invoke-CIPPStandardIntuneTemplate { https://docs.cipp.app/user-documentation/tenant/standards/list-standards #> param($Tenant, $Settings) + $TestResult = Test-CIPPStandardLicense -StandardName 'IntuneTemplate_general' -TenantFilter $Tenant -RequiredCapabilities @('INTUNE_A', 'MDM_Services', 'EMS', 'SCCM', 'MICROSOFTINTUNEPLAN1') ##$Rerun -Type Standard -Tenant $Tenant -Settings $Settings 'intuneTemplate' @@ -58,6 +59,16 @@ function Invoke-CIPPStandardIntuneTemplate { Write-LogMessage -API 'Standards' -tenant $tenant -message "Failed to find template $($Template.TemplateList.value). Has this Intune Template been deleted?" -sev 'Error' continue } + try { + $reusableSync = Sync-CIPPReusablePolicySettings -TemplateInfo $Request.body -Tenant $Tenant -ErrorAction Stop + if ($null -ne $reusableSync -and $reusableSync.PSObject.Properties.Name -contains 'RawJSON' -and $reusableSync.RawJSON) { + $Request.body.RawJSON = $reusableSync.RawJSON + } + } catch { + Write-LogMessage -API 'Standards' -tenant $tenant -message "Failed to sync reusable policy settings for template $($Template.TemplateList.value): $($_.Exception.Message)" -sev 'Error' + Write-Host "IntuneTemplate: $($Template.TemplateList.value) - Failed to sync reusable policy settings. Skipping this template." + continue + } Write-Host "IntuneTemplate: $($Template.TemplateList.value) - Got template." $displayname = $request.body.Displayname @@ -140,7 +151,7 @@ function Invoke-CIPPStandardIntuneTemplate { Write-Host "working on template deploy: $($TemplateFile.displayname)" try { $TemplateFile.customGroup ? ($TemplateFile.AssignTo = $TemplateFile.customGroup) : $null - + $PolicyParams = @{ TemplateType = $TemplateFile.body.Type Description = $TemplateFile.description diff --git a/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardReusableSettingsTemplate.ps1 b/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardReusableSettingsTemplate.ps1 new file mode 100644 index 000000000000..3960ddb4281c --- /dev/null +++ b/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardReusableSettingsTemplate.ps1 @@ -0,0 +1,174 @@ +function Invoke-CIPPStandardReusableSettingsTemplate { + <# + .FUNCTIONALITY + Internal + .COMPONENT + (APIName) ReusableSettingsTemplate + .SYNOPSIS + (Label) Reusable Settings Template + .DESCRIPTION + (Helptext) Deploy and manage Intune reusable settings templates for reuse across multiple policies. + (DocsDescription) Deploy and manage Intune reusable settings templates for reuse across multiple policies. + .NOTES + CAT + Templates + MULTIPLE + True + DISABLEDFEATURES + {"report":false,"warn":false,"remediate":false} + IMPACT + Low Impact + ADDEDDATE + 2026-01-11 + EXECUTIVETEXT + Creates and maintains reusable Intune settings templates that can be referenced by multiple policies, ensuring consistent firewall and configuration rule blocks are centrally managed and updated. + ADDEDCOMPONENT + {"type":"autoComplete","multiple":true,"creatable":false,"required":true,"name":"TemplateList","label":"Select Reusable Settings Template","api":{"queryKey":"ListIntuneReusableSettingTemplates","url":"/api/ListIntuneReusableSettingTemplates","labelField":"DisplayName","valueField":"GUID","showRefresh":true,"templateView":{"title":"Reusable Settings","property":"RawJSON","type":"intune"}}} + UPDATECOMMENTBLOCK + Run the Tools\Update-StandardsComments.ps1 script to update this comment block + .LINK + https://docs.cipp.app/user-documentation/tenant/standards/list-standards + #> + param($Tenant, $Settings) + + function Remove-CIPPNullProperties { + param($InputObject) + + if ($null -eq $InputObject) { + return $null + } + + if ($InputObject -is [System.Collections.IEnumerable] -and $InputObject -isnot [string]) { + $CleanArray = [System.Collections.Generic.List[object]]::new() + foreach ($item in $InputObject) { + $CleanArray.Add((Remove-CIPPNullProperties -InputObject $item)) + } + return $CleanArray + } + + if ($InputObject -is [psobject]) { + $Output = [ordered]@{} + foreach ($prop in $InputObject.PSObject.Properties) { + if ($null -ne $prop.Value) { + $Output[$prop.Name] = Remove-CIPPNullProperties -InputObject $prop.Value + } + } + return [pscustomobject]$Output + } + + return $InputObject + } + + $RequiredCapabilities = @('INTUNE_A', 'MDM_Services', 'EMS', 'SCCM', 'MICROSOFTINTUNEPLAN1') + $TestResult = Test-CIPPStandardLicense -StandardName 'ReusableSettingsTemplate_general' -TenantFilter $Tenant -RequiredCapabilities $RequiredCapabilities + if ($TestResult -eq $false) { + $settings.TemplateList | ForEach-Object { + $MissingLicenseMessage = "This tenant is missing one or more required licenses for this standard: $($RequiredCapabilities -join ', ')." + Set-CIPPStandardsCompareField -FieldName "standards.ReusableSettingsTemplate.$($_.value)" -FieldValue $MissingLicenseMessage -Tenant $Tenant + } + Write-LogMessage -API 'Standards' -tenant $Tenant -message "Exiting as the correct license is not present for this standard. Missing: $($RequiredCapabilities -join ', ')" -sev 'Warn' + return $true + } + + $Table = Get-CippTable -tablename 'templates' + $ExistingReusableSettings = New-GraphGETRequest -Uri 'https://graph.microsoft.com/beta/deviceManagement/reusablePolicySettings?$top=999' -tenantid $Tenant + + # Align with other template standards by resolving all selected templates upfront + $SelectedTemplateIds = @($Settings.TemplateList.value) + if (-not $SelectedTemplateIds) { + Write-LogMessage -API 'Standards' -tenant $Tenant -message 'No reusable settings templates were selected.' -sev 'Warn' + return $true + } + + $AllTemplateEntities = Get-CIPPAzDataTableEntity @Table -Filter "PartitionKey eq 'IntuneReusableSettingTemplate'" + $TemplateEntities = $AllTemplateEntities | + Where-Object { ($_.RowKey -in $SelectedTemplateIds) -and (-not [string]::IsNullOrWhiteSpace($_.JSON)) } | + ForEach-Object { $_.JSON } | + ConvertFrom-Json -ErrorAction SilentlyContinue + if (-not $TemplateEntities) { + Write-LogMessage -API 'Standards' -tenant $Tenant -message "Failed to resolve reusable settings templates: $($SelectedTemplateIds -join ', ')" -sev 'Error' + return $true + } + + $CompareList = foreach ($TemplateEntity in $TemplateEntities) { + $Compare = $null + $displayName = $TemplateEntity.DisplayName ?? $TemplateEntity.Name + $RawJSON = $TemplateEntity.RawJSON ?? $TemplateEntity.JSON + $BodyObject = $RawJSON | ConvertFrom-Json -ErrorAction SilentlyContinue + $BodyObjectClean = Remove-CIPPNullProperties -InputObject $BodyObject + $Existing = $ExistingReusableSettings | Where-Object -Property displayName -EQ $displayName | Select-Object -First 1 + + if ($Existing) { + try { + $ExistingSanitized = $Existing | Select-Object -Property * -ExcludeProperty id, createdDateTime, lastModifiedDateTime, version, '@odata.context' + $ExistingClean = Remove-CIPPNullProperties -InputObject $ExistingSanitized + $Compare = Compare-CIPPIntuneObject -ReferenceObject $BodyObjectClean -DifferenceObject $ExistingClean -compareType 'ReusablePolicySetting' -ErrorAction SilentlyContinue + } catch { + Write-LogMessage -API 'Standards' -tenant $Tenant -message "ReusableSettingsTemplate: compare failed for $displayName. $($_.Exception.Message)" -sev 'Error' + } + } else { + $Compare = [pscustomobject]@{ + MatchFailed = $true + Difference = 'Reusable setting is missing in this tenant.' + } + } + + $CompareClean = if ($Compare) { Remove-CIPPNullProperties -InputObject $Compare } else { $Compare } + + [pscustomobject]@{ + MatchFailed = [bool]$Compare + displayname = $displayName + compare = $CompareClean + rawJSON = $RawJSON + remediate = $Settings.remediate + alert = $Settings.alert + report = $Settings.report + templateId = $TemplateEntity.GUID + existingId = $Existing.id + } + } + + if ($true -in $Settings.remediate) { + foreach ($Template in $CompareList | Where-Object -Property remediate -EQ $true) { + $Body = $Template.rawJSON + + if ($Template.existingId) { + try { + $null = New-GraphPOSTRequest -uri "https://graph.microsoft.com/beta/deviceManagement/reusablePolicySettings/$($Template.existingId)" -tenantid $Tenant -type PUT -body $Body + Write-LogMessage -API 'Standards' -tenant $Tenant -message "Updated reusable setting $($Template.displayName)" -sev 'Info' + } catch { + $errorMessage = Get-NormalizedError -Message $_.Exception.Message + Write-LogMessage -API 'Standards' -tenant $Tenant -message "Failed to update reusable setting $($Template.displayName). Error: $errorMessage" -sev 'Error' + } + } else { + try { + $CreateRequest = New-GraphPOSTRequest -uri 'https://graph.microsoft.com/beta/deviceManagement/reusablePolicySettings' -tenantid $Tenant -type POST -body $Body + Write-LogMessage -API 'Standards' -tenant $Tenant -message "Created reusable setting $($Template.displayName)" -sev 'Info' + } catch { + $createError = Get-NormalizedError -Message $_.Exception.Message + Write-LogMessage -API 'Standards' -tenant $Tenant -message "Failed to create reusable setting $($Template.displayName). Error: $createError" -sev 'Error' + } + } + } + } + + if ($true -in $Settings.alert) { + foreach ($Template in $CompareList | Where-Object -Property alert -EQ $true) { + $AlertObj = $Template | Select-Object -Property displayName, compare, existingId + if ($Template.compare) { + Write-StandardsAlert -message "Reusable setting $($Template.displayName) does not match the expected configuration." -object $AlertObj -tenant $Tenant -standardName 'ReusableSettingsTemplate' -standardId $Template.templateId + Write-LogMessage -API 'Standards' -tenant $Tenant -message "Reusable setting $($Template.displayName) is out of compliance." -sev info + } else { + Write-LogMessage -API 'Standards' -tenant $Tenant -message "Reusable setting $($Template.displayName) is compliant." -sev Info + } + } + } + + if ($true -in $Settings.report) { + foreach ($Template in $CompareList | Where-Object { $_.report -eq $true -or $_.remediate -eq $true }) { + $id = $Template.templateId + $state = $Template.compare ? $Template.compare : $true + Set-CIPPStandardsCompareField -FieldName "standards.ReusableSettingsTemplate.$id" -FieldValue $state -TenantFilter $Tenant + } + } +} diff --git a/Modules/CIPPCore/Public/Sync-CIPPReusablePolicySettings.ps1 b/Modules/CIPPCore/Public/Sync-CIPPReusablePolicySettings.ps1 new file mode 100644 index 000000000000..6d1313157f03 --- /dev/null +++ b/Modules/CIPPCore/Public/Sync-CIPPReusablePolicySettings.ps1 @@ -0,0 +1,78 @@ +function Sync-CIPPReusablePolicySettings { + param( + [psobject]$TemplateInfo, + [string]$Tenant + ) + + $result = [pscustomobject]@{ + RawJSON = $TemplateInfo.RawJSON + Map = @{} + } + + $reusableRefs = @($TemplateInfo.ReusableSettings) + if (-not $reusableRefs) { return $result } + + $existingReusableSettings = New-GraphGETRequest -Uri 'https://graph.microsoft.com/beta/deviceManagement/reusablePolicySettings?$top=999' -tenantid $Tenant + $table = Get-CippTable -tablename 'templates' + $templateEntities = Get-CIPPAzDataTableEntity @table -Filter "PartitionKey eq 'IntuneReusableSettingTemplate'" + + foreach ($ref in $reusableRefs) { + $templateId = $ref.templateId ?? $ref.templateID ?? $ref.GUID ?? $ref.RowKey + $sourceId = $ref.sourceId ?? $ref.sourceReusableSettingId ?? $ref.sourceGuid ?? $ref.id + $displayName = $ref.displayName ?? $ref.DisplayName + + if (-not $templateId -or -not $displayName) { continue } + + $templateEntity = $templateEntities | Where-Object { $_.RowKey -eq $templateId } | Select-Object -First 1 + if (-not $templateEntity) { continue } + + $templateData = $templateEntity.JSON | ConvertFrom-Json -Depth 200 -ErrorAction SilentlyContinue + $templateRaw = $templateData.RawJSON + if ($templateRaw -is [string] -and $templateRaw -match '"children"\s*:\s*null') { + try { + $templateRaw = [regex]::Replace($templateRaw, '"children"\s*:\s*null', '"children":[]', [System.Text.RegularExpressions.RegexOptions]::IgnoreCase) + } catch {} + } + $templateBody = $templateRaw | ConvertFrom-Json -Depth 200 -ErrorAction SilentlyContinue + if (-not $templateRaw -or -not $templateBody) { continue } + $existingMatch = $existingReusableSettings | Where-Object -Property displayName -EQ $displayName | Select-Object -First 1 + $targetId = $existingMatch.id + $needsUpdate = $false + + if ($existingMatch) { + try { + $existingClean = $existingMatch | Select-Object -Property * -ExcludeProperty id, createdDateTime, lastModifiedDateTime, version, '@odata.context', '@odata.etag' + $compare = Compare-CIPPIntuneObject -ReferenceObject $templateBody -DifferenceObject $existingClean -compareType 'ReusablePolicySetting' -ErrorAction SilentlyContinue + if ($compare) { $needsUpdate = $true } + } catch { + $needsUpdate = $true + } + } else { + $needsUpdate = $true + } + + if ($needsUpdate) { + try { + if ($targetId) { + $updated = New-GraphPOSTRequest -uri "https://graph.microsoft.com/beta/deviceManagement/reusablePolicySettings/$targetId" -tenantid $Tenant -type PUT -body $templateRaw + $targetId = $updated.id ?? $targetId + } else { + $created = New-GraphPOSTRequest -uri 'https://graph.microsoft.com/beta/deviceManagement/reusablePolicySettings' -tenantid $Tenant -type POST -body $templateRaw + $targetId = $created.id ?? $targetId + } + } catch { + Write-LogMessage -API 'Standards' -tenant $Tenant -message "Failed to deploy reusable setting $($displayName): $($_.Exception.Message)" -sev 'Error' + } + } + + if ($sourceId -and $targetId) { $result.Map[$sourceId] = $targetId } + } + + $updatedJson = $result.RawJSON + foreach ($pair in $result.Map.GetEnumerator()) { + $updatedJson = $updatedJson -replace [regex]::Escape($pair.Key), $pair.Value + } + $result.RawJSON = $updatedJson + + return $result +} diff --git a/Tests/Endpoint/Invoke-AddIntuneReusableSetting.Tests.ps1 b/Tests/Endpoint/Invoke-AddIntuneReusableSetting.Tests.ps1 new file mode 100644 index 000000000000..555008547f99 --- /dev/null +++ b/Tests/Endpoint/Invoke-AddIntuneReusableSetting.Tests.ps1 @@ -0,0 +1,86 @@ +# Pester tests for Invoke-AddIntuneReusableSetting +# Validates create path, compliance short-circuit, and validation + +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-AddIntuneReusableSetting.ps1' + + class HttpResponseContext { + [int]$StatusCode + [object]$Body + } + + function Get-CippTable { param($tablename) @{} } + function Get-CIPPAzDataTableEntity { param($Filter) $script:lastFilter = $Filter; return $script:templateRow } + function New-GraphGETRequest { param($Uri, $tenantid) return $script:existingSettings } + function Compare-CIPPIntuneObject { param($ReferenceObject, $DifferenceObject, $compareType) return $script:compareResult } + function New-GraphPOSTRequest { param($Uri, $tenantid, $type, $body) $script:lastPost = @{ Uri = $Uri; Type = $type; Body = $body } } + function Write-LogMessage { param($headers, $API, $message, $sev, $LogData) $script:logs += $message } + function Get-CippException { param($Exception) $Exception } + + . $FunctionPath +} + +Describe 'Invoke-AddIntuneReusableSetting' { + BeforeEach { + $script:lastFilter = $null + $script:templateRow = [pscustomobject]@{ + RawJSON = '{"displayName":"Reusable One","setting":"value"}' + DisplayName = 'Reusable One' + } + $script:existingSettings = @() + $script:compareResult = $null + $script:lastPost = $null + $script:logs = @() + } + + It 'creates a new reusable setting when none exist' { + $request = [pscustomobject]@{ + Params = @{ CIPPEndpoint = 'AddIntuneReusableSetting' } + Headers = @{ Authorization = 'token' } + Body = [pscustomobject]@{ + tenantFilter = 'contoso.onmicrosoft.com' + TemplateId = 'template-1' + } + } + + $response = Invoke-AddIntuneReusableSetting -Request $request -TriggerMetadata $null + + $response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::OK) + $response.Body.Results | Should -Match 'Created reusable setting' + $lastPost.Type | Should -Be 'POST' + $lastPost.Uri | Should -Match '/reusablePolicySettings$' + $lastPost.Body | Should -Match 'displayName":"Reusable One"' + $logs | Should -Not -BeNullOrEmpty + } + + It 'returns OK and does not post when the setting is already compliant' { + $script:existingSettings = @([pscustomobject]@{ id = 'existing'; displayName = 'Reusable One'; version = 1 }) + $script:compareResult = $null + + $request = [pscustomobject]@{ + Params = @{ CIPPEndpoint = 'AddIntuneReusableSetting' } + Headers = @{} + Body = [pscustomobject]@{ + tenantFilter = 'contoso.onmicrosoft.com' + TemplateId = 'template-1' + } + } + + $response = Invoke-AddIntuneReusableSetting -Request $request -TriggerMetadata $null + + $response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::OK) + $response.Body.Id | Should -Be 'existing' + $response.Body.Results | Should -Match 'already compliant' + $lastPost | Should -BeNullOrEmpty + } + + It 'returns BadRequest when tenantFilter is missing' { + $request = [pscustomobject]@{ Params = @{}; Body = [pscustomobject]@{ TemplateId = 'template-1' } } + + $response = Invoke-AddIntuneReusableSetting -Request $request -TriggerMetadata $null + + $response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::BadRequest) + $response.Body.Results | Should -Match 'tenantFilter is required' + } +} diff --git a/Tests/Endpoint/Invoke-AddIntuneReusableSettingTemplate.Tests.ps1 b/Tests/Endpoint/Invoke-AddIntuneReusableSettingTemplate.Tests.ps1 new file mode 100644 index 000000000000..14a79de3fca1 --- /dev/null +++ b/Tests/Endpoint/Invoke-AddIntuneReusableSettingTemplate.Tests.ps1 @@ -0,0 +1,75 @@ +# Pester tests for Invoke-AddIntuneReusableSettingTemplate +# Validates template creation and validation + +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' + + class HttpResponseContext { + [int]$StatusCode + [object]$Body + } + + function Get-CippTable { param($tablename) @{} } + function Add-CIPPAzDataTableEntity { param([switch]$Force, $Entity) $script:lastEntity = $Entity; $script:lastForce = $Force } + function Write-LogMessage { param($headers, $API, $message, $sev, $LogData) $script:logs += $message } + function Get-CippException { + param($Exception) + # Mimic normalized error structure returned in prod code + [pscustomobject]@{ NormalizedError = $Exception } + } + + # Pass-through for metadata cleanup used in the function + function Remove-CIPPReusableSettingMetadata { param($InputObject) $InputObject } + + . $FunctionPath +} + +Describe 'Invoke-AddIntuneReusableSettingTemplate' { + BeforeEach { + $script:lastEntity = $null + $script:lastForce = $false + $script:logs = @() + } + + It 'creates a reusable setting template with stored metadata' { + $request = [pscustomobject]@{ + Params = @{ CIPPEndpoint = 'AddIntuneReusableSettingTemplate' } + Headers = @{ Authorization = 'Bearer token' } + Body = [pscustomobject]@{ + displayName = 'Template A' + description = 'Template description' + rawJSON = '{"displayName":"Template A"}' + GUID = 'template-a' + } + } + + $response = Invoke-AddIntuneReusableSettingTemplate -Request $request -TriggerMetadata $null + + $response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::OK) + $response.Body.Results | Should -Match 'Successfully added reusable setting template' + $lastEntity.PartitionKey | Should -Be 'IntuneReusableSettingTemplate' + $lastEntity.RowKey | Should -Be 'template-a' + $lastEntity.DisplayName | Should -Be 'Template A' + $lastEntity.Description | Should -Be 'Template description' + $lastEntity.RawJSON | Should -Match '"displayName":"Template A"' + $lastForce | Should -BeTrue + $logs | Should -Not -BeNullOrEmpty + } + + It 'returns InternalServerError when raw JSON is invalid' { + $request = [pscustomobject]@{ + Params = @{ CIPPEndpoint = 'AddIntuneReusableSettingTemplate' } + Headers = @{} + Body = [pscustomobject]@{ + displayName = 'Broken Template' + rawJSON = '{not-json}' + } + } + + $response = Invoke-AddIntuneReusableSettingTemplate -Request $request -TriggerMetadata $null + + $response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::InternalServerError) + $response.Body.Results | Should -Match 'RawJSON is not valid JSON' + } +} diff --git a/Tests/Endpoint/Invoke-ListIntuneReusableSettingTemplates.Tests.ps1 b/Tests/Endpoint/Invoke-ListIntuneReusableSettingTemplates.Tests.ps1 new file mode 100644 index 000000000000..3813c7de946b --- /dev/null +++ b/Tests/Endpoint/Invoke-ListIntuneReusableSettingTemplates.Tests.ps1 @@ -0,0 +1,66 @@ +# Pester tests for Invoke-ListIntuneReusableSettingTemplates +# Validates sorting, parsing, filtering, and sync flags + +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-ListIntuneReusableSettingTemplates.ps1' + + class HttpResponseContext { + [int]$StatusCode + [object]$Body + } + + function Get-CippTable { param($tablename) @{} } + function Get-CIPPAzDataTableEntity { param($Filter) $script:lastFilter = $Filter; return $script:tableRows } + + . $FunctionPath +} + +Describe 'Invoke-ListIntuneReusableSettingTemplates' { + BeforeEach { + $script:lastFilter = $null + $script:tableRows = @( + [pscustomobject]@{ + RowKey = 'b-guid' + JSON = '{"DisplayName":"B","RawJSON":"{\"b\":1}","Description":"B desc"}' + Source = 'sync' + SHA = 'abc123' + }, + [pscustomobject]@{ + RowKey = 'a-guid' + RawJSON = '{"displayName":"A"}' + DisplayName = 'A' + Description = 'Entity desc' + } + ) + } + + It 'returns sorted templates with parsed metadata and sync flag' { + $request = [pscustomobject]@{ query = @{} } + + $response = Invoke-ListIntuneReusableSettingTemplates -Request $request -TriggerMetadata $null + + $response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::OK) + $response.Body | Should -HaveCount 2 + $response.Body[0].displayName | Should -Be 'A' + $response.Body[0].description | Should -Be 'Entity desc' + $response.Body[0].GUID | Should -Be 'a-guid' + $response.Body[0].RawJSON | Should -Match '"displayName":"A"' + $response.Body[0].isSynced | Should -BeFalse + + $response.Body[1].displayName | Should -Be 'B' + $response.Body[1].description | Should -Be 'B desc' + $response.Body[1].GUID | Should -Be 'b-guid' + $response.Body[1].isSynced | Should -BeTrue + $lastFilter | Should -Be "PartitionKey eq 'IntuneReusableSettingTemplate'" + } + + It 'filters by ID when provided' { + $request = [pscustomobject]@{ query = @{ ID = 'b-guid' } } + + $response = Invoke-ListIntuneReusableSettingTemplates -Request $request -TriggerMetadata $null + + $response.Body | Should -HaveCount 1 + $response.Body[0].GUID | Should -Be 'b-guid' + } +} diff --git a/Tests/Endpoint/Invoke-ListIntuneReusableSettings.Tests.ps1 b/Tests/Endpoint/Invoke-ListIntuneReusableSettings.Tests.ps1 new file mode 100644 index 000000000000..5a66f62a43a4 --- /dev/null +++ b/Tests/Endpoint/Invoke-ListIntuneReusableSettings.Tests.ps1 @@ -0,0 +1,66 @@ +# Pester tests for Invoke-ListIntuneReusableSettings +# Validates listing and filtering of live reusable settings + +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-ListIntuneReusableSettings.ps1' + + class HttpResponseContext { + [int]$StatusCode + [object]$Body + } + + Add-Type -AssemblyName System.Net.Http + + function Write-LogMessage { param($headers, $API, $message, $sev, $LogData) } + function Get-CippException { param($Exception) $Exception } + function New-GraphGETRequest { param($uri, $tenantid) } + + . $FunctionPath +} + +Describe 'Invoke-ListIntuneReusableSettings' { + BeforeEach { + $script:lastUri = $null + } + + It 'returns reusable settings with raw JSON when tenantFilter is provided' { + Mock -CommandName New-GraphGETRequest -MockWith { + @( + [pscustomobject]@{ id = 'one'; displayName = 'A Item'; description = 'A description'; version = 1 }, + [pscustomobject]@{ id = 'two'; displayName = 'Z Item'; description = 'Z description'; version = 2 } + ) + } + + $request = [pscustomobject]@{ query = @{ tenantFilter = 'contoso.onmicrosoft.com' } } + $response = Invoke-ListIntuneReusableSettings -Request $request -TriggerMetadata $null + + $response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::OK) + $response.Body.Count | Should -Be 2 + $response.Body[0].displayName | Should -Be 'A Item' + $response.Body[0].RawJSON | Should -Not -BeNullOrEmpty + } + + It 'requests a specific setting when ID is provided' { + Mock -CommandName New-GraphGETRequest -MockWith { + param($uri, $tenantid) + $script:lastUri = $uri + @([pscustomobject]@{ id = 'beta'; displayName = 'Beta' }) + } + + $request = [pscustomobject]@{ query = @{ tenantFilter = 'contoso.onmicrosoft.com'; ID = 'beta' } } + $response = Invoke-ListIntuneReusableSettings -Request $request -TriggerMetadata $null + + $lastUri | Should -Match '/reusablePolicySettings/beta' + $response.Body.Count | Should -Be 1 + $response.Body[0].displayName | Should -Be 'Beta' + $response.Body[0].RawJSON | Should -Match '"id":"beta"' + } + + It 'returns BadRequest when tenantFilter is missing' { + $request = [pscustomobject]@{ query = @{} } + $response = Invoke-ListIntuneReusableSettings -Request $request -TriggerMetadata $null + + $response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::BadRequest) + } +} diff --git a/Tests/Endpoint/Invoke-RemoveIntuneReusableSetting.Tests.ps1 b/Tests/Endpoint/Invoke-RemoveIntuneReusableSetting.Tests.ps1 new file mode 100644 index 000000000000..da1acacfab22 --- /dev/null +++ b/Tests/Endpoint/Invoke-RemoveIntuneReusableSetting.Tests.ps1 @@ -0,0 +1,64 @@ +# Pester tests for Invoke-RemoveIntuneReusableSetting +# Validates deletion and required parameters + +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-RemoveIntuneReusableSetting.ps1' + + class HttpResponseContext { + [int]$StatusCode + [object]$Body + } + + function New-GraphPOSTRequest { param($uri, $type, $tenantid) $script:lastDelete = @{ Uri = $uri; Type = $type; Tenant = $tenantid } } + function Write-LogMessage { param($headers, $API, $message, $sev, $LogData) $script:logs += $message } + function Get-CippException { param($Exception) $Exception } + + . $FunctionPath +} + +Describe 'Invoke-RemoveIntuneReusableSetting' { + BeforeEach { + $script:lastDelete = $null + $script:logs = @() + } + + It 'deletes a reusable setting when tenant and ID are provided' { + $request = [pscustomobject]@{ + Params = @{ CIPPEndpoint = 'RemoveIntuneReusableSetting' } + Headers = @{ Authorization = 'token' } + Body = [pscustomobject]@{ + tenantFilter = 'contoso.onmicrosoft.com' + ID = 'setting-1' + DisplayName = 'Setting One' + } + } + + $response = Invoke-RemoveIntuneReusableSetting -Request $request -TriggerMetadata $null + + $response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::OK) + $response.Body.Results | Should -Match 'Deleted Intune reusable setting' + $lastDelete.Type | Should -Be 'DELETE' + $lastDelete.Uri | Should -Match '/reusablePolicySettings/setting-1' + $lastDelete.Tenant | Should -Be 'contoso.onmicrosoft.com' + $logs | Should -Not -BeNullOrEmpty + } + + It 'returns BadRequest when tenantFilter is missing' { + $request = [pscustomobject]@{ Body = [pscustomobject]@{ ID = 'missing-tenant' } } + + $response = Invoke-RemoveIntuneReusableSetting -Request $request -TriggerMetadata $null + + $response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::BadRequest) + $response.Body.Results | Should -Match 'tenantFilter is required' + } + + It 'returns BadRequest when ID is missing' { + $request = [pscustomobject]@{ Body = [pscustomobject]@{ tenantFilter = 'contoso.onmicrosoft.com' } } + + $response = Invoke-RemoveIntuneReusableSetting -Request $request -TriggerMetadata $null + + $response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::BadRequest) + $response.Body.Results | Should -Match 'ID is required' + } +} diff --git a/Tests/Endpoint/Invoke-RemoveIntuneReusableSettingTemplate.Tests.ps1 b/Tests/Endpoint/Invoke-RemoveIntuneReusableSettingTemplate.Tests.ps1 new file mode 100644 index 000000000000..e39bb1dfa0b7 --- /dev/null +++ b/Tests/Endpoint/Invoke-RemoveIntuneReusableSettingTemplate.Tests.ps1 @@ -0,0 +1,53 @@ +# Pester tests for Invoke-RemoveIntuneReusableSettingTemplate +# Validates template removal and error handling + +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-RemoveIntuneReusableSettingTemplate.ps1' + + class HttpResponseContext { + [int]$StatusCode + [object]$Body + } + + function Get-CippTable { param($tablename) @{} } + function Get-CIPPAzDataTableEntity { param($Filter, $Property) return [pscustomobject]@{ PartitionKey = 'IntuneReusableSettingTemplate'; RowKey = 'template-x' } } + function Remove-AzDataTableEntity { param([switch]$Force, $Entity) $script:lastRemoved = $Entity; $script:lastForce = $Force } + function Write-LogMessage { param($Headers, $API, $message, $sev, $LogData) $script:logs += $message } + function Get-CippException { param($Exception) [pscustomobject]@{ NormalizedError = $Exception } } + + . $FunctionPath +} + +Describe 'Invoke-RemoveIntuneReusableSettingTemplate' { + BeforeEach { + $script:lastRemoved = $null + $script:lastForce = $false + $script:logs = @() + } + + It 'removes a reusable setting template when ID is provided' { + $request = [pscustomobject]@{ + Params = @{ CIPPEndpoint = 'RemoveIntuneReusableSettingTemplate' } + Headers = @{ Authorization = 'token' } + Query = @{ ID = 'template-x' } + } + + $response = Invoke-RemoveIntuneReusableSettingTemplate -Request $request -TriggerMetadata $null + + $response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::OK) + $response.Body.Results | Should -Match 'Removed Intune reusable setting template with ID template-x' + $lastRemoved.RowKey | Should -Be 'template-x' + $lastForce | Should -BeTrue + $logs | Should -Not -BeNullOrEmpty + } + + It 'returns InternalServerError when ID is missing' { + $request = [pscustomobject]@{ Params = @{}; Query = @{}; Body = [pscustomobject]@{} } + + $response = Invoke-RemoveIntuneReusableSettingTemplate -Request $request -TriggerMetadata $null + + $response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::InternalServerError) + $response.Body.Results | Should -Match 'You must supply an ID' + } +} diff --git a/Tests/Standards/Invoke-CIPPStandardReusableSettingsTemplate.Tests.ps1 b/Tests/Standards/Invoke-CIPPStandardReusableSettingsTemplate.Tests.ps1 new file mode 100644 index 000000000000..9777672690c5 --- /dev/null +++ b/Tests/Standards/Invoke-CIPPStandardReusableSettingsTemplate.Tests.ps1 @@ -0,0 +1,152 @@ +# Pester tests for Invoke-CIPPStandardReusableSettingsTemplate +# Validates licensing guard, remediation flows, alerting, and reporting + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $StandardPath = Join-Path $RepoRoot 'Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardReusableSettingsTemplate.ps1' + + function Test-CIPPStandardLicense { param($StandardName, $TenantFilter, $RequiredCapabilities) } + function Get-CippTable { param($tablename) } + function New-GraphGETRequest { param($uri, $tenantid) } + function Get-CippAzDataTableEntity { param($Table, $Filter) } + function Compare-CIPPIntuneObject { param($ReferenceObject, $DifferenceObject, $compareType) } + function New-GraphPOSTRequest { param($uri, $tenantid, $type, $body) } + function Write-LogMessage { param($API, $tenant, $message, $sev) } + function Write-StandardsAlert { param($message, $object, $tenant, $standardName, $standardId) } + function Set-CIPPStandardsCompareField { param($FieldName, $FieldValue, $TenantFilter) } + function Get-NormalizedError { param($Message) $Message } + + . $StandardPath +} + +Describe 'Invoke-CIPPStandardReusableSettingsTemplate' { + $tenant = 'contoso.onmicrosoft.com' + + BeforeEach { + $script:compareFields = @() + $script:alerts = @() + $script:logs = @() + $script:updateCalls = 0 + $script:createCalls = 0 + + Mock -CommandName Test-CIPPStandardLicense -MockWith { $true } + Mock -CommandName Get-CippTable -MockWith { @{ Table = 'templates' } } + Mock -CommandName New-GraphGETRequest -MockWith { @() } + Mock -CommandName Get-CippAzDataTableEntity -MockWith { + @([pscustomobject]@{ + RowKey = 'template-existing' + JSON = '{"DisplayName":"Reusable A","RawJSON":"{\"displayName\":\"Reusable A\"}"}' + RawJSON = '{"displayName":"Reusable A"}' + DisplayName = 'Reusable A' + }) + } + Mock -CommandName Compare-CIPPIntuneObject -MockWith { $null } + Mock -CommandName New-GraphPOSTRequest -MockWith { + param($uri, $tenantid, $type, $body) + if ($type -eq 'PUT') { $script:updateCalls++ } else { $script:createCalls++ } + } + Mock -CommandName Write-LogMessage -MockWith { + param($API, $tenant, $message, $sev) + $script:logs += @{ Message = $message; Sev = $sev } + } + Mock -CommandName Write-StandardsAlert -MockWith { + param($message, $object, $tenant, $standardName, $standardId) + $script:alerts += @{ Message = $message; Object = $object; Standard = $standardName; Id = $standardId } + } + Mock -CommandName Set-CIPPStandardsCompareField -MockWith { + param($FieldName, $FieldValue, $TenantFilter) + $script:compareFields += @{ Field = $FieldName; Value = $FieldValue; Tenant = $TenantFilter } + } + } + + It 'sets compare fields and exits when license requirement fails' { + Mock -CommandName Test-CIPPStandardLicense -MockWith { $false } + + $settings = @( + [pscustomobject]@{ TemplateList = [pscustomobject]@{ value = 'template-one' } }, + [pscustomobject]@{ TemplateList = [pscustomobject]@{ value = 'template-two' } } + ) + + $result = Invoke-CIPPStandardReusableSettingsTemplate -Tenant $tenant -Settings $settings + + $result | Should -BeTrue + $compareFields.Field | Should -Contain 'standards.ReusableSettingsTemplate.template-one' + $compareFields.Field | Should -Contain 'standards.ReusableSettingsTemplate.template-two' + Should -Invoke Get-CippAzDataTableEntity -Times 0 + Should -Invoke New-GraphGETRequest -Times 0 + } + + It 'creates missing reusable settings when remediate is enabled' { + Mock -CommandName Get-CippAzDataTableEntity -MockWith { + @([pscustomobject]@{ + RowKey = 'template-create' + JSON = '{"DisplayName":"Reusable Create","RawJSON":"{\"displayName\":\"Reusable Create\"}"}' + RawJSON = '{"displayName":"Reusable Create"}' + DisplayName = 'Reusable Create' + }) + } + + $settings = @( + [pscustomobject]@{ TemplateList = [pscustomobject]@{ value = 'template-create' }; remediate = $true; alert = $false; report = $false } + ) + + Invoke-CIPPStandardReusableSettingsTemplate -Tenant $tenant -Settings $settings + + $createCalls | Should -Be 1 + Should -Invoke New-GraphPOSTRequest -ParameterFilter { $type -eq 'POST' -and $uri -like '*reusablePolicySettings' } -Times 1 + $compareFields | Should -BeNullOrEmpty + } + + It 'updates existing reusable settings when a mismatch is found' { + Mock -CommandName New-GraphGETRequest -MockWith { + @([pscustomobject]@{ id = 'existing-1'; displayName = 'Reusable A'; version = 1 }) + } + Mock -CommandName Compare-CIPPIntuneObject -MockWith { [pscustomobject]@{ Difference = 'changed' } } + + $settings = @( + [pscustomobject]@{ TemplateList = [pscustomobject]@{ value = 'template-existing' }; remediate = $true; alert = $false; report = $false } + ) + + Invoke-CIPPStandardReusableSettingsTemplate -Tenant $tenant -Settings $settings + + $updateCalls | Should -Be 1 + Should -Invoke New-GraphPOSTRequest -ParameterFilter { $type -eq 'PUT' -and $uri -like '*reusablePolicySettings/existing-1' } -Times 1 + Should -Invoke New-GraphPOSTRequest -ParameterFilter { $type -eq 'POST' } -Times 0 + } + + It 'writes standards alerts when alerting is enabled and drift exists' { + Mock -CommandName New-GraphGETRequest -MockWith { + @([pscustomobject]@{ id = 'existing-2'; displayName = 'Reusable Alert' }) + } + Mock -CommandName Compare-CIPPIntuneObject -MockWith { @{ Difference = 'drift' } } + + $settings = @( + [pscustomobject]@{ TemplateList = [pscustomobject]@{ value = 'template-existing' }; remediate = $false; alert = $true; report = $false } + ) + + Invoke-CIPPStandardReusableSettingsTemplate -Tenant $tenant -Settings $settings + + $alerts | Should -HaveCount 1 + $alerts[0].Message | Should -Match 'Reusable setting Reusable A does not match' + $alerts[0].Standard | Should -Be 'ReusableSettingsTemplate' + $logs.Where({ $_.Message -like '*out of compliance*' }).Count | Should -Be 1 + } + + It 'logs compliance and reports true when no differences are found' { + Mock -CommandName New-GraphGETRequest -MockWith { + @([pscustomobject]@{ id = 'existing-3'; displayName = 'Reusable A' }) + } + Mock -CommandName Compare-CIPPIntuneObject -MockWith { $null } + + $settings = @( + [pscustomobject]@{ TemplateList = [pscustomobject]@{ value = 'template-existing' }; remediate = $false; alert = $true; report = $true } + ) + + Invoke-CIPPStandardReusableSettingsTemplate -Tenant $tenant -Settings $settings + + $logs.Where({ $_.Message -like '*is compliant.*' }).Count | Should -Be 1 + $compareFields | Should -HaveCount 1 + $compareFields[0].Value | Should -BeTrue + Should -Invoke -CommandName Write-StandardsAlert -Times 0 + } +} From 8e69843ff99d358b67d9ecdf9c4a11e3a1d5bd63 Mon Sep 17 00:00:00 2001 From: Logan Cook <2997336+MWG-Logan@users.noreply.github.com> Date: Mon, 12 Jan 2026 16:00:38 -0500 Subject: [PATCH 003/150] fix(api): boost BRR on ID filtering in ListIntuneReusableSettingTemplates --- .../MEM/Invoke-ListIntuneReusableSettingTemplates.ps1 | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ListIntuneReusableSettingTemplates.ps1 b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ListIntuneReusableSettingTemplates.ps1 index 941142e43fc7..93dd3b986ce3 100644 --- a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ListIntuneReusableSettingTemplates.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ListIntuneReusableSettingTemplates.ps1 @@ -10,6 +10,12 @@ function Invoke-ListIntuneReusableSettingTemplates { $Table = Get-CippTable -tablename 'templates' $Filter = "PartitionKey eq 'IntuneReusableSettingTemplate'" + + if ($Request.query.ID) { + $EscapedId = $Request.query.ID -replace "'", "''" # escape OData quotes + $Filter = "PartitionKey eq 'IntuneReusableSettingTemplate' and RowKey eq '$EscapedId'" + } + $RawTemplates = Get-CIPPAzDataTableEntity @Table -Filter $Filter $Templates = foreach ($Item in $RawTemplates) { @@ -32,10 +38,6 @@ function Invoke-ListIntuneReusableSettingTemplates { $Templates = $Templates | Sort-Object -Property displayName - if ($Request.query.ID) { - $Templates = $Templates | Where-Object -Property GUID -EQ $Request.query.ID - } - return ([HttpResponseContext]@{ StatusCode = [System.Net.HttpStatusCode]::OK Body = @($Templates) From f88cad05854e5e8133f3bda45f0a4764947dc6cf Mon Sep 17 00:00:00 2001 From: Logan Cook <2997336+MWG-Logan@users.noreply.github.com> Date: Mon, 12 Jan 2026 16:02:53 -0500 Subject: [PATCH 004/150] fix(api): remove unnecessary initialization of Settings array --- .../Endpoint/MEM/Invoke-ListIntuneReusableSettings.ps1 | 1 - 1 file changed, 1 deletion(-) diff --git a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ListIntuneReusableSettings.ps1 b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ListIntuneReusableSettings.ps1 index 319a79b8956c..20f2712ae3c0 100644 --- a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ListIntuneReusableSettings.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ListIntuneReusableSettings.ps1 @@ -57,7 +57,6 @@ function Invoke-ListIntuneReusableSettings { $ErrorMessage = Get-CippException -Exception $_ $logMessage = "Failed to retrieve reusable policy settings: $($ErrorMessage.NormalizedError)" Write-LogMessage -headers $Headers -API $APIName -message $logMessage -Sev Error -LogData $ErrorMessage - $Settings = @() $StatusCode = [System.Net.HttpStatusCode]::InternalServerError return ([HttpResponseContext]@{ StatusCode = $StatusCode From bd391d6bdd34a968a1cbd8629adc03a0842a6337 Mon Sep 17 00:00:00 2001 From: redanthrax Date: Thu, 29 Jan 2026 08:43:06 -0800 Subject: [PATCH 005/150] fix: Hudu sync creating duplicate users and devices MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This fix resolves issue #5257 where Hudu sync was creating thousands of duplicate user and device entries. Root Cause: - The $People and $HuduDevices collections were fetched once at the start of the sync process - When new users/devices were created in Hudu during the sync, they were not added to these in-memory collections - Subsequent iterations or sync runs would not find the newly created assets in the stale collections and create them again, leading to duplicates Changes: - Converted $People and $HuduDevices from static arrays to System.Collections.Generic.List[object] for efficient mutation - Added newly created users to $People collection after creation - Added newly created devices to $HuduDevices collection after creation - This ensures the collections stay up-to-date during the sync process and prevents duplicate creation Fixes: KelvinTegelaar/CIPP#5257 💘 Generated with Crush Assisted-by: Claude Sonnet 4.5 via Crush --- .../Public/Hudu/Invoke-HuduExtensionSync.ps1 | 21 ++++++++++++------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/Modules/CippExtensions/Public/Hudu/Invoke-HuduExtensionSync.ps1 b/Modules/CippExtensions/Public/Hudu/Invoke-HuduExtensionSync.ps1 index 19b1e8c13def..fc68634b5f29 100644 --- a/Modules/CippExtensions/Public/Hudu/Invoke-HuduExtensionSync.ps1 +++ b/Modules/CippExtensions/Public/Hudu/Invoke-HuduExtensionSync.ps1 @@ -66,18 +66,19 @@ function Invoke-HuduExtensionSync { $CreateUsers = $Configuration.CreateMissingUsers $PeopleLayout = Get-HuduAssetLayouts -Id $PeopleLayoutId if ($PeopleLayout.id) { - $People = Get-HuduAssets -CompanyId $company_id -AssetLayoutId $PeopleLayout.id + $PeopleArray = Get-HuduAssets -CompanyId $company_id -AssetLayoutId $PeopleLayout.id + $People = [System.Collections.Generic.List[object]]::new($PeopleArray) } else { $CreateUsers = $false - $People = @() + $People = [System.Collections.Generic.List[object]]::new() } } else { $CreateUsers = $false - $People = @() + $People = [System.Collections.Generic.List[object]]::new() } } catch { $CreateUsers = $false - $People = @() + $People = [System.Collections.Generic.List[object]]::new() $CompanyResult.Errors.add("Company: Unable to fetch People $_") Write-Host "Hudu People - Error: $_" } @@ -91,18 +92,18 @@ function Invoke-HuduExtensionSync { $DesktopsLayout = Get-HuduAssetLayouts -Id $DeviceLayoutId if ($DesktopsLayout.id) { $HuduDesktopDevices = Get-HuduAssets -CompanyId $company_id -AssetLayoutId $DesktopsLayout.id - $HuduDevices = $HuduDesktopDevices + $HuduDevices = [System.Collections.Generic.List[object]]::new($HuduDesktopDevices) } else { $CreateDevices = $false - $HuduDevices = @() + $HuduDevices = [System.Collections.Generic.List[object]]::new() } } else { $CreateDevices = $false - $HuduDevices = @() + $HuduDevices = [System.Collections.Generic.List[object]]::new() } } catch { $CreateDevices = $false - $HuduDevices = @() + $HuduDevices = [System.Collections.Generic.List[object]]::new() $CompanyResult.Errors.add("Company: Unable to fetch Devices $_") Write-Host "Hudu Devices - Error: $_" } @@ -753,6 +754,8 @@ function Invoke-HuduExtensionSync { Hash = [string]$NewHash } Add-CIPPAzDataTableEntity @HuduAssetCache -Entity $AssetCache -Force + # Add newly created user to the People collection to prevent duplicates + $People.Add($CreateHuduUser) } } } else { @@ -997,6 +1000,8 @@ function Invoke-HuduExtensionSync { Hash = [string]$NewHash } Add-CIPPAzDataTableEntity @HuduAssetCache -Entity $AssetCache -Force + # Add newly created device to the HuduDevices collection to prevent duplicates + $HuduDevices.Add($CreateHuduDevice) $RelHuduUser = $People | Where-Object { $_.primary_mail -eq $Device.userPrincipalName -or ($_.cards.integrator_name -eq 'cw_manage' -and $_.cards.data.communicationItems.communicationType -eq 'Email' -and $_.cards.data.communicationItems.value -eq $Device.userPrincipalName) } if ($RelHuduUser) { From 64dd20564601a5d48de6aed9c26e4af5452ae78f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kristian=20Kj=C3=A6rg=C3=A5rd?= Date: Thu, 29 Jan 2026 20:12:21 +0100 Subject: [PATCH 006/150] fix: remove top to fix far too few results returning --- .../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 ed88689e9578..3d6a59a25d46 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 @@ -1,4 +1,4 @@ -Function Invoke-ListDefenderTVM { +function Invoke-ListDefenderTVM { <# .FUNCTIONALITY Entrypoint @@ -10,7 +10,7 @@ Function Invoke-ListDefenderTVM { $TenantFilter = $Request.Query.tenantFilter # Interact with query parameters or the body of the request. try { - $GraphRequest = New-GraphGetRequest -tenantid $TenantFilter -uri "https://api.securitycenter.microsoft.com/api/machines/SoftwareVulnerabilitiesByMachine?`$top=999" -scope 'https://api.securitycenter.microsoft.com/.default' | Group-Object cveId + $GraphRequest = New-GraphGetRequest -tenantid $TenantFilter -uri 'https://api.securitycenter.microsoft.com/api/machines/SoftwareVulnerabilitiesByMachine' -scope 'https://api.securitycenter.microsoft.com/.default' | Group-Object cveId $GroupObj = foreach ($cve in $GraphRequest) { # Start with base properties $obj = [ordered]@{ From ca4be7246682cfd923bc1b1acbde34d6f345223a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kristian=20Kj=C3=A6rg=C3=A5rd?= Date: Thu, 29 Jan 2026 20:14:23 +0100 Subject: [PATCH 007/150] feat: enhance vulnerability alert configuration Updated the "Vulnerabilities" alert to support multiple inputs for age, CVSS severity, and exploitability levels. This allows for more granular control over vulnerability monitoring. Adjusted the description for clarity. --- .../Alerts/Get-CIPPAlertVulnerabilities.ps1 | 45 ++++++++++++++++++- 1 file changed, 43 insertions(+), 2 deletions(-) diff --git a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertVulnerabilities.ps1 b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertVulnerabilities.ps1 index 068a039086ff..f6cb1d37f0b8 100644 --- a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertVulnerabilities.ps1 +++ b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertVulnerabilities.ps1 @@ -11,8 +11,36 @@ function Get-CIPPAlertVulnerabilities { $TenantFilter ) + # Extract filter parameters from InputValue + $ExploitabilityLevels = [System.Collections.Generic.List[string]]::new() + if ($InputValue -is [hashtable] -or $InputValue -is [PSCustomObject]) { + # Number inputs are stored directly + $AgeThresholdHours = if ($InputValue.VulnerabilityAgeHours) { [int]$InputValue.VulnerabilityAgeHours } else { 0 } + # Autocomplete inputs store value in .value subproperty + $CVSSSeverity = if ($InputValue.CVSSSeverity.value) { $InputValue.CVSSSeverity.value } else { 'low' } + # Multi-select autocomplete returns array of objects with .value + if ($InputValue.ExploitabilityLevels) { + foreach ($level in $InputValue.ExploitabilityLevels) { + $ExploitabilityLevels.Add($(if ($level.value) { $level.value } else { $level })) + } + } + } else { + # Backward compatibility: simple value = hours threshold + $AgeThresholdHours = if ($InputValue) { [int]$InputValue } else { 0 } + $CVSSSeverity = 'low' + } + + # Convert CVSS severity to minimum score + $CVSSMinScore = switch ($CVSSSeverity.ToLower()) { + 'critical' { 9.0 } + 'high' { 7.0 } + 'medium' { 4.0 } + 'low' { 0.0 } + default { 0.0 } + } + try { - $VulnerabilityRequest = New-GraphGetRequest -tenantid $TenantFilter -uri "https://api.securitycenter.microsoft.com/api/machines/SoftwareVulnerabilitiesByMachine?`$top=999&`$filter=cveId ne null" -scope 'https://api.securitycenter.microsoft.com/.default' + $VulnerabilityRequest = New-GraphGetRequest -tenantid $TenantFilter -uri 'https://api.securitycenter.microsoft.com/api/machines/SoftwareVulnerabilitiesByMachine' -scope 'https://api.securitycenter.microsoft.com/.default' if ($VulnerabilityRequest) { $AlertData = [System.Collections.Generic.List[PSCustomObject]]::new() @@ -25,10 +53,23 @@ function Get-CIPPAlertVulnerabilities { $HoursOld = [math]::Round(((Get-Date) - [datetime]$FirstVuln.firstSeenTimestamp).TotalHours) # Skip if vulnerability is not old enough - if ($HoursOld -lt [int]$InputValue) { + if ($HoursOld -lt $AgeThresholdHours) { continue } + # Skip if CVSS score is below minimum threshold + $VulnCVSS = if ($null -ne $FirstVuln.cvssScore) { [double]$FirstVuln.cvssScore } else { 0 } + if ($VulnCVSS -lt $CVSSMinScore) { + continue + } + + # Skip if exploitability level doesn't match filter (unless "All" is selected) + if ($ExploitabilityLevels.Count -gt 0 -and 'All' -notin $ExploitabilityLevels) { + if ($FirstVuln.exploitabilityLevel -notin $ExploitabilityLevels) { + continue + } + } + $DaysOld = [math]::Round(((Get-Date) - [datetime]$FirstVuln.firstSeenTimestamp).TotalDays) $AffectedDevices = ($Group.Group | Select-Object -ExpandProperty deviceName -Unique) -join ', ' From b374cbd9169326e9b6ab6d55d34c56bdc18e8795 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kristian=20Kj=C3=A6rg=C3=A5rd?= Date: Thu, 29 Jan 2026 20:59:23 +0100 Subject: [PATCH 008/150] feat: enhance incident alert with severity filtering - Updated the logic to filter incidents based on severity. - Added additional incident properties: CreatedAt, IncidentID, and IncidentUrl. --- .../Alerts/Get-CIPPAlertDefenderIncidents.ps1 | 28 ++++++++++++++----- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertDefenderIncidents.ps1 b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertDefenderIncidents.ps1 index 49be9d9a153a..398a0875ca20 100644 --- a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertDefenderIncidents.ps1 +++ b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertDefenderIncidents.ps1 @@ -11,15 +11,29 @@ function Get-CIPPAlertDefenderIncidents { $InputValue, $TenantFilter ) + + $IncidentSeverities = $InputValue.IncidentSeverities.value -as [System.Collections.Generic.List[string]] try { - $AlertData = New-GraphGetRequest -uri "https://graph.microsoft.com/v1.0/security/incidents?`$top=50&`$filter=status eq 'active'" -tenantid $TenantFilter | ForEach-Object { + $Incidents = New-GraphGetRequest -uri "https://graph.microsoft.com/v1.0/security/incidents?`$top=50&`$filter=status eq 'active'" -tenantid $TenantFilter + $AlertData = foreach ($Incident in $Incidents) { + # Skip if severity doesn't match filter (unless "All" is selected or no filter) + if ($IncidentSeverities.Count -gt 0 -and 'All' -notin $IncidentSeverities) { + if ($Incident.severity -notin $IncidentSeverities) { + continue + } + } + [PSCustomObject]@{ - IncidentID = $_.id - CreatedAt = $_.createdDateTime - Severity = $_.severity - IncidentName = $_.displayName - IncidentUrl = $_.incidentWebUrl - Tenant = $TenantFilter + IncidentName = $Incident.displayName + Severity = $Incident.severity + Classification = $Incident.classification + Determination = $Incident.determination + Summary = $Incident.summary + AssignedTo = $Incident.assignedTo + CreatedAt = $Incident.createdDateTime + IncidentID = $Incident.id + IncidentUrl = $Incident.incidentWebUrl + Tenant = $TenantFilter } } Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData From 7c2386722181424835ccd98651995f969290e802 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kristian=20Kj=C3=A6rg=C3=A5rd?= Date: Thu, 29 Jan 2026 21:42:13 +0100 Subject: [PATCH 009/150] feat: add DefenderAlerts with severity filtering options Added a new alert configuration for DefenderAlerts that includes a recommended run interval of 4 hours and allows users to filter alerts by severity. The input options include All Severities, High, Medium, Low, and Informational. --- .../Alerts/Get-CIPPAlertDefenderAlerts.ps1 | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 Modules/CIPPCore/Public/Alerts/Get-CIPPAlertDefenderAlerts.ps1 diff --git a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertDefenderAlerts.ps1 b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertDefenderAlerts.ps1 new file mode 100644 index 000000000000..76a42423395b --- /dev/null +++ b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertDefenderAlerts.ps1 @@ -0,0 +1,58 @@ + +function Get-CIPPAlertDefenderAlerts { + <# + .FUNCTIONALITY + Entrypoint + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $false)] + [Alias('input')] + $InputValue, + $TenantFilter + ) + + $AlertSeverities = $InputValue.AlertSeverities.value -as [System.Collections.Generic.List[string]] + try { + $DefenderAlerts = New-GraphGetRequest -uri "https://graph.microsoft.com/v1.0/security/alerts_v2?`$top=50&`$filter=status eq 'new'" -tenantid $TenantFilter + $AlertData = foreach ($Alert in $DefenderAlerts) { + # Skip if severity doesn't match filter (unless "All" is selected or no filter) + if ($AlertSeverities.Count -gt 0 -and 'All' -notin $AlertSeverities) { + if ($Alert.severity -notin $AlertSeverities) { + continue + } + } + + [PSCustomObject]@{ + Title = $Alert.title + Description = $Alert.description + Severity = $Alert.severity + Category = $Alert.category + ServiceSource = $Alert.serviceSource + ProductName = $Alert.productName + DetectionSource = $Alert.detectionSource + Classification = $Alert.classification + Determination = $Alert.determination + ThreatDisplayName = $Alert.threatDisplayName + ThreatFamilyName = $Alert.threatFamilyName + ActorDisplayName = $Alert.actorDisplayName + MitreTechniques = ($Alert.mitreTechniques -join ', ') + AssignedTo = $Alert.assignedTo + FirstActivityDateTime = $Alert.firstActivityDateTime + LastActivityDateTime = $Alert.lastActivityDateTime + CreatedAt = $Alert.createdDateTime + RecommendedActions = $Alert.recommendedActions + AlertID = $Alert.id + IncidentID = $Alert.incidentId + AlertUrl = $Alert.alertWebUrl + IncidentUrl = $Alert.incidentWebUrl + Tenant = $TenantFilter + } + } + Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData + + } catch { + # Commented out due to potential licensing spam + # Write-AlertMessage -tenant $($TenantFilter) -message "Could not get Defender alerts for $($TenantFilter): $(Get-NormalizedError -message $_.Exception.message)" + } +} From 43db5a9adc7560437a475e725c6b791a88d4f329 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kristian=20Kj=C3=A6rg=C3=A5rd?= Date: Sat, 31 Jan 2026 01:16:58 +0100 Subject: [PATCH 010/150] fix: GET to POST for domain analyser --- .../CIPP/Settings/Invoke-ExecDnsConfig.ps1 | 34 +++++++++++-------- .../Public/Get-CIPPDomainAnalyser.ps1 | 13 ++++--- 2 files changed, 29 insertions(+), 18 deletions(-) diff --git a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecDnsConfig.ps1 b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecDnsConfig.ps1 index 6ad0becf4381..04440b5926f7 100644 --- a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecDnsConfig.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecDnsConfig.ps1 @@ -9,6 +9,7 @@ function Invoke-ExecDnsConfig { param($Request, $TriggerMetadata) $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers # List of supported resolvers $ValidResolvers = @( 'Google' @@ -16,8 +17,11 @@ function Invoke-ExecDnsConfig { ) - $StatusCode = [HttpStatusCode]::OK + $Action = $Request.Query.Action ?? $Request.Body.Action + $Domain = $Request.Query.Domain ?? $Request.Body.Domain + $Resolver = $Request.Query.Resolver ?? $Request.Body.Resolver + $Selector = $Request.Query.Selector ?? $Request.Body.Selector try { $ConfigTable = Get-CippTable -tablename Config $Filter = "PartitionKey eq 'Domains' and RowKey eq 'Domains'" @@ -36,10 +40,9 @@ function Invoke-ExecDnsConfig { $updated = $false - switch ($Request.Query.Action) { + switch ($Action) { 'SetConfig' { - if ($Request.Body.Resolver) { - $Resolver = $Request.Body.Resolver + if ($Resolver) { if ($ValidResolvers -contains $Resolver) { try { $Config.Resolver = $Resolver @@ -53,7 +56,7 @@ function Invoke-ExecDnsConfig { } if ($updated) { Add-CIPPAzDataTableEntity @ConfigTable -Entity $Config -Force - Write-LogMessage -API $APINAME -tenant 'Global' -headers $Request.Headers -message 'DNS configuration updated' -Sev 'Info' + Write-LogMessage -API $APIName -tenant 'Global' -headers $Headers -message 'DNS configuration updated' -Sev 'Info' $body = [pscustomobject]@{'Results' = 'Success: DNS configuration updated.' } } else { $StatusCode = [HttpStatusCode]::BadRequest @@ -61,8 +64,8 @@ function Invoke-ExecDnsConfig { } } 'SetDkimConfig' { - $Domain = $Request.Query.Domain - $Selector = ($Request.Query.Selector).trim() -split '\s*,\s*' + $Domain = $Domain + $Selector = ($Selector).trim() -split '\s*,\s*' $DomainTable = Get-CIPPTable -Table 'Domains' $Filter = "RowKey eq '{0}'" -f $Domain $DomainInfo = Get-CIPPAzDataTableEntity @DomainTable -Filter $Filter @@ -71,7 +74,7 @@ function Invoke-ExecDnsConfig { $DomainInfo.DkimSelectors = $DkimSelectors } else { $DomainInfo = @{ - 'RowKey' = $Request.Query.Domain + 'RowKey' = $Domain 'PartitionKey' = 'ManualEntry' 'TenantId' = 'NoTenant' 'MailProviders' = '' @@ -81,22 +84,25 @@ function Invoke-ExecDnsConfig { } } Add-CIPPAzDataTableEntity @DomainTable -Entity $DomainInfo -Force + Write-LogMessage -API $APIName -tenant 'Global' -headers $Headers -message "Updated DKIM selectors for domain: $Domain - Selectors: $($Selector -join ', ')" -Sev 'Info' + $body = [pscustomobject]@{ 'Results' = "Success: DKIM selectors updated for $Domain. Selectors: $($Selector -join ', ')" } } 'GetConfig' { $body = [pscustomobject]$Config - Write-LogMessage -API $APINAME -tenant 'Global' -headers $Request.Headers -message 'Retrieved DNS configuration' -Sev 'Debug' + Write-LogMessage -API $APIName -tenant 'Global' -headers $Headers -message 'Retrieved DNS configuration' -Sev 'Debug' } 'RemoveDomain' { - $Filter = "RowKey eq '{0}'" -f $Request.Query.Domain + $Filter = "RowKey eq '{0}'" -f $Domain $DomainRow = Get-CIPPAzDataTableEntity @DomainTable -Filter $Filter -Property PartitionKey, RowKey Remove-AzDataTableEntity -Force @DomainTable -Entity $DomainRow - Write-LogMessage -API $APINAME -tenant 'Global' -headers $Request.Headers -message "Removed Domain - $($Request.Query.Domain) " -Sev 'Info' - $body = [pscustomobject]@{ 'Results' = "Domain removed - $($Request.Query.Domain)" } + Write-LogMessage -API $APIName -tenant 'Global' -headers $Headers -message "Removed Domain - $Domain " -Sev 'Info' + $body = [pscustomobject]@{ 'Results' = "Domain removed - $Domain" } } } } catch { - Write-LogMessage -API $APINAME -tenant $($name) -headers $Request.Headers -message "DNS Config API failed. $($_.Exception.Message)" -Sev 'Error' - $body = [pscustomobject]@{'Results' = "Failed. $($_.Exception.Message)" } + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API $APIName -tenant $($name) -headers $Headers -message "DNS Config API failed. $($ErrorMessage.NormalizedError)" -Sev 'Error' -LogData $ErrorMessage + $body = [pscustomobject]@{'Results' = "Failed. $($ErrorMessage.NormalizedError)" } $StatusCode = [HttpStatusCode]::BadRequest } diff --git a/Modules/CIPPCore/Public/Get-CIPPDomainAnalyser.ps1 b/Modules/CIPPCore/Public/Get-CIPPDomainAnalyser.ps1 index d201d8a5a41f..e1aed77dbd55 100644 --- a/Modules/CIPPCore/Public/Get-CIPPDomainAnalyser.ps1 +++ b/Modules/CIPPCore/Public/Get-CIPPDomainAnalyser.ps1 @@ -26,11 +26,16 @@ function Get-CIPPDomainAnalyser { } $Domains = Get-CIPPAzDataTableEntity @DomainTable | Where-Object { $_.TenantGUID -in $Tenants.customerId -or $TenantFilter -eq $_.TenantGUID } try { - # Extract json from table results - $Results = foreach ($DomainAnalyserResult in ($Domains).DomainAnalyser) { + # Extract json from table results and merge with DkimSelectors from the domain entity + $Results = foreach ($Domain in $Domains) { try { - if (![string]::IsNullOrEmpty($DomainAnalyserResult)) { - $Object = $DomainAnalyserResult | ConvertFrom-Json -ErrorAction SilentlyContinue + if (![string]::IsNullOrEmpty($Domain.DomainAnalyser)) { + $Object = $Domain.DomainAnalyser | ConvertFrom-Json -ErrorAction SilentlyContinue + # Add DkimSelectors from the domain entity if available + if (![string]::IsNullOrEmpty($Domain.DkimSelectors)) { + $Selectors = $Domain.DkimSelectors | ConvertFrom-Json -ErrorAction SilentlyContinue + $Object | Add-Member -NotePropertyName 'DkimSelectors' -NotePropertyValue ($Selectors) -Force + } $Object } } catch {} From 3ae02bb45d69077a7c777be74a99070e0b2a6ea8 Mon Sep 17 00:00:00 2001 From: Phillip Schjeldal Hansen Date: Sun, 1 Feb 2026 22:14:12 +0100 Subject: [PATCH 011/150] Fixed reporting for standard RestrictThirdPartyStorageServices --- .../Invoke-CIPPStandardRestrictThirdPartyStorageServices.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardRestrictThirdPartyStorageServices.ps1 b/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardRestrictThirdPartyStorageServices.ps1 index 517045ae0169..7d6811020b46 100644 --- a/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardRestrictThirdPartyStorageServices.ps1 +++ b/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardRestrictThirdPartyStorageServices.ps1 @@ -90,7 +90,7 @@ function Invoke-CIPPStandardRestrictThirdPartyStorageServices { thirdPartyStorageRestricted = $CurrentState.accountEnabled -eq $false } $ExpectedValue = @{ - thirdPartyStorageRestricted = $false + thirdPartyStorageRestricted = $true } Set-CIPPStandardsCompareField -FieldName 'standards.RestrictThirdPartyStorageServices' -CurrentValue $CurrentValue -ExpectedValue $ExpectedValue -TenantFilter $Tenant From b70e851454bc8552236842d9fc3c882b49988cee Mon Sep 17 00:00:00 2001 From: Phillip Schjeldal Hansen Date: Mon, 2 Feb 2026 01:24:30 +0100 Subject: [PATCH 012/150] Added Report to DisableSelfServiceLicenses and refactored remidate to minimize requests --- ...CIPPStandardDisableSelfServiceLicenses.ps1 | 71 ++++++++++--------- 1 file changed, 38 insertions(+), 33 deletions(-) diff --git a/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardDisableSelfServiceLicenses.ps1 b/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardDisableSelfServiceLicenses.ps1 index f06f7e0091f0..7a242e22ae79 100644 --- a/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardDisableSelfServiceLicenses.ps1 +++ b/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardDisableSelfServiceLicenses.ps1 @@ -45,50 +45,52 @@ function Invoke-CIPPStandardDisableSelfServiceLicenses { throw $Message } - if ($settings.remediate) { - if ($settings.exclusions -like '*;*') { - $exclusions = $settings.Exclusions -split (';') - } else { - $exclusions = $settings.Exclusions -split (',') + + if ($settings.exclusions -like '*;*') { + $exclusions = $settings.Exclusions -split (';') + } else { + $exclusions = $settings.Exclusions -split (',') + } + + $ExpectedValues = [System.Collections.Generic.List[PSCustomObject]]::new() + foreach ($Item in $selfServiceItems) { + if ($Item.productId -in $exclusions) { + $Item.policyValue = "Enabled" + $ExpectedValues.add(($Item | Select-Object -Property productName, productId, policyValue)) + Write-LogMessage -API 'Standards' -tenant $Tenant -message "Exclusion present for self-service license '$($Item.productName) - $($Item.productId)'" } + else { + $Item.policyValue = "Disabled" + $ExpectedValues.add(($Item | Select-Object -Property productName, productId, policyValue)) + } + } - foreach ($Item in $selfServiceItems) { - $body = $null + $CurrentValues = $selfServiceItems | Select-Object -Property productName, productId, policyValue - if ($Item.policyValue -eq 'Enabled' -AND ($Item.productId -in $exclusions)) { - # Self service is enabled on product and productId is in exclusions, skip - } - if ($Item.policyValue -eq 'Disabled' -AND ($Item.productId -in $exclusions)) { - # Self service is disabled on product and productId is in exclusions, enable - $body = '{ "policyValue": "Enabled" }' - } - if ($Item.policyValue -eq 'Enabled' -AND ($Item.productId -notin $exclusions)) { - # Self service is enabled on product and productId is NOT in exclusions, disable - $body = '{ "policyValue": "Disabled" }' - } - if ($Item.policyValue -eq 'Disabled' -AND ($Item.productId -notin $exclusions)) { - # Self service is disabled on product and productId is NOT in exclusions, skip - } + if ($settings.remediate) { + $Compare = Compare-Object -ReferenceObject $ExpectedValues -DifferenceObject $CurrentValues -Property productName, productId, policyValue - try { - if ($body) { + if (!$Compare) { + Write-LogMessage -API 'Standards' -tenant $Tenant -message 'self service licenses are already set correctly.' -sev Info + } + else { + $NeedsUpdate = $Compare | Where-Object {$_.SideIndicator -eq "<="} + foreach ($Item in $NeedsUpdate) { + try { + $body = @{policyValue=$Item.policyValue} | ConvertTo-Json -Compress New-GraphPOSTRequest -scope 'aeb86249-8ea3-49e2-900b-54cc8e308f85/.default' -uri "https://licensing.m365.microsoft.com/v1.0/policies/AllowSelfServicePurchase/products/$($Item.productId)" -tenantid $Tenant -body $body -type PUT + Write-LogMessage -API 'Standards' -tenant $tenant -message "Changed Self Service status for product '$($Item.productName) - $($Item.productId)' to '$($Item.policyValue)'" + } catch { + Write-LogMessage -API 'Standards' -tenant $tenant -message "Failed to set product status for '$($Item.productName) - $($Item.productId)' with body $($body) for reason: $($_.Exception.Message)" -sev Error } - } catch { - Write-LogMessage -API 'Standards' -tenant $tenant -message "Failed to set product status for $($Item.productId) with body $($body) for reason: $($_.Exception.Message)" -sev Error - #Write-Error "Failed to disable product $($Item.productName):$($_.Exception.Message)" } } - if (!$exclusions) { - Write-LogMessage -API 'Standards' -tenant $Tenant -message 'No exclusions set for self-service licenses, disabled all not excluded licenses for self-service.' -sev Info - } else { - Write-LogMessage -API 'Standards' -tenant $Tenant -message 'Exclusions present for self-service licenses, disabled all not excluded licenses for self-service.' -sev Info - } + $CurrentValues = (New-GraphGETRequest -scope 'aeb86249-8ea3-49e2-900b-54cc8e308f85/.default' -uri 'https://licensing.m365.microsoft.com/v1.0/policies/AllowSelfServicePurchase/products' -tenantid $Tenant).items | Select-Object -Property productName, productId, policyValue } if ($Settings.alert) { - $selfServiceItemsToAlert = $selfServiceItems | Where-Object { $_.policyValue -eq 'Enabled' } + $selfServiceItemsToAlert = $CurrentValues | Where-Object { $_.policyValue -eq 'Enabled' } if (!$selfServiceItemsToAlert) { Write-LogMessage -API 'Standards' -tenant $tenant -message 'All self-service licenses are disabled' -sev Info } else { @@ -98,6 +100,9 @@ function Invoke-CIPPStandardDisableSelfServiceLicenses { } if ($Settings.report -eq $true) { - #Add-CIPPBPAField -FieldName '????' -FieldValue "????" -StoreAs bool -Tenant $tenant + $StateIsCorrect = !(Compare-Object -ReferenceObject $ExpectedValues -DifferenceObject $CurrentValues -Property productName, productId, policyValue) + + Set-CIPPStandardsCompareField -FieldName 'standards.DisableSelfServiceLicenses' -CurrentValue $CurrentValues -ExpectedValue $ExpectedValues -TenantFilter $Tenant + Add-CIPPBPAField -FieldName 'DisableSelfServiceLicenses' -FieldValue $StateIsCorrect -StoreAs bool -Tenant $Tenant } } From 546eb1451a6231d4dcb97638adeb17f8a40f8eff Mon Sep 17 00:00:00 2001 From: Phillip Schjeldal Hansen Date: Mon, 2 Feb 2026 12:07:41 +0100 Subject: [PATCH 013/150] StandardDisableSelfServiceLicenses Changed presentation of report values --- ...CIPPStandardDisableSelfServiceLicenses.ps1 | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardDisableSelfServiceLicenses.ps1 b/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardDisableSelfServiceLicenses.ps1 index 7a242e22ae79..03845d7a4d03 100644 --- a/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardDisableSelfServiceLicenses.ps1 +++ b/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardDisableSelfServiceLicenses.ps1 @@ -102,7 +102,25 @@ function Invoke-CIPPStandardDisableSelfServiceLicenses { if ($Settings.report -eq $true) { $StateIsCorrect = !(Compare-Object -ReferenceObject $ExpectedValues -DifferenceObject $CurrentValues -Property productName, productId, policyValue) - Set-CIPPStandardsCompareField -FieldName 'standards.DisableSelfServiceLicenses' -CurrentValue $CurrentValues -ExpectedValue $ExpectedValues -TenantFilter $Tenant + $ExpectedValuesHash = @{} + foreach ($Item in $ExpectedValues) { + $ExpectedValuesHash[$Item.productName] = [PSCustomObject]@{ + Id = $Item.productId + Value = $Item.policyValue + } + } + $ExpectedValue = [PSCustomObject]$ExpectedValuesHash + + $CurrentValuesHash = @{} + foreach ($Item in $CurrentValues) { + $CurrentValuesHash[$Item.productName] = [PSCustomObject]@{ + Id = $Item.productId + Value = $Item.policyValue + } + } + $CurrentValue = [PSCustomObject]$CurrentValuesHash + + Set-CIPPStandardsCompareField -FieldName 'standards.DisableSelfServiceLicenses' -CurrentValue $CurrentValue -ExpectedValue $ExpectedValue -TenantFilter $Tenant Add-CIPPBPAField -FieldName 'DisableSelfServiceLicenses' -FieldValue $StateIsCorrect -StoreAs bool -Tenant $Tenant } } From 5fa89888094613ec5df89de28754efd192b397f4 Mon Sep 17 00:00:00 2001 From: James Tarran Date: Wed, 4 Feb 2026 15:01:50 +0000 Subject: [PATCH 014/150] Update Get-CIPPAlertSmtpAuthSuccess.ps1 Remove .value from $signins as this is done is new-GraphGetRequest already so $SignIns.value.value is null --- Modules/CIPPCore/Public/Alerts/Get-CIPPAlertSmtpAuthSuccess.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertSmtpAuthSuccess.ps1 b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertSmtpAuthSuccess.ps1 index c7c8e57f4ea6..f8e4b0f9b809 100644 --- a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertSmtpAuthSuccess.ps1 +++ b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertSmtpAuthSuccess.ps1 @@ -19,7 +19,7 @@ function Get-CIPPAlertSmtpAuthSuccess { $SignIns = New-GraphGetRequest -uri $uri -tenantid $TenantFilter # Select only the properties you care about - $AlertData = $SignIns.value | Select-Object userPrincipalName, createdDateTime, clientAppUsed, ipAddress, status, @{Name = 'Tenant'; Expression = { $TenantFilter } } + $AlertData = $SignIns | Select-Object userPrincipalName, createdDateTime, clientAppUsed, ipAddress, status, @{Name = 'Tenant'; Expression = { $TenantFilter } } # Write results into the alert pipeline Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData From 637688067f1c6b9bc34373af63588e2710d9233a Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Thu, 5 Feb 2026 04:37:49 -0800 Subject: [PATCH 015/150] Licence management granular control and optimisations Optimise getting users licences, making bulk graph requests making it much faster when updating more than 1 licence. --- .../Users/Invoke-ExecBulkLicense.ps1 | 91 ++++++--- .../CIPPCore/Public/Set-CIPPUserLicense.ps1 | 182 ++++++++++++++---- 2 files changed, 209 insertions(+), 64 deletions(-) diff --git a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ExecBulkLicense.ps1 b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ExecBulkLicense.ps1 index 69b8b3f169ff..58c4446912ba 100644 --- a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ExecBulkLicense.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ExecBulkLicense.ps1 @@ -23,7 +23,21 @@ function Invoke-ExecBulkLicense { foreach ($TenantGroup in $TenantGroups) { $TenantFilter = $TenantGroup.Name $TenantRequests = $TenantGroup.Group - $AllUsers = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/users?&`$select=id,userPrincipalName,assignedLicenses" -tenantid $TenantFilter + + # Initialize list for bulk license requests + $LicenseRequests = [System.Collections.Generic.List[object]]::new() + + # Get unique user IDs for this tenant + $UserIds = $TenantRequests.userIds | Select-Object -Unique + + # Build OData filter for specific users only + $UserIdFilters = $UserIds | ForEach-Object { "id eq '$_'" } + $FilterQuery = $UserIdFilters -join ' or ' + + # Fetch only the users we need with server-side filtering + $AllUsers = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/users?`$filter=$FilterQuery&`$select=id,userPrincipalName,assignedLicenses&top=999" -tenantid $TenantFilter + + # Create lookup for quick access $UserLookup = @{} foreach ($User in $AllUsers) { $UserLookup[$User.id] = $User @@ -31,37 +45,64 @@ function Invoke-ExecBulkLicense { # Process each user request foreach ($UserRequest in $TenantRequests) { - try { - $UserId = $UserRequest.userIds - $User = $UserLookup[$UserId] - $UserPrincipalName = $User.userPrincipalName - $LicenseOperation = $UserRequest.LicenseOperation - $RemoveAllLicenses = [bool]$UserRequest.RemoveAllLicenses - $Licenses = $UserRequest.Licenses | ForEach-Object { $_.value } - # Handle license operations - if ($LicenseOperation -eq 'Add' -or $LicenseOperation -eq 'Replace') { - $AddLicenses = $Licenses - } + $UserId = $UserRequest.userIds + $User = $UserLookup[$UserId] + $UserPrincipalName = $User.userPrincipalName + $LicenseOperation = $UserRequest.LicenseOperation + $RemoveAllLicenses = [bool]$UserRequest.RemoveAllLicenses + $ReplaceAllLicenses = [bool]$UserRequest.ReplaceAllLicenses + $Licenses = $UserRequest.Licenses | ForEach-Object { $_.value } + $LicensesToRemove = $UserRequest.LicensesToRemove | ForEach-Object { $_.value } + $LicensesToReplace = $UserRequest.LicensesToReplace | ForEach-Object { $_.value } - if ($LicenseOperation -eq 'Remove' -and $RemoveAllLicenses) { + # Handle license operations + if ($LicenseOperation -eq 'Add') { + $AddLicenses = $Licenses + $RemoveLicenses = @() + } elseif ($LicenseOperation -eq 'Remove') { + if ($RemoveAllLicenses) { $RemoveLicenses = $User.assignedLicenses.skuId - } elseif ($LicenseOperation -eq 'Remove') { - $RemoveLicenses = $Licenses - } elseif ($LicenseOperation -eq 'Replace') { - $RemoveReplace = $User.assignedLicenses.skuId - if ($RemoveReplace) { Set-CIPPUserLicense -UserId $UserId -TenantFilter $TenantFilter -RemoveLicenses $RemoveReplace -APIName $APIName -Headers $Headers } - } elseif ($RemoveAllLicenses) { + } else { + # Only remove licenses the user actually has + $RemoveLicenses = $LicensesToRemove | Where-Object { $_ -in $User.assignedLicenses.skuId } + } + $AddLicenses = @() + } elseif ($LicenseOperation -eq 'Replace') { + $AddLicenses = $Licenses + if ($ReplaceAllLicenses) { + # Replace all existing licenses with new ones $RemoveLicenses = $User.assignedLicenses.skuId + } else { + # Only replace licenses the user actually has + $RemoveLicenses = $LicensesToReplace | Where-Object { $_ -in $User.assignedLicenses.skuId } } - #todo: Actually build bulk support into set-cippuserlicense. - $TaskResults = Set-CIPPUserLicense -UserId $UserId -TenantFilter $TenantFilter -AddLicenses $AddLicenses -RemoveLicenses $RemoveLicenses -APIName $APIName -Headers $Headers + } - $Results.Add($TaskResults) - Write-LogMessage -API $APIName -tenant $TenantFilter -message "Successfully processed licenses for user $UserPrincipalName" -Sev 'Info' + # Add to processing list if there are licenses to add or remove + if ($AddLicenses.Count -gt 0 -or $RemoveLicenses.Count -gt 0) { + $LicenseRequests.Add([PSCustomObject]@{ + UserId = $UserId + UserPrincipalName = $UserPrincipalName + AddLicenses = $AddLicenses + RemoveLicenses = $RemoveLicenses + IsReplace = ($LicenseOperation -eq 'Replace' -and $ReplaceAllLicenses) + }) + } else { + $Results.Add("No license changes needed for user $UserPrincipalName") + } + } + + # Process all license changes in bulk + if ($LicenseRequests.Count -gt 0) { + try { + $BulkResults = Set-CIPPUserLicense -LicenseRequests $LicenseRequests -TenantFilter $TenantFilter -APIName $APIName -Headers $Headers + foreach ($Result in $BulkResults) { + $Results.Add($Result) + } } catch { $ErrorMessage = Get-CippException -Exception $_ - $Results.Add("Failed to process licenses for user $($UserRequest.userIds). Error: $($ErrorMessage.NormalizedError)") - Write-LogMessage -API $APIName -tenant $TenantFilter -message "Failed to process licenses for user $($UserRequest.userIds). Error: $($ErrorMessage.NormalizedError)" -Sev 'Error' -LogData $ErrorMessage + $Results.Add("Failed to process bulk license operation for tenant $TenantFilter. Error: $($ErrorMessage.NormalizedError)") + Write-LogMessage -API $APIName -tenant $TenantFilter -message "Failed to process bulk license operation. Error: $($ErrorMessage.NormalizedError)" -Sev 'Error' -LogData $ErrorMessage } } } diff --git a/Modules/CIPPCore/Public/Set-CIPPUserLicense.ps1 b/Modules/CIPPCore/Public/Set-CIPPUserLicense.ps1 index e72fb7b69701..587728681c63 100644 --- a/Modules/CIPPCore/Public/Set-CIPPUserLicense.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPUserLicense.ps1 @@ -1,59 +1,163 @@ function Set-CIPPUserLicense { [CmdletBinding()] param ( - [Parameter(Mandatory)][string]$UserId, + [Parameter(ParameterSetName = 'Single', Mandatory)][string]$UserId, + [Parameter(ParameterSetName = 'Single')][string]$UserPrincipalName, + [Parameter(ParameterSetName = 'Single')][array]$AddLicenses = @(), + [Parameter(ParameterSetName = 'Single')][array]$RemoveLicenses = @(), + [Parameter(ParameterSetName = 'Bulk', Mandatory)][System.Collections.Generic.List[object]]$LicenseRequests, [Parameter(Mandatory)][string]$TenantFilter, - [Parameter()][array]$AddLicenses = @(), - [Parameter()][array]$RemoveLicenses = @(), $Headers, $APIName = 'Set User License' ) - # Build the addLicenses array - $AddLicensesArray = foreach ($license in $AddLicenses) { + # Handle single user request (legacy support) + if ($PSCmdlet.ParameterSetName -eq 'Single') { + $LicenseRequests = [System.Collections.Generic.List[object]]::new() + $LicenseRequests.Add([PSCustomObject]@{ + UserId = $UserId + UserPrincipalName = $UserPrincipalName + AddLicenses = $AddLicenses + RemoveLicenses = $RemoveLicenses + IsReplace = $false + }) + } + + $Results = [System.Collections.Generic.List[string]]::new() + + # Get default usage location once for all users + $Table = Get-CippTable -tablename 'UserSettings' + $UserSettings = Get-CIPPAzDataTableEntity @Table -Filter "PartitionKey eq 'UserSettings' and RowKey eq 'allUsers'" + if ($UserSettings) { $DefaultUsageLocation = (ConvertFrom-Json $UserSettings.JSON -Depth 5 -ErrorAction SilentlyContinue).usageLocation.value } + $DefaultUsageLocation ??= 'US' + + # Process Replace operations first (remove all licenses) + $ReplaceRequests = $LicenseRequests | Where-Object { $_.IsReplace -and $_.RemoveLicenses.Count -gt 0 } + if ($ReplaceRequests.Count -gt 0) { + $RemoveBulkRequests = foreach ($Request in $ReplaceRequests) { + @{ + id = $Request.UserId + method = 'POST' + url = "/users/$($Request.UserId)/assignLicense" + body = @{ + 'addLicenses' = @() + 'removeLicenses' = @($Request.RemoveLicenses) + } + headers = @{ 'Content-Type' = 'application/json' } + } + } + + $RemoveResults = New-GraphBulkRequest -tenantid $TenantFilter -Requests @($RemoveBulkRequests) + + foreach ($Result in $RemoveResults) { + $Request = $ReplaceRequests | Where-Object { $_.UserId -eq $Result.id } + if ($Result.status -ge 200 -and $Result.status -le 299) { + Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter -message "Removed existing licenses for user $($Request.UserPrincipalName)" -Sev 'Info' + } else { + $Results.Add("Failed to remove licenses for user $($Request.UserPrincipalName): $($Result.body.error.message)") + Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter -message "Failed to remove licenses for user $($Request.UserPrincipalName): $($Result.body.error.message)" -Sev 'Error' + } + } + } + + # Build bulk requests for license assignment + $BulkRequests = foreach ($Request in $LicenseRequests) { + $AddLicensesArray = foreach ($license in $Request.AddLicenses) { + @{ 'disabledPlans' = @(); 'skuId' = $license } + } + @{ - 'disabledPlans' = @() - 'skuId' = $license + id = $Request.UserId + method = 'POST' + url = "/users/$($Request.UserId)/assignLicense" + body = @{ + 'addLicenses' = @($AddLicensesArray) + 'removeLicenses' = $Request.IsReplace ? @() : @($Request.RemoveLicenses) + } + headers = @{ 'Content-Type' = 'application/json' } } } - # Build the LicenseBody hashtable - $LicenseBody = @{ - 'addLicenses' = @($AddLicensesArray) - 'removeLicenses' = @($RemoveLicenses) ? @($RemoveLicenses) : @() + # Execute bulk request + $BulkResults = New-GraphBulkRequest -tenantid $TenantFilter -Requests @($BulkRequests) + + # Collect users with usage location errors + $UsageLocationErrors = [System.Collections.Generic.List[object]]::new() + + foreach ($Result in $BulkResults) { + $Request = $LicenseRequests | Where-Object { $_.UserId -eq $Result.id } + + if ($Result.status -ge 200 -and $Result.status -le 299) { + $Results.Add("Successfully set licenses for $($Request.UserPrincipalName). It may take 2–5 minutes before the changes become visible.") + Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter -message "Assigned licenses to user $($Request.UserPrincipalName). Added: $($Request.AddLicenses -join ', '); Removed: $($Request.RemoveLicenses -join ', ')" -Sev 'Info' + } elseif ($Result.body.error.message -like '*invalid usage location*' -or $Result.body.error.message -like '*UsageLocation*') { + $UsageLocationErrors.Add($Request) + } else { + $Results.Add("Failed to assign licenses for user $($Request.UserPrincipalName): $($Result.body.error.message)") + Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter -message "Failed to assign licenses for user $($Request.UserPrincipalName): $($Result.body.error.message)" -Sev 'Error' + } } - # Convert the LicenseBody to JSON - $LicenseBodyJson = ConvertTo-Json -InputObject $LicenseBody -Depth 10 -Compress - - Write-Host "License body JSON: $LicenseBodyJson" - - try { - try { - $null = New-GraphPostRequest -uri "https://graph.microsoft.com/beta/users/$UserId/assignLicense" -tenantid $TenantFilter -type POST -body $LicenseBodyJson -Verbose - } catch { - # Handle if the error is due to missing usage location - if ($_.Exception.Message -like '*invalid usage location*') { - $Table = Get-CippTable -tablename 'UserSettings' - $UserSettings = Get-CIPPAzDataTableEntity @Table -Filter "PartitionKey eq 'UserSettings' and RowKey eq 'allUsers'" - if ($UserSettings) { $DefaultUsageLocation = (ConvertFrom-Json $UserSettings.JSON -Depth 5 -ErrorAction SilentlyContinue).usageLocation.value } - $DefaultUsageLocation ??= 'US' # Fallback to US if not set - - $UsageLocationJson = ConvertTo-Json -InputObject @{'usageLocation' = $DefaultUsageLocation } -Depth 5 -Compress - $null = New-GraphPostRequest -uri "https://graph.microsoft.com/beta/users/$UserId" -tenantid $TenantFilter -type PATCH -body $UsageLocationJson -Verbose - Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter -message "Set usage location for user $UserId to $DefaultUsageLocation" -Sev 'Info' - # Retry assigning the license - $null = New-GraphPostRequest -uri "https://graph.microsoft.com/beta/users/$UserId/assignLicense" -tenantid $TenantFilter -type POST -body $LicenseBodyJson -Verbose + # Handle usage location errors + if ($UsageLocationErrors.Count -gt 0) { + # Set usage location for all users with errors + $UsageLocationRequests = foreach ($Request in $UsageLocationErrors) { + @{ + id = $Request.UserId + method = 'PATCH' + url = "/users/$($Request.UserId)" + body = @{ 'usageLocation' = $DefaultUsageLocation } + headers = @{ 'Content-Type' = 'application/json' } + } + } + + $UsageLocationResults = New-GraphBulkRequest -tenantid $TenantFilter -Requests @($UsageLocationRequests) + + # Log usage location updates + foreach ($Result in $UsageLocationResults) { + $Request = $UsageLocationErrors | Where-Object { $_.UserId -eq $Result.id } + if ($Result.status -ge 200 -and $Result.status -le 299) { + Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter -message "Set usage location for user $($Request.UserPrincipalName) to $DefaultUsageLocation" -Sev 'Info' + } + } + + # Retry license assignment for users with fixed usage location + $RetryBulkRequests = foreach ($Request in $UsageLocationErrors) { + $AddLicensesArray = foreach ($license in $Request.AddLicenses) { + @{ 'disabledPlans' = @(); 'skuId' = $license } + } + + @{ + id = $Request.UserId + method = 'POST' + url = "/users/$($Request.UserId)/assignLicense" + body = @{ + 'addLicenses' = @($AddLicensesArray) + 'removeLicenses' = $Request.IsReplace ? @() : @($Request.RemoveLicenses) + } + headers = @{ 'Content-Type' = 'application/json' } + } + } + + $RetryResults = New-GraphBulkRequest -tenantid $TenantFilter -Requests @($RetryBulkRequests) + + foreach ($Result in $RetryResults) { + $Request = $UsageLocationErrors | Where-Object { $_.UserId -eq $Result.id } + + if ($Result.status -ge 200 -and $Result.status -le 299) { + $Results.Add("Successfully set licenses for $($Request.UserPrincipalName) after setting usage location. It may take 2–5 minutes before the changes become visible.") + Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter -message "Assigned licenses to user $($Request.UserPrincipalName) after usage location fix. Added: $($Request.AddLicenses -join ', '); Removed: $($Request.RemoveLicenses -join ', ')" -Sev 'Info' } else { - throw $_ + $Results.Add("Failed to assign licenses for user $($Request.UserPrincipalName) after setting usage location: $($Result.body.error.message)") + Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter -message "Failed to assign licenses for user $($Request.UserPrincipalName) after usage location fix: $($Result.body.error.message)" -Sev 'Error' } } - } catch { - $ErrorMessage = Get-CippException -Exception $_ - Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter -message "Failed to assign the license. Error: $($ErrorMessage.NormalizedError)" -Sev Error -LogData $ErrorMessage - throw "Failed to assign the license. $($ErrorMessage.NormalizedError)" } - Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter -message "Assigned licenses to user $UserId. Added: $AddLicenses; Removed: $RemoveLicenses" -Sev 'Info' - return "Successfully set licenses for $UserId. It may take 2–5 minutes before the changes become visible." + # Return single result for legacy support, or all results for bulk + if ($PSCmdlet.ParameterSetName -eq 'Single') { + return $Results[0] + } else { + return $Results + } } From 72144629230f3f346220f6e41479631b34da08dc Mon Sep 17 00:00:00 2001 From: John Duprey Date: Thu, 5 Feb 2026 12:43:24 -0800 Subject: [PATCH 016/150] Fix nested group lookup and variable naming Accumulate nested group memberships into a typed List and use AddRange to correctly collect results from Graph queries. Rename loop variables to avoid shadowing ($RoleGroup / $ExpectedGroup) and update matching/log messages accordingly. Include nested groups in the returned Memberships so missing-group detection considers indirect membership; preserve AdminAgents as an error-level issue. --- .../Public/Test-CIPPGDAPRelationships.ps1 | 21 ++++++++++--------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/Modules/CIPPCore/Public/Test-CIPPGDAPRelationships.ps1 b/Modules/CIPPCore/Public/Test-CIPPGDAPRelationships.ps1 index 18748ca7e5f4..44defc8c5b28 100644 --- a/Modules/CIPPCore/Public/Test-CIPPGDAPRelationships.ps1 +++ b/Modules/CIPPCore/Public/Test-CIPPGDAPRelationships.ps1 @@ -56,30 +56,31 @@ function Test-CIPPGDAPRelationships { 'M365 GDAP Privileged Authentication Administrator' ) $RoleAssignableGroups = $SAMUserMemberships | Where-Object { $_.isAssignableToRole } - $NestedGroups = foreach ($Group in $RoleAssignableGroups) { - Write-Information "Getting nested group memberships for $($Group.displayName)" - New-GraphGetRequest -uri "https://graph.microsoft.com/beta/groups/$($Group.id)/memberOf?`$select=id,displayName" -NoAuthCheck $true + $NestedGroups = [System.Collections.Generic.List[object]]::new() + foreach ($RoleGroup in $RoleAssignableGroups) { + Write-Information "Getting nested group memberships for $($RoleGroup.displayName)" + $NestedGroups.AddRange(@(New-GraphGetRequest -uri "https://graph.microsoft.com/beta/groups/$($RoleGroup.id)/memberOf?`$select=id,displayName" -NoAuthCheck $true)) } - foreach ($Group in $ExpectedGroups) { + foreach ($ExpectedGroup in $ExpectedGroups) { $GroupFound = $false foreach ($Membership in ($SAMUserMemberships + $NestedGroups)) { - if ($Membership.displayName -match $Group) { - Write-Information "Found $Group in group memberships" + if ($Membership.displayName -match $ExpectedGroup) { + Write-Information "Found $ExpectedGroup in group memberships" $GroupFound = $true } } if (-not $GroupFound) { - if ($Group -eq 'AdminAgents') { $Type = 'Error' } else { $Type = 'Warning' } + if ($ExpectedGroup -eq 'AdminAgents') { $Type = 'Error' } else { $Type = 'Warning' } $GDAPissues.add([PSCustomObject]@{ Type = $Type - Issue = "$($Group) is not assigned to the SAM user $me. If you have migrated outside of CIPP this is to be expected. Please perform an access check to make sure you have the correct set of permissions." + Issue = "$($ExpectedGroup) is not assigned to the SAM user $me. If you have migrated outside of CIPP this is to be expected. Please perform an access check to make sure you have the correct set of permissions." Tenant = '*Partner Tenant' Relationship = 'None' Link = 'https://docs.cipp.app/setup/gdap/troubleshooting#groups' }) | Out-Null $MissingGroups.Add([PSCustomObject]@{ - Name = $Group + Name = $ExpectedGroup Type = 'SAM User Membership' }) | Out-Null } @@ -103,7 +104,7 @@ function Test-CIPPGDAPRelationships { $GDAPRelationships = [PSCustomObject]@{ GDAPIssues = @($GDAPissues) MissingGroups = @($MissingGroups) - Memberships = @($SAMUserMemberships) + Memberships = @($SAMUserMemberships + $NestedGroups) CIPPGroupCount = $CIPPGroupCount } From 5d2549dd64362ead88c3d67dfc83e9a1cc43d3ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kristian=20Kj=C3=A6rg=C3=A5rd?= Date: Fri, 6 Feb 2026 08:40:55 +0100 Subject: [PATCH 017/150] refactor: clean up guest invitation logic in Invoke-AddGuest --- .../Administration/Users/Invoke-AddGuest.ps1 | 49 ++++++++++--------- 1 file changed, 26 insertions(+), 23 deletions(-) diff --git a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-AddGuest.ps1 b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-AddGuest.ps1 index 34d1114ac9a0..79950f3571aa 100644 --- a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-AddGuest.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-AddGuest.ps1 @@ -1,4 +1,4 @@ -Function Invoke-AddGuest { +function Invoke-AddGuest { <# .FUNCTIONALITY Entrypoint @@ -13,33 +13,36 @@ Function Invoke-AddGuest { $TenantFilter = $Request.Body.tenantFilter - $UserObject = $Request.Body + $DisplayName = -not [string]::IsNullOrWhiteSpace($Request.Body.displayName) ? $Request.Body.displayName : $null + $EmailAddress = -not [string]::IsNullOrWhiteSpace($Request.Body.mail) ? $Request.Body.mail : $null + $Message = -not [string]::IsNullOrWhiteSpace($Request.Body.message) ? $Request.Body.message : $null + $RedirectURL = -not [string]::IsNullOrWhiteSpace($Request.Body.redirectUri) ? $Request.Body.redirectUri : 'https://myapps.microsoft.com' + $SendInvite = [System.Convert]::ToBoolean($Request.Body.sendInvite) ?? $true + + Write-Information -MessageData "Received request to add guest with email $EmailAddress to tenant filter $TenantFilter with display name $DisplayName. SendInvite is set to $SendInvite. Redirect URL is $RedirectURL. Message is $Message" try { - if ($UserObject.RedirectURL) { - $BodyToShip = [pscustomobject] @{ - 'InvitedUserDisplayName' = $UserObject.DisplayName - 'InvitedUserEmailAddress' = $($UserObject.mail) - 'inviteRedirectUrl' = $($UserObject.RedirectURL) - 'sendInvitationMessage' = [bool]$UserObject.SendInvite - } - } else { - $BodyToShip = [pscustomobject] @{ - 'InvitedUserDisplayName' = $UserObject.DisplayName - 'InvitedUserEmailAddress' = $($UserObject.mail) - 'sendInvitationMessage' = [bool]$UserObject.SendInvite - 'inviteRedirectUrl' = 'https://myapps.microsoft.com' - } + $BodyToShip = [pscustomobject] @{ + invitedUserDisplayName = $DisplayName + invitedUserEmailAddress = $EmailAddress + inviteRedirectUrl = $RedirectURL + sendInvitationMessage = $SendInvite } - $bodyToShip = ConvertTo-Json -Depth 10 -InputObject $BodyToShip -Compress - $null = New-GraphPostRequest -uri 'https://graph.microsoft.com/beta/invitations' -tenantid $TenantFilter -type POST -body $BodyToShip -Verbose - if ($UserObject.SendInvite -eq $true) { - $Result = "Invited Guest $($UserObject.DisplayName) with Email Invite" - Write-LogMessage -headers $Headers -API $APIName -tenant $($TenantFilter) -message $Result -Sev 'Info' + + if (-not [string]::IsNullOrWhiteSpace($Message)) { + $BodyToShip | Add-Member -MemberType NoteProperty -Name 'invitedUserMessageInfo' -Value ([pscustomobject]@{ + customizedMessageBody = $Message + }) + } + + $BodyToShipJson = ConvertTo-Json -Depth 5 -InputObject $BodyToShip + $null = New-GraphPostRequest -uri 'https://graph.microsoft.com/beta/invitations' -tenantid $TenantFilter -type POST -body $BodyToShipJson + if ($SendInvite -eq $true) { + $Result = "Invited Guest $($DisplayName) with Email Invite" } else { - $Result = "Invited Guest $($UserObject.DisplayName) with no Email Invite" - Write-LogMessage -headers $Headers -API $APIName -tenant $($TenantFilter) -message $Result -Sev 'Info' + $Result = "Invited Guest $($DisplayName) with no Email Invite" } + Write-LogMessage -headers $Headers -API $APIName -tenant $($TenantFilter) -message $Result -Sev 'Info' $StatusCode = [HttpStatusCode]::OK } catch { $ErrorMessage = Get-CippException -Exception $_ From 76354bf9db6c45a129958115d46486483f711018 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Fri, 6 Feb 2026 12:38:10 -0800 Subject: [PATCH 018/150] Fixes failing calls for licence lookups and moving to a new endpoint --- .../CIPP/Core/Invoke-ListAdminPortalLicenses.ps1 | 3 ++- Modules/CIPPCore/Public/Get-CIPPLicenseOverview.ps1 | 7 ++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ListAdminPortalLicenses.ps1 b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ListAdminPortalLicenses.ps1 index dc1ae4139972..dbbd60370647 100644 --- a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ListAdminPortalLicenses.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ListAdminPortalLicenses.ps1 @@ -11,7 +11,8 @@ function Invoke-ListAdminPortalLicenses { $TenantFilter = $Request.Query.tenantFilter try { - $AdminPortalLicenses = New-GraphGetRequest -scope 'https://admin.microsoft.com/.default' -TenantID $TenantFilter -Uri 'https://admin.microsoft.com/admin/api/tenant/accountSkus' + $AdminPortalLicenses = New-GraphGetRequest -scope 'https://admin.microsoft.com/.default' -TenantID $TenantFilter -Uri 'https://admin.microsoft.com/fd/m365licensing/v3/licensedProducts?allotmentSourceOwnerType=User&allotmentSourceType=LowFrictionTrial&allotmentSourceState=Active,Deleted,Suspended,Lockout,Warning&displayNameLanguage=en-GB' + } catch { Write-Warning 'Failed to get Admin Portal Licenses' $AdminPortalLicenses = @() diff --git a/Modules/CIPPCore/Public/Get-CIPPLicenseOverview.ps1 b/Modules/CIPPCore/Public/Get-CIPPLicenseOverview.ps1 index 2866df56749f..2e838aa0a63a 100644 --- a/Modules/CIPPCore/Public/Get-CIPPLicenseOverview.ps1 +++ b/Modules/CIPPCore/Public/Get-CIPPLicenseOverview.ps1 @@ -37,9 +37,10 @@ function Get-CIPPLicenseOverview { ) try { - $AdminPortalLicenses = New-GraphGetRequest -scope 'https://admin.microsoft.com/.default' -TenantID $TenantFilter -Uri 'https://admin.microsoft.com/admin/api/tenant/accountSkus' + $AdminPortalLicenses = New-GraphGetRequest -scope 'https://admin.microsoft.com/.default' -TenantID $TenantFilter -Uri 'https://admin.microsoft.com/fd/m365licensing/v3/licensedProducts?allotmentSourceOwnerType=User&allotmentSourceType=LowFrictionTrial&allotmentSourceState=Active,Deleted,Suspended,Lockout,Warning&displayNameLanguage=en-GB' } catch { - Write-Warning 'Failed to get Admin Portal Licenses' + Write-Warning "Failed to get Admin Portal Licenses: $($_.Exception.Message)" + $AdminPortalLicenses = @() } $Results = New-GraphBulkRequest -Requests $Requests -TenantID $TenantFilter -asapp $true @@ -103,7 +104,7 @@ function Get-CIPPLicenseOverview { $skuId = $singleReq.Licenses foreach ($sku in $skuId) { if ($sku.skuId -in $ExcludedSkuList.GUID) { continue } - $PrettyNameAdmin = $AdminPortalLicenses | Where-Object { $_.SkuId -eq $sku.skuId } | Select-Object -ExpandProperty Name + $PrettyNameAdmin = $AdminPortalLicenses | Where-Object { $_.aadSkuId -eq $sku.skuId } | Select-Object -ExpandProperty displayName -First 1 $PrettyNameCSV = ($ConvertTable | Where-Object { $_.guid -eq $sku.skuid }).'Product_Display_Name' | Select-Object -Last 1 $PrettyName = $PrettyNameAdmin ?? $PrettyNameCSV ?? $sku.skuPartNumber From ea0a6a42abd3b6a99f4229d99ecdad46e7ae80e3 Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Mon, 9 Feb 2026 07:42:56 +0100 Subject: [PATCH 019/150] deprecated std --- .../Standards/Invoke-CIPPStandardLegacyEmailReportAddins.ps1 | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardLegacyEmailReportAddins.ps1 b/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardLegacyEmailReportAddins.ps1 index 899677c80a05..627c88eabc0c 100644 --- a/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardLegacyEmailReportAddins.ps1 +++ b/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardLegacyEmailReportAddins.ps1 @@ -31,6 +31,8 @@ function Invoke-CIPPStandardLegacyEmailReportAddins { param($Tenant, $Settings) + #Deprecated, immmediate return + return $true # Define the legacy add-ins to remove $LegacyAddins = @( @{ From 923a9291cb8dff9f670b0721468280d30064f84c Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Mon, 9 Feb 2026 07:53:25 +0100 Subject: [PATCH 020/150] use db instead. --- .../Standards/Invoke-CIPPStandardDelegateSentItems.ps1 | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardDelegateSentItems.ps1 b/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardDelegateSentItems.ps1 index 561f1420bbf1..cb3591f04121 100644 --- a/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardDelegateSentItems.ps1 +++ b/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardDelegateSentItems.ps1 @@ -43,13 +43,11 @@ function Invoke-CIPPStandardDelegateSentItems { if ([string]::IsNullOrWhiteSpace($Settings.IncludeUserMailboxes)) { $Settings.IncludeUserMailboxes = $true } - + $Mailboxes = New-CippDbRequest -TenantFilter $Tenant -Type 'Mailboxes' if ($Settings.IncludeUserMailboxes -eq $true) { - $Mailboxes = New-ExoRequest -tenantid $Tenant -cmdlet 'Get-Mailbox' -cmdParams @{ RecipientTypeDetails = @('UserMailbox', 'SharedMailbox') } -Select 'Identity,UserPrincipalName,MessageCopyForSendOnBehalfEnabled,MessageCopyForSentAsEnabled' | - Where-Object { $_.MessageCopyForSendOnBehalfEnabled -eq $false -or $_.MessageCopyForSentAsEnabled -eq $false } + $Mailboxes = $Mailboxes | Where-Object { $_.recipientTypeDetails -ne 'DiscoveryMailbox' -and ($_.MessageCopyForSendOnBehalfEnabled -eq $false -or $_.MessageCopyForSentAsEnabled -eq $false) } } else { - $Mailboxes = New-ExoRequest -tenantid $Tenant -cmdlet 'Get-Mailbox' -cmdParams @{ RecipientTypeDetails = @('SharedMailbox') } -Select 'Identity,UserPrincipalName,MessageCopyForSendOnBehalfEnabled,MessageCopyForSentAsEnabled' | - Where-Object { $_.MessageCopyForSendOnBehalfEnabled -eq $false -or $_.MessageCopyForSentAsEnabled -eq $false } + $Mailboxes = $Mailboxes | Where-Object { $_.recipientTypeDetails -eq 'SharedMailbox' -and ($_.MessageCopyForSendOnBehalfEnabled -eq $false -or $_.MessageCopyForSentAsEnabled -eq $false) } } $CurrentValue = if (!$Mailboxes) { From 8373fe9fbbb4cfa9dc66fd082aa97b6ad30bb317 Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Mon, 9 Feb 2026 08:00:20 +0100 Subject: [PATCH 021/150] Less exo requests --- .../Standards/Invoke-CIPPStandardDisableBasicAuthSMTP.ps1 | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardDisableBasicAuthSMTP.ps1 b/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardDisableBasicAuthSMTP.ps1 index 6c54c0b78c1b..4e2146a003d4 100644 --- a/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardDisableBasicAuthSMTP.ps1 +++ b/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardDisableBasicAuthSMTP.ps1 @@ -42,10 +42,8 @@ function Invoke-CIPPStandardDisableBasicAuthSMTP { ##$Rerun -Type Standard -Tenant $Tenant -Settings $Settings 'DisableBasicAuthSMTP' try { - $CurrentInfo = New-ExoRequest -tenantid $Tenant -cmdlet 'Get-TransportConfig' - - $SMTPusers = New-ExoRequest -tenantid $Tenant -cmdlet 'Get-CASMailbox' | - Where-Object { ($_.SmtpClientAuthenticationDisabled -eq $false) } + $CurrentInfo = New-CippDbRequest -TenantFilter $Tenant -Type 'Get-TransportConfig' + $SMTPusers = New-CippDbRequest -TenantFilter $Tenant -Type 'CASMailbox' | Where-Object { ($_.SmtpClientAuthenticationDisabled -eq $false) } } catch { $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message Write-LogMessage -API 'Standards' -Tenant $Tenant -Message "Could not get the DisableBasicAuthSMTP state for $Tenant. Error: $ErrorMessage" -Sev Error From 11f1df417e1ff4880febdb02bfa1d465090742dc Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Mon, 9 Feb 2026 08:04:47 +0100 Subject: [PATCH 022/150] use db --- .../CIPPCore/Public/Standards/Invoke-CIPPStandardlaps.ps1 | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardlaps.ps1 b/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardlaps.ps1 index b08508db2e6e..b7fa8f3e7544 100644 --- a/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardlaps.ps1 +++ b/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardlaps.ps1 @@ -33,15 +33,14 @@ function Invoke-CIPPStandardlaps { param($Tenant, $Settings) try { - $PreviousSetting = New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/policies/deviceRegistrationPolicy' -tenantid $Tenant - } - catch { + $PreviousSetting = New-CippDbRequest -TenantFilter $Tenant -Type 'DeviceRegistrationPolicy' + } catch { $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message Write-LogMessage -API 'Standards' -Tenant $Tenant -Message "Could not get the DeviceRegistrationPolicy state for $Tenant. Error: $ErrorMessage" -Sev Error return } - If ($Settings.remediate -eq $true) { + if ($Settings.remediate -eq $true) { try { $PreviousSetting.localAdminPassword.isEnabled = $true $NewBody = ConvertTo-Json -Compress -InputObject $PreviousSetting -Depth 10 From 27e8f6f07ffb340c1899c62e484fa978cf93a223 Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Mon, 9 Feb 2026 15:33:28 +0100 Subject: [PATCH 023/150] add some exclusions --- Modules/CIPPCore/Public/Compare-CIPPIntuneObject.ps1 | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Modules/CIPPCore/Public/Compare-CIPPIntuneObject.ps1 b/Modules/CIPPCore/Public/Compare-CIPPIntuneObject.ps1 index 01e0c0f8f82e..7a51151cbc90 100644 --- a/Modules/CIPPCore/Public/Compare-CIPPIntuneObject.ps1 +++ b/Modules/CIPPCore/Public/Compare-CIPPIntuneObject.ps1 @@ -31,7 +31,9 @@ function Compare-CIPPIntuneObject { 'wslDistributions', 'lastSuccessfulSyncDateTime', 'tenantFilter', - 'agents' + 'agents', + 'isSynced' + 'locationInfo' ) $excludeProps = $defaultExcludeProperties + $ExcludeProperties From 98b1c7ad761f911eb9a3906cb3a8a4174ac3f991 Mon Sep 17 00:00:00 2001 From: Logan Cook <2997336+MWG-Logan@users.noreply.github.com> Date: Mon, 9 Feb 2026 12:19:58 -0500 Subject: [PATCH 024/150] fix(standards): Update Intune Deploy reusable settings to match KelvinCode --- .../Invoke-CIPPStandardIntuneTemplate.ps1 | 245 +++++++----------- 1 file changed, 100 insertions(+), 145 deletions(-) diff --git a/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardIntuneTemplate.ps1 b/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardIntuneTemplate.ps1 index 595e536c0714..1d5be63ed1b6 100644 --- a/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardIntuneTemplate.ps1 +++ b/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardIntuneTemplate.ps1 @@ -37,172 +37,127 @@ function Invoke-CIPPStandardIntuneTemplate { #> param($Tenant, $Settings) - $TestResult = Test-CIPPStandardLicense -StandardName 'IntuneTemplate_general' -TenantFilter $Tenant -RequiredCapabilities @('INTUNE_A', 'MDM_Services', 'EMS', 'SCCM', 'MICROSOFTINTUNEPLAN1') - ##$Rerun -Type Standard -Tenant $Tenant -Settings $Settings 'intuneTemplate' - - if ($TestResult -eq $false) { - #writing to each item that the license is not present. - $settings.TemplateList | ForEach-Object { - Set-CIPPStandardsCompareField -FieldName "standards.IntuneTemplate.$($_.value)" -FieldValue 'This tenant does not have the required license for this standard.' -Tenant $Tenant - } - Write-Host "We're exiting as the correct license is not present for this standard." - return $true - } #we're done. + Write-Host 'INTUNETEMPLATERUN' $Table = Get-CippTable -tablename 'templates' $Filter = "PartitionKey eq 'IntuneTemplate'" - $Request = @{body = $null } - Write-Host "IntuneTemplate: Starting process. Settings are: $($Settings | ConvertTo-Json -Compress)" - $CompareList = foreach ($Template in $Settings) { - Write-Host "IntuneTemplate: $($Template.TemplateList.value) - Trying to find template" - $Request.body = (Get-CIPPAzDataTableEntity @Table -Filter $Filter | Where-Object -Property RowKey -Like "$($Template.TemplateList.value)*").JSON | ConvertFrom-Json -ErrorAction SilentlyContinue - if ($null -eq $Request.body) { - Write-LogMessage -API 'Standards' -tenant $tenant -message "Failed to find template $($Template.TemplateList.value). Has this Intune Template been deleted?" -sev 'Error' - continue - } - try { - $reusableSync = Sync-CIPPReusablePolicySettings -TemplateInfo $Request.body -Tenant $Tenant -ErrorAction Stop - if ($null -ne $reusableSync -and $reusableSync.PSObject.Properties.Name -contains 'RawJSON' -and $reusableSync.RawJSON) { - $Request.body.RawJSON = $reusableSync.RawJSON - } - } catch { - Write-LogMessage -API 'Standards' -tenant $tenant -message "Failed to sync reusable policy settings for template $($Template.TemplateList.value): $($_.Exception.Message)" -sev 'Error' - Write-Host "IntuneTemplate: $($Template.TemplateList.value) - Failed to sync reusable policy settings. Skipping this template." - continue + + $Template = (Get-CIPPAzDataTableEntity @Table -Filter $Filter | Where-Object -Property RowKey -Like "$($Settings.TemplateList.value)*").JSON | ConvertFrom-Json -ErrorAction SilentlyContinue + if ($null -eq $Template) { + Write-LogMessage -API 'Standards' -tenant $tenant -message "Failed to find template $($Settings.TemplateList.value). Has this Intune Template been deleted?" -sev 'Error' + return $true + } + + try { + $reusableSync = Sync-CIPPReusablePolicySettings -TemplateInfo $Template -Tenant $Tenant -ErrorAction Stop + if ($null -ne $reusableSync -and $reusableSync.PSObject.Properties.Name -contains 'RawJSON' -and $reusableSync.RawJSON) { + $Template.RawJSON = $reusableSync.RawJSON } - Write-Host "IntuneTemplate: $($Template.TemplateList.value) - Got template." + } catch { + Write-LogMessage -API 'Standards' -tenant $tenant -message "Failed to sync reusable policy settings for template $($Settings.TemplateList.value): $($_.Exception.Message)" -sev 'Error' + Write-Host "IntuneTemplate: $($Settings.TemplateList.value) - Failed to sync reusable policy settings. Skipping this template." + return $true + } - $displayname = $request.body.Displayname - $description = $request.body.Description - $RawJSON = $Request.body.RawJSON + $displayname = $Template.Displayname + $description = $Template.Description + $RawJSON = $Template.RawJSON + $TemplateType = $Template.Type + + try { + $ExistingPolicy = Get-CIPPIntunePolicy -tenantFilter $Tenant -DisplayName $displayname -TemplateType $TemplateType + } catch { + $ExistingPolicy = $null + } + + if ($ExistingPolicy) { try { - Write-Host "IntuneTemplate: $($Template.TemplateList.value) - Grabbing existing Policy" - $ExistingPolicy = Get-CIPPIntunePolicy -tenantFilter $Tenant -DisplayName $displayname -TemplateType $Request.body.Type + $RawJSON = Get-CIPPTextReplacement -Text $RawJSON -TenantFilter $Tenant + $JSONExistingPolicy = $ExistingPolicy.cippconfiguration | ConvertFrom-Json + $JSONTemplate = $RawJSON | ConvertFrom-Json + #This might be a slow one. + $Compare = Compare-CIPPIntuneObject -ReferenceObject $JSONTemplate -DifferenceObject $JSONExistingPolicy -compareType $TemplateType -ErrorAction SilentlyContinue } catch { - Write-Host "IntuneTemplate: $($Template.TemplateList.value) - Failed to get existing." - } - if ($ExistingPolicy) { - try { - Write-Host "IntuneTemplate: $($Template.TemplateList.value) - Found existing policy." - $RawJSON = Get-CIPPTextReplacement -Text $RawJSON -TenantFilter $Tenant - Write-Host "IntuneTemplate: $($Template.TemplateList.value) - Grabbing JSON existing." - $JSONExistingPolicy = $ExistingPolicy.cippconfiguration | ConvertFrom-Json - Write-Host "IntuneTemplate: $($Template.TemplateList.value) - Got existing JSON. Converting RawJSON to Template" - $JSONTemplate = $RawJSON | ConvertFrom-Json - Write-Host "IntuneTemplate: $($Template.TemplateList.value) - Converted RawJSON to Template." - Write-Host "IntuneTemplate: $($Template.TemplateList.value) - Comparing JSON." - $Compare = Compare-CIPPIntuneObject -ReferenceObject $JSONTemplate -DifferenceObject $JSONExistingPolicy -compareType $Request.body.Type -ErrorAction SilentlyContinue - } catch { - Write-Host "The compare failed. The error was: $($_.Exception.Message)" - } - Write-Host "IntuneTemplate: $($Template.TemplateList.value) - Compared JSON: $($Compare | ConvertTo-Json -Compress)" - } else { - Write-Host "IntuneTemplate: $($Template.TemplateList.value) - No existing policy found." - $compare = [pscustomobject]@{ - MatchFailed = $true - Difference = 'This policy does not exist in Intune.' - } } - if ($Compare) { - Write-Host "IntuneTemplate: $($Template.TemplateList.value) - Compare found differences." - [PSCustomObject]@{ - MatchFailed = $true - displayname = $displayname - description = $description - compare = $Compare - rawJSON = $RawJSON - body = $Request.body - assignTo = $Template.AssignTo - excludeGroup = $Template.excludeGroup - remediate = $Template.remediate - alert = $Template.alert - report = $Template.report - existingPolicyId = $ExistingPolicy.id - templateId = $Template.TemplateList.value - customGroup = $Template.customGroup - assignmentFilter = $Template.assignmentFilter - assignmentFilterType = $Template.assignmentFilterType - } - } else { - Write-Host "IntuneTemplate: $($Template.TemplateList.value) - No differences found." - [PSCustomObject]@{ - MatchFailed = $false - displayname = $displayname - description = $description - compare = $false - rawJSON = $RawJSON - body = $Request.body - assignTo = $Template.AssignTo - excludeGroup = $Template.excludeGroup - remediate = $Template.remediate - alert = $Template.alert - report = $Template.report - existingPolicyId = $ExistingPolicy.id - templateId = $Template.TemplateList.value - customGroup = $Template.customGroup - assignmentFilter = $Template.assignmentFilter - assignmentFilterType = $Template.assignmentFilterType - } + } else { + $compare = [pscustomobject]@{ + MatchFailed = $true + Difference = 'This policy does not exist in Intune.' } } + $CompareResult = [PSCustomObject]@{ + MatchFailed = [bool]$Compare + displayname = $displayname + description = $description + compare = $Compare + rawJSON = $RawJSON + templateType = $TemplateType + assignTo = $Settings.AssignTo + excludeGroup = $Settings.excludeGroup + remediate = $Settings.remediate + alert = $Settings.alert + report = $Settings.report + existingPolicyId = $ExistingPolicy.id + templateId = $Settings.TemplateList.value + customGroup = $Settings.customGroup + assignmentFilter = $Settings.assignmentFilter + assignmentFilterType = $Settings.assignmentFilterType + } - if ($true -in $Settings.remediate) { - Write-Host 'starting template deploy' - foreach ($TemplateFile in $CompareList | Where-Object -Property remediate -EQ $true) { - Write-Host "working on template deploy: $($TemplateFile.displayname)" - try { - $TemplateFile.customGroup ? ($TemplateFile.AssignTo = $TemplateFile.customGroup) : $null - - $PolicyParams = @{ - TemplateType = $TemplateFile.body.Type - Description = $TemplateFile.description - DisplayName = $TemplateFile.displayname - RawJSON = $templateFile.rawJSON - AssignTo = $TemplateFile.AssignTo - ExcludeGroup = $TemplateFile.excludeGroup - tenantFilter = $Tenant - } - - # Add assignment filter if specified - if ($TemplateFile.assignmentFilter) { - $PolicyParams.AssignmentFilterName = $TemplateFile.assignmentFilter - $PolicyParams.AssignmentFilterType = $TemplateFile.assignmentFilterType ?? 'include' - } + if ($Settings.remediate) { + try { + $CompareResult.customGroup ? ($CompareResult.AssignTo = $CompareResult.customGroup) : $null + $PolicyParams = @{ + TemplateType = $CompareResult.templateType + Description = $CompareResult.description + DisplayName = $CompareResult.displayname + RawJSON = $CompareResult.rawJSON + AssignTo = $CompareResult.AssignTo + ExcludeGroup = $CompareResult.excludeGroup + tenantFilter = $Tenant + } - Set-CIPPIntunePolicy @PolicyParams - } catch { - $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message - Write-LogMessage -API 'Standards' -tenant $tenant -message "Failed to create or update Intune Template $($TemplateFile.displayname), Error: $ErrorMessage" -sev 'Error' + # Add assignment filter if specified + if ($CompareResult.assignmentFilter) { + $PolicyParams.AssignmentFilterName = $CompareResult.assignmentFilter + $PolicyParams.AssignmentFilterType = $CompareResult.assignmentFilterType ?? 'include' } - } + Set-CIPPIntunePolicy @PolicyParams + } catch { + $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message + Write-LogMessage -API 'Standards' -tenant $tenant -message "Failed to create or update Intune Template $($CompareResult.displayname), Error: $ErrorMessage" -sev 'Error' + } } - if ($true -in $Settings.alert) { - foreach ($Template in $CompareList | Where-Object -Property alert -EQ $true) { - Write-Host "working on template alert: $($Template.displayname)" - $AlertObj = $Template | Select-Object -Property displayname, description, compare, assignTo, excludeGroup, existingPolicyId - if ($Template.compare) { - Write-StandardsAlert -message "Template $($Template.displayname) does not match the expected configuration." -object $AlertObj -tenant $Tenant -standardName 'IntuneTemplate' -standardId $Settings.templateId - Write-LogMessage -API 'Standards' -tenant $Tenant -message "Template $($Template.displayname) does not match the expected configuration. We've generated an alert" -sev info + if ($Settings.alert) { + $AlertObj = $CompareResult | Select-Object -Property displayname, description, compare, assignTo, excludeGroup, existingPolicyId + if ($CompareResult.compare) { + Write-StandardsAlert -message "Template $($CompareResult.displayname) does not match the expected configuration." -object $AlertObj -tenant $Tenant -standardName 'IntuneTemplate' -standardId $Settings.templateId + Write-LogMessage -API 'Standards' -tenant $Tenant -message "Template $($CompareResult.displayname) does not match the expected configuration. We've generated an alert" -sev info + } else { + if ($CompareResult.ExistingPolicyId) { + Write-LogMessage -API 'Standards' -tenant $Tenant -message "Template $($CompareResult.displayname) has the correct configuration." -sev Info } else { - if ($Template.ExistingPolicyId) { - Write-LogMessage -API 'Standards' -tenant $Tenant -message "Template $($Template.displayname) has the correct configuration." -sev Info - } else { - Write-StandardsAlert -message "Template $($Template.displayname) is missing." -object $AlertObj -tenant $Tenant -standardName 'IntuneTemplate' -standardId $Settings.templateId - Write-LogMessage -API 'Standards' -tenant $Tenant -message "Template $($Template.displayname) is missing." -sev info - } + Write-StandardsAlert -message "Template $($CompareResult.displayname) is missing." -object $AlertObj -tenant $Tenant -standardName 'IntuneTemplate' -standardId $Settings.templateId + Write-LogMessage -API 'Standards' -tenant $Tenant -message "Template $($CompareResult.displayname) is missing." -sev info } } } - if ($true -in $Settings.report) { - foreach ($Template in $CompareList | Where-Object { $_.report -eq $true -or $_.remediate -eq $true }) { - Write-Host "working on template report: $($Template.displayname)" - $id = $Template.templateId - $CompareObj = $Template.compare - $state = $CompareObj ? $CompareObj : $true - Set-CIPPStandardsCompareField -FieldName "standards.IntuneTemplate.$id" -FieldValue $state -TenantFilter $Tenant + if ($Settings.report -or $Settings.remediate) { + $id = $CompareResult.templateId + + $CurrentValue = @{ + displayName = $CompareResult.displayname + description = $CompareResult.description + isCompliant = if ($CompareResult.compare) { $false } else { $true } + } + $ExpectedValue = @{ + displayName = $CompareResult.displayname + description = $CompareResult.description + isCompliant = $true } + Set-CIPPStandardsCompareField -FieldName "standards.IntuneTemplate.$id" -CurrentValue $CurrentValue -ExpectedValue $ExpectedValue -TenantFilter $Tenant #Add-CIPPBPAField -FieldName "policy-$id" -FieldValue $Compare -StoreAs bool -Tenant $tenant } } From 733e1b98751da40e5673f2a710a22e47d1884901 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Mon, 9 Feb 2026 12:26:52 -0500 Subject: [PATCH 025/150] ensure bulk requests are always arrays --- .../Endpoint/MEM/Invoke-ListAppProtectionPolicies.ps1 | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ListAppProtectionPolicies.ps1 b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ListAppProtectionPolicies.ps1 index ef6b59fec416..749c2984ad75 100644 --- a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ListAppProtectionPolicies.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ListAppProtectionPolicies.ps1 @@ -34,7 +34,7 @@ } ) - $BulkResults = New-GraphBulkRequest -Requests $BulkRequests -tenantid $TenantFilter + $BulkResults = New-GraphBulkRequest -Requests @($BulkRequests) -tenantid $TenantFilter # Extract groups for resolving assignment names $Groups = ($BulkResults | Where-Object { $_.id -eq 'Groups' }).body.value @@ -66,7 +66,7 @@ } } - $ManagedAppPoliciesBulkResults = New-GraphBulkRequest -Requests $ManagedAppPoliciesBulkRequests -tenantid $TenantFilter + $ManagedAppPoliciesBulkResults = New-GraphBulkRequest -Requests @($ManagedAppPoliciesBulkRequests) -tenantid $TenantFilter # Do this horriblenes as a workaround, as the results dont return with a odata.type property $ManagedAppPolicies = $ManagedAppPoliciesBulkResults | ForEach-Object { $URLName = $_.id From c9b0b1e7ad1f1cd4ab8986e11eaa969ad4d91354 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Mon, 9 Feb 2026 13:16:15 -0500 Subject: [PATCH 026/150] Improve MX record change detection and logging Add robust handling for missing or empty MX data and wrap per-domain comparison in try/catch to prevent runtime errors. Normalize PreviousRecords and CurrentRecords to arrays, use Compare-Object for differences, and emit informational messages when records are newly added or removed. Also ensure CurrentRecords is an array when updating the cache. These changes prevent failures when ActualMXRecords/Hostname are absent and provide clearer diagnostics. --- .../Alerts/Get-CIPPAlertMXRecordChanged.ps1 | 31 +++++++++++++++---- 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertMXRecordChanged.ps1 b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertMXRecordChanged.ps1 index 31811b78ed68..8abf8bb43747 100644 --- a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertMXRecordChanged.ps1 +++ b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertMXRecordChanged.ps1 @@ -17,16 +17,35 @@ function Get-CIPPAlertMXRecordChanged { $PreviousResults = Get-CIPPAzDataTableEntity @CacheTable -Filter "PartitionKey eq '$TenantFilter'" $ChangedDomains = foreach ($Domain in $DomainData) { - $PreviousDomain = $PreviousResults | Where-Object { $_.Domain -eq $Domain.Domain } - $PreviousRecords = $PreviousDomain.ActualMXRecords -split ',' | Sort-Object - $CurrentRecords = $Domain.ActualMXRecords.Hostname | Sort-Object - if ($PreviousDomain -and $PreviousRecords -ne $CurrentRecords) { - "$($Domain.Domain): MX records changed from [$($PreviousRecords -join ', ')] to [$($CurrentRecords -join ', ')]" + try { + $PreviousDomain = $PreviousResults | Where-Object { $_.Domain -eq $Domain.Domain } + $PreviousRecords = if ($PreviousDomain.ActualMXRecords) { @($PreviousDomain.ActualMXRecords -split ',' | Sort-Object) } else { @() } + $CurrentRecords = if ($Domain.ActualMXRecords.Hostname) { @($Domain.ActualMXRecords.Hostname | Sort-Object) } else { @() } + + # Only compare if both have records + $Differences = $null + if ($PreviousRecords.Count -gt 0 -and $CurrentRecords.Count -gt 0) { + $Differences = Compare-Object -ReferenceObject $PreviousRecords -DifferenceObject $CurrentRecords + } + + if ($PreviousRecords.Count -eq 0 -and $CurrentRecords.Count -gt 0) { + Write-Information "New MX records detected for domain $($Domain.Domain): $($CurrentRecords -join ', ')" + $Differences = 'NewRecords' + } elseif ($PreviousRecords.Count -gt 0 -and $CurrentRecords.Count -eq 0) { + Write-Information "All MX records removed for domain $($Domain.Domain). Previous records were: $($PreviousRecords -join ', ')" + $Differences = 'RemovedRecords' + } + + if ($Differences) { + "$($Domain.Domain): MX records changed from [$($PreviousRecords -join ', ')] to [$($CurrentRecords -join ', ')]" + } + } catch { + Write-Information "Error checking domain $($Domain.Domain): $($_.Exception.Message)" } } # Update cache with current data foreach ($Domain in $DomainData) { - $CurrentRecords = $Domain.ActualMXRecords.Hostname | Sort-Object + $CurrentRecords = @($Domain.ActualMXRecords.Hostname | Sort-Object) $CacheEntity = @{ PartitionKey = [string]$TenantFilter RowKey = [string]$Domain.Domain From 79d7d7a89a3eb1e25e8ba8403491a8e87e374e48 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Mon, 9 Feb 2026 13:51:10 -0500 Subject: [PATCH 027/150] fix ca policy location lookup --- Modules/CIPPCore/Public/New-CIPPCAPolicy.ps1 | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/Modules/CIPPCore/Public/New-CIPPCAPolicy.ps1 b/Modules/CIPPCore/Public/New-CIPPCAPolicy.ps1 index 250b31296dd8..1f908ff4e0f2 100644 --- a/Modules/CIPPCore/Public/New-CIPPCAPolicy.ps1 +++ b/Modules/CIPPCore/Public/New-CIPPCAPolicy.ps1 @@ -189,9 +189,9 @@ function New-CIPPCAPolicy { 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 - name = $ExistingLocation.displayName - templateId = $location.id + id = $ExistingLocation.id + name = $ExistingLocation.displayName + templateId = $location.id } } else { if ($location.countriesAndRegions) { $location.countriesAndRegions = @($location.countriesAndRegions) } @@ -224,7 +224,9 @@ function New-CIPPCAPolicy { if (!$lookup) { continue } Write-Information "Replacing named location - $location" $index = [array]::IndexOf($JSONobj.conditions.locations.includeLocations, $location) - $JSONobj.conditions.locations.includeLocations[$index] = $lookup.id + if ($lookup.id) { + $JSONobj.conditions.locations.includeLocations[$index] = $lookup.id + } } foreach ($location in $JSONobj.conditions.locations.excludeLocations) { @@ -233,7 +235,9 @@ function New-CIPPCAPolicy { if (!$lookup) { continue } Write-Information "Replacing named location - $location" $index = [array]::IndexOf($JSONobj.conditions.locations.excludeLocations, $location) - $JSONobj.conditions.locations.excludeLocations[$index] = $lookup.id + if ($lookup.id) { + $JSONobj.conditions.locations.excludeLocations[$index] = $lookup.id + } } switch ($ReplacePattern) { 'none' { From d8e734bcbab6fcfe13eaa883bc6ef797ce4e8160 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Mon, 9 Feb 2026 14:03:45 -0500 Subject: [PATCH 028/150] fix renaming templates --- .../HTTP Functions/CIPP/Core/Invoke-ExecEditTemplate.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecEditTemplate.ps1 b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecEditTemplate.ps1 index 5b47b16aceeb..f26daa540b5b 100644 --- a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecEditTemplate.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecEditTemplate.ps1 @@ -31,7 +31,7 @@ function Invoke-ExecEditTemplate { if ($Request.Body.parsedRAWJson) { $RawJSON = ConvertTo-Json -Compress -Depth 100 -InputObject $Request.Body.parsedRAWJson } else { - $RawJSON = $OriginalJSON + $RawJSON = $TemplateData.RAWJson } $IntuneTemplate = @{ From 4781956207b866850770c2ee4f0afa41535ae0f9 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Mon, 9 Feb 2026 15:58:34 -0500 Subject: [PATCH 029/150] Clear rerun cache before scheduling task Invoke-AddScheduledItem: when an existing ScheduledTask is found, call Test-CIPPRerun with Clear=$true for that tenant/API before calling Add-CIPPScheduledTask -RunNow to ensure any stale rerun cache is cleared. Test-CIPPRerun: avoid computing the estimated interval when Clear/ClearAll flags are present; and build a tighter Azure Table filter by optionally scoping PartitionKey (unless AllTenants) and using a RowKey ge/le range (with '~' as high ascii) to restrict results to the specific Type_API. These changes prevent stale rerun entries from blocking immediate runs and reduce table query scope for better performance. --- .../Scheduler/Invoke-AddScheduledItem.ps1 | 8 +++++ Modules/CIPPCore/Public/Test-CIPPRerun.ps1 | 31 ++++++++++++------- 2 files changed, 28 insertions(+), 11 deletions(-) diff --git a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/CIPP/Scheduler/Invoke-AddScheduledItem.ps1 b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/CIPP/Scheduler/Invoke-AddScheduledItem.ps1 index 18aecda3ab90..b076401bab5d 100644 --- a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/CIPP/Scheduler/Invoke-AddScheduledItem.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/CIPP/Scheduler/Invoke-AddScheduledItem.ps1 @@ -20,7 +20,15 @@ function Invoke-AddScheduledItem { $Table = Get-CIPPTable -TableName 'ScheduledTasks' $Filter = "PartitionKey eq 'ScheduledTask' and RowKey eq '$($Request.Body.RowKey)'" $ExistingTask = (Get-CIPPAzDataTableEntity @Table -Filter $Filter) + if ($ExistingTask) { + $RerunParams = @{ + TenantFilter = $ExistingTask.Tenant + Type = 'ScheduledTask' + API = $Request.Body.RowKey + Clear = $true + } + $null = Test-CIPPRerun @RerunParams $Result = Add-CIPPScheduledTask -RowKey $Request.Body.RowKey -RunNow -Headers $Request.Headers } else { $Result = "Task with id $($Request.Body.RowKey) does not exist" diff --git a/Modules/CIPPCore/Public/Test-CIPPRerun.ps1 b/Modules/CIPPCore/Public/Test-CIPPRerun.ps1 index 57d8d9b3a603..f83f7e649a23 100644 --- a/Modules/CIPPCore/Public/Test-CIPPRerun.ps1 +++ b/Modules/CIPPCore/Public/Test-CIPPRerun.ps1 @@ -13,16 +13,18 @@ function Test-CIPPRerun { ) $RerunTable = Get-CIPPTable -tablename 'RerunCache' - # Use custom interval if provided, otherwise use type-based defaults - if ($Interval -gt 0) { - $EstimatedDifference = $Interval - } else { - $EstimatedDifference = switch ($Type) { - 'Standard' { 9800 } # 2 hours 45 minutes ish. - 'BPA' { 85000 } # 24 hours ish. - 'CippTests' { 85000 } # 24 hours ish. - 'ExchangeMonitor' { 3500 } #about an hour - default { throw "Unknown type: $Type" } + if (!$ClearAll.IsPresent -and !$Clear.IsPresent) { + # Use custom interval if provided, otherwise use type-based defaults + if ($Interval -gt 0) { + $EstimatedDifference = $Interval + } else { + $EstimatedDifference = switch ($Type) { + 'Standard' { 9800 } # 2 hours 45 minutes ish. + 'BPA' { 85000 } # 24 hours ish. + 'CippTests' { 85000 } # 24 hours ish. + 'ExchangeMonitor' { 3500 } #about an hour + default { throw "Unknown type: $Type" } + } } } @@ -31,7 +33,14 @@ function Test-CIPPRerun { $EstimatedNextRun = $CurrentUnixTime + $EstimatedDifference try { - $RerunData = Get-CIPPAzDataTableEntity @RerunTable -filter "PartitionKey eq '$($TenantFilter)'" | Where-Object { $_.RowKey -match "^$($Type)_$($API)" } + $Filters = [System.Collections.Generic.List[string]]::new() + if ($TenantFilter -ne 'AllTenants') { + $Filters.Add("PartitionKey eq '$TenantFilter'") + } + $Filters.Add("RowKey ge '$($Type)_$($API)' and RowKey le '$($Type)_$($API)~'") # ~ is the highest ascii character, this ensures we only get entries for this API. + $FilterString = [string]::Join(' and ', $Filters) + + $RerunData = Get-CIPPAzDataTableEntity @RerunTable -filter $FilterString if ($ClearAll.IsPresent) { $AllRerunData = Get-CIPPAzDataTableEntity @RerunTable if ($AllRerunData) { From ebe90179626cf3647cd3c4052c26145ba8fc70f7 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Mon, 9 Feb 2026 15:59:46 -0500 Subject: [PATCH 030/150] Use generated GUID and add JSON logging In Import-CommunityTemplate.ps1 generate a new GUID ($id) for imported entities and use it instead of Template.id when setting GUID/RowKey for Group, CATemplate and IntuneTemplate entries. Remove redundant assignments of $id from Template.id, exclude the 'templateId' property from template selection, and add Write-Information logs showing the raw JSON before ID replacement and the final entity for easier debugging. This ensures unique IDs for imported templates and improves traceability during import. --- .../Public/Tools/Import-CommunityTemplate.ps1 | 26 ++++++++++--------- 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/Modules/CIPPCore/Public/Tools/Import-CommunityTemplate.ps1 b/Modules/CIPPCore/Public/Tools/Import-CommunityTemplate.ps1 index 9ea1c1b98a76..d099f2a280f8 100644 --- a/Modules/CIPPCore/Public/Tools/Import-CommunityTemplate.ps1 +++ b/Modules/CIPPCore/Public/Tools/Import-CommunityTemplate.ps1 @@ -68,26 +68,26 @@ function Import-CommunityTemplate { $Template | Add-Member -MemberType NoteProperty -Name Source -Value $Source -Force Add-CIPPAzDataTableEntity @Table -Entity $Template -Force } 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]@{ Displayname = $Template.displayName Description = $Template.Description MembershipRules = $Template.membershipRule username = $Template.mailNickname - GUID = $Template.id + GUID = $id groupType = 'generic' } | ConvertTo-Json -Depth 100 $entity = @{ JSON = "$RawJsonObj" PartitionKey = 'GroupTemplate' SHA = $SHA - GUID = $Template.id - RowKey = $Template.id + GUID = $id + RowKey = $id Source = $Source } Add-CIPPAzDataTableEntity @Table -Entity $entity -Force @@ -99,8 +99,7 @@ function Import-CommunityTemplate { $NonEmptyProperties = $_.psobject.Properties | Where-Object { $null -ne $_.Value } | Select-Object -ExpandProperty Name $_ | Select-Object -Property $NonEmptyProperties } - $id = $Template.id - $Template = $Template | Select-Object * -ExcludeProperty lastModifiedDateTime, 'assignments', '#microsoft*', '*@odata.navigationLink', '*@odata.associationLink', '*@odata.context', 'ScopeTagIds', 'supportsScopeTags', 'createdDateTime', '@odata.id', '@odata.editLink', '*odata.type', 'roleScopeTagIds@odata.type', createdDateTime, 'createdDateTime@odata.type' + $Template = $Template | Select-Object * -ExcludeProperty lastModifiedDateTime, 'assignments', '#microsoft*', '*@odata.navigationLink', '*@odata.associationLink', '*@odata.context', 'ScopeTagIds', 'supportsScopeTags', 'createdDateTime', '@odata.id', '@odata.editLink', '*odata.type', 'roleScopeTagIds@odata.type', createdDateTime, 'createdDateTime@odata.type', 'templateId' Remove-ODataProperties -Object $Template $LocationInfo = [system.collections.generic.list[object]]::new() @@ -117,6 +116,8 @@ function Import-CommunityTemplate { } $RawJson = ConvertTo-Json -InputObject $Template -Depth 100 -Compress + + Write-Information "Raw JSON before ID replacement: $RawJson" #Replace the ids with the displayname by using the migration table, this is a simple find and replace each instance in the JSON. $MigrationTable.objects | ForEach-Object { if ($RawJson -match $_.ID) { @@ -128,10 +129,12 @@ function Import-CommunityTemplate { JSON = "$RawJson" PartitionKey = 'CATemplate' SHA = $SHA - GUID = $ID - RowKey = $ID + GUID = $id + RowKey = $id Source = $Source } + Write-Information "Final entity: $($entity | ConvertTo-Json -Depth 10)" + Add-CIPPAzDataTableEntity @Table -Entity $entity -Force break } @@ -145,7 +148,6 @@ function Import-CommunityTemplate { '*managedAppPolicies*' { 'AppProtection' } '*deviceAppManagement*' { 'AppProtection' } } - $id = $Template.id $RawJson = $Template | Select-Object * -ExcludeProperty id, lastModifiedDateTime, 'assignments', '#microsoft*', '*@odata.navigationLink', '*@odata.associationLink', '*@odata.context', 'ScopeTagIds', 'supportsScopeTags', 'createdDateTime', '@odata.id', '@odata.editLink', 'lastModifiedDateTime@odata.type', 'roleScopeTagIds@odata.type', createdDateTime, 'createdDateTime@odata.type' Remove-ODataProperties -Object $RawJson $RawJson = $RawJson | ConvertTo-Json -Depth 100 -Compress @@ -156,15 +158,15 @@ function Import-CommunityTemplate { Description = $Template.Description RAWJson = $RawJson Type = $URLName - GUID = $ID + GUID = $id } | ConvertTo-Json -Depth 100 -Compress $entity = @{ JSON = "$RawJsonObj" PartitionKey = 'IntuneTemplate' SHA = $SHA - GUID = $ID - RowKey = $ID + GUID = $id + RowKey = $id Source = $Source } From 8b30e70267f6099e47c45dc9d3b84a13014aa18d Mon Sep 17 00:00:00 2001 From: John Duprey Date: Mon, 9 Feb 2026 18:13:50 -0500 Subject: [PATCH 031/150] Handle guest users and userType in MFA state Include userType in Graph query and user objects, and add handling for policies that target guests or external users. Introduce GuestUserPolicies (and use generic lists) to collect guest-targeting policies, evaluate include/exclude guest settings (guestOrExternalUserTypes) and exclude groups when calculating conditional access coverage for Guest users. Also add defensive null-checks throughout, ensure group include/exclude checks are robust, and expose UserType in the returned user record. Minor cleanup: use single quotes for a license error message and normalize list initializations. --- Modules/CIPPCore/Public/Get-CIPPMFAState.ps1 | 96 ++++++++++++++++++-- 1 file changed, 87 insertions(+), 9 deletions(-) diff --git a/Modules/CIPPCore/Public/Get-CIPPMFAState.ps1 b/Modules/CIPPCore/Public/Get-CIPPMFAState.ps1 index 193a2b1ca532..ee6ee2ea96a1 100644 --- a/Modules/CIPPCore/Public/Get-CIPPMFAState.ps1 +++ b/Modules/CIPPCore/Public/Get-CIPPMFAState.ps1 @@ -6,7 +6,7 @@ function Get-CIPPMFAState { $Headers ) #$PerUserMFAState = Get-CIPPPerUserMFA -TenantFilter $TenantFilter -AllUsers $true - $users = foreach ($user in (New-GraphGetRequest -uri 'https://graph.microsoft.com/v1.0/users?$top=999&$select=id,UserPrincipalName,DisplayName,accountEnabled,assignedLicenses,perUserMfaState' -tenantid $TenantFilter)) { + $users = foreach ($user in (New-GraphGetRequest -uri 'https://graph.microsoft.com/v1.0/users?$top=999&$select=id,UserPrincipalName,DisplayName,accountEnabled,assignedLicenses,perUserMfaState,userType' -tenantid $TenantFilter)) { [PSCustomObject]@{ UserPrincipalName = $user.UserPrincipalName isLicensed = [boolean]$user.assignedLicenses.Count @@ -14,6 +14,7 @@ function Get-CIPPMFAState { DisplayName = $user.DisplayName ObjectId = $user.id perUserMfaState = $user.perUserMfaState + UserType = $user.userType } } @@ -22,7 +23,8 @@ function Get-CIPPMFAState { $CASuccess = $false $CAError = $null $PolicyTable = @{} - $AllUserPolicies = @() + $AllUserPolicies = [System.Collections.Generic.List[object]]::new() + $GuestUserPolicies = [System.Collections.Generic.List[object]]::new() $UserGroupMembership = @{} $UserExcludeGroupMembership = @{} $GroupNameLookup = @{} @@ -47,7 +49,7 @@ function Get-CIPPMFAState { } catch { $CAState.Add('Not Licensed for Conditional Access') | Out-Null $MFARegistration = $null - $CAError = "MFA registration not available - licensing required for Conditional Access reporting" + $CAError = 'MFA registration not available - licensing required for Conditional Access reporting' if ($_.Exception.Message -ne "Tenant is not a B2C tenant and doesn't have premium licenses") { $Errors.Add(@{Step = 'MFARegistration'; Message = $_.Exception.Message }) } @@ -76,7 +78,7 @@ function Get-CIPPMFAState { if ($RequiresMFA) { # Handle user assignments - if ($Policy.conditions.users.includeUsers -ne $null) { + if ($null -ne $Policy.conditions.users.includeUsers) { # Check if "All" is included if ($Policy.conditions.users.includeUsers -contains 'All') { $AllUserPolicies.Add($Policy) @@ -90,15 +92,20 @@ function Get-CIPPMFAState { } } + # Handle guest/external user assignments + if ($null -ne $Policy.conditions.users.includeGuestsOrExternalUsers) { + $GuestUserPolicies.Add($Policy) + } + # Collect groups to resolve - if ($Policy.conditions.users.includeGroups -ne $null -and $Policy.conditions.users.includeGroups.Count -gt 0) { + if ($null -ne $Policy.conditions.users.includeGroups -and $Policy.conditions.users.includeGroups.Count -gt 0) { foreach ($GroupId in $Policy.conditions.users.includeGroups) { [void]$GroupsToResolve.Add($GroupId) } } # Collect exclude groups to resolve - if ($Policy.conditions.users.excludeGroups -ne $null -and $Policy.conditions.users.excludeGroups.Count -gt 0) { + if ($null -ne $Policy.conditions.users.excludeGroups -and $Policy.conditions.users.excludeGroups.Count -gt 0) { foreach ($GroupId in $Policy.conditions.users.excludeGroups) { [void]$ExcludeGroupsToResolve.Add($GroupId) } @@ -181,7 +188,7 @@ function Get-CIPPMFAState { } # Now add policies to users based on group membership - foreach ($Policy in $CAPolicies | Where-Object { $_.conditions.users.includeGroups -ne $null -and $_.conditions.users.includeGroups.Count -gt 0 }) { + foreach ($Policy in $CAPolicies | Where-Object { $null -ne $_.conditions.users.includeGroups -and $_.conditions.users.includeGroups.Count -gt 0 }) { # Check if this policy requires MFA $RequiresMFA = $false if ($Policy.grantControls.builtInControls -contains 'mfa') { @@ -235,6 +242,65 @@ function Get-CIPPMFAState { $GraphRequest = $Users | ForEach-Object { $UserCAState = [System.Collections.Generic.List[object]]::new() + # Check if user is a guest and add guest-targeting policies + if ($_.UserType -eq 'Guest') { + foreach ($Policy in $GuestUserPolicies) { + $GuestConfig = $Policy.conditions.users.includeGuestsOrExternalUsers + $IsGuestIncluded = $false + + if ($null -ne $GuestConfig -and $null -ne $GuestConfig.guestOrExternalUserTypes) { + $GuestTypes = $GuestConfig.guestOrExternalUserTypes -split ',' + # Check if policy includes all guests or specifically internal guests + if ($GuestTypes -contains 'internalGuest') { + $IsGuestIncluded = $true + } + } + + # Check if guests are explicitly excluded from the policy + $ExcludeGuestConfig = $Policy.conditions.users.excludeGuestsOrExternalUsers + if ($null -ne $ExcludeGuestConfig -and $null -ne $ExcludeGuestConfig.guestOrExternalUserTypes) { + $ExcludeGuestTypes = $ExcludeGuestConfig.guestOrExternalUserTypes -split ',' + if ($ExcludeGuestTypes -contains 'internalGuest') { + $IsGuestIncluded = $false + } + } + + if ($IsGuestIncluded) { + # Check if user is excluded directly or via group + $IsExcluded = $Policy.conditions.users.excludeUsers -contains $_.ObjectId + $ExcludedViaGroup = $null + + # Check exclude groups + if (-not $IsExcluded -and $null -ne $Policy.conditions.users.excludeGroups -and $Policy.conditions.users.excludeGroups.Count -gt 0) { + if ($UserExcludeGroupMembership.ContainsKey($_.ObjectId)) { + foreach ($ExcludeGroupId in $Policy.conditions.users.excludeGroups) { + if ($UserExcludeGroupMembership[$_.ObjectId].Contains($ExcludeGroupId)) { + $IsExcluded = $true + $ExcludedViaGroup = if ($GroupNameLookup.ContainsKey($ExcludeGroupId)) { + $GroupNameLookup[$ExcludeGroupId] + } else { + $ExcludeGroupId + } + break + } + } + } + } + + $PolicyObj = [PSCustomObject]@{ + DisplayName = $Policy.displayName + UserIncluded = -not $IsExcluded + AllApps = ($Policy.conditions.applications.includeApplications -contains 'All') + PolicyState = $Policy.state + } + if ($ExcludedViaGroup) { + $PolicyObj | Add-Member -NotePropertyName 'ExcludedViaGroup' -NotePropertyValue $ExcludedViaGroup + } + $UserCAState.Add($PolicyObj) + } + } + } + # Add policies that apply to this specific user if ($PolicyTable.ContainsKey($_.ObjectId)) { foreach ($Policy in $PolicyTable[$_.ObjectId]) { @@ -243,7 +309,7 @@ function Get-CIPPMFAState { $ExcludedViaGroup = $null # Check exclude groups - if (-not $IsExcluded -and $Policy.conditions.users.excludeGroups -ne $null -and $Policy.conditions.users.excludeGroups.Count -gt 0) { + if (-not $IsExcluded -and $null -ne $Policy.conditions.users.excludeGroups -and $Policy.conditions.users.excludeGroups.Count -gt 0) { if ($UserExcludeGroupMembership.ContainsKey($_.ObjectId)) { foreach ($ExcludeGroupId in $Policy.conditions.users.excludeGroups) { if ($UserExcludeGroupMembership[$_.ObjectId].Contains($ExcludeGroupId)) { @@ -278,8 +344,19 @@ function Get-CIPPMFAState { $IsExcluded = $Policy.conditions.users.excludeUsers -contains $_.ObjectId $ExcludedViaGroup = $null + # Check if guests are excluded from this "All users" policy + if (-not $IsExcluded -and $_.UserType -eq 'Guest') { + $ExcludeGuestConfig = $Policy.conditions.users.excludeGuestsOrExternalUsers + if ($null -ne $ExcludeGuestConfig -and $null -ne $ExcludeGuestConfig.guestOrExternalUserTypes) { + $ExcludeGuestTypes = $ExcludeGuestConfig.guestOrExternalUserTypes -split ',' + if ($ExcludeGuestTypes -contains 'internalGuest') { + $IsExcluded = $true + } + } + } + # Check exclude groups - if (-not $IsExcluded -and $Policy.conditions.users.excludeGroups -ne $null -and $Policy.conditions.users.excludeGroups.Count -gt 0) { + if (-not $IsExcluded -and $null -ne $Policy.conditions.users.excludeGroups -and $Policy.conditions.users.excludeGroups.Count -gt 0) { if ($UserExcludeGroupMembership.ContainsKey($_.ObjectId)) { foreach ($ExcludeGroupId in $Policy.conditions.users.excludeGroups) { if ($UserExcludeGroupMembership[$_.ObjectId].Contains($ExcludeGroupId)) { @@ -344,6 +421,7 @@ function Get-CIPPMFAState { CAPolicies = @($UserCAState) CoveredBySD = $SecureDefaultsState IsAdmin = $IsAdmin + UserType = $_.UserType RowKey = [string]($_.UserPrincipalName).replace('#', '') PartitionKey = 'users' } From 7b08cc05724ee5694e19d3954aa38c090007ccb4 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Mon, 9 Feb 2026 16:37:54 -0800 Subject: [PATCH 032/150] Revive Legacy Report Addin --- ...ke-CIPPStandardLegacyEmailReportAddins.ps1 | 78 ++++++++++--------- 1 file changed, 40 insertions(+), 38 deletions(-) diff --git a/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardLegacyEmailReportAddins.ps1 b/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardLegacyEmailReportAddins.ps1 index 627c88eabc0c..0876db936f1a 100644 --- a/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardLegacyEmailReportAddins.ps1 +++ b/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardLegacyEmailReportAddins.ps1 @@ -31,8 +31,6 @@ function Invoke-CIPPStandardLegacyEmailReportAddins { param($Tenant, $Settings) - #Deprecated, immmediate return - return $true # Define the legacy add-ins to remove $LegacyAddins = @( @{ @@ -48,62 +46,60 @@ function Invoke-CIPPStandardLegacyEmailReportAddins { ) try { - $CurrentApps = New-GraphGetRequest -scope 'https://admin.microsoft.com/.default' -TenantID $Tenant -Uri 'https://admin.microsoft.com/fd/addins/api/apps?workloads=AzureActiveDirectory,WXPO,MetaOS,Teams,SharePoint' - $InstalledApps = $CurrentApps.apps + $CurrentApps = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/applications&select=addins" -TenantID $Tenant + + # Filter to only applications that have the legacy add-ins we're looking for + $LegacyProductIds = $LegacyAddins | ForEach-Object { $_.ProductId } + $InstalledApps = $CurrentApps | Where-Object { + $app = $_ + $app.addIns | Where-Object { $_.id -in $LegacyProductIds } + } + Write-LogMessage -API 'Standards' -Tenant $Tenant -Message "Retrieved $($InstalledApps.Count) applications with legacy add-ins" -Sev Info } catch { $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message Write-LogMessage -API 'Standards' -Tenant $Tenant -Message "Could not get the installed add-ins for $Tenant. Error: $ErrorMessage" -Sev Error return } - # Check which legacy add-ins are currently installed - $AddinsToRemove = [System.Collections.Generic.List[PSCustomObject]]::new() $InstalledLegacyAddins = [System.Collections.Generic.List[string]]::new() - foreach ($LegacyAddin in $LegacyAddins) { - $InstalledAddin = $InstalledApps | Where-Object { $_.assetId -eq $LegacyAddin.AssetId -or $_.productId -eq $LegacyAddin.ProductId } - if ($InstalledAddin) { - $InstalledLegacyAddins.Add($LegacyAddin.Name) - $AddinsToRemove.Add([PSCustomObject]@{ - AppsourceAssetID = $LegacyAddin.AssetId - ProductID = $LegacyAddin.ProductId - Command = 'UNDEPLOY' - Workload = 'WXPO' - }) + foreach ($App in $InstalledApps) { + foreach ($Addin in $App.addIns) { + $LegacyAddin = $LegacyAddins | Where-Object { $_.ProductId -eq $Addin.id } + if ($LegacyAddin) { + $InstalledLegacyAddins.Add($LegacyAddin.Name) + } } } - $StateIsCorrect = ($AddinsToRemove.Count -eq 0) + $StateIsCorrect = ($InstalledApps.Count -eq 0) $RemediationPerformed = $false if ($Settings.remediate -eq $true) { if ($StateIsCorrect -eq $true) { Write-LogMessage -API 'Standards' -Tenant $Tenant -Message 'Legacy Email Report Add-ins are already removed.' -Sev Info } else { - foreach ($AddinToRemove in $AddinsToRemove) { + foreach ($App in $InstalledApps) { try { - $Body = @{ - Locale = 'en-US' - WorkloadManagementList = @($AddinToRemove) - } | ConvertTo-Json -Depth 10 -Compress - + # Delete the application object using Graph API $GraphRequest = @{ - tenantID = $Tenant - uri = 'https://admin.microsoft.com/fd/addins/api/apps' - scope = 'https://admin.microsoft.com/.default' - AsApp = $false - Type = 'POST' - ContentType = 'application/json; charset=utf-8' - Body = $Body + tenantID = $Tenant + uri = "https://graph.microsoft.com/beta/applications/$($App.id)" + Type = 'DELETE' } - $Response = New-GraphPostRequest @GraphRequest - $AddinName = ($LegacyAddins | Where-Object { $_.AssetId -eq $AddinToRemove.AppsourceAssetID }).Name - Write-LogMessage -API 'Standards' -Tenant $Tenant -Message "Successfully initiated removal of $AddinName add-in" -Sev Info + $null = New-GraphPostRequest @GraphRequest + + $RemovedAddins = foreach ($Addin in $App.addIns) { + $LegacyAddin = $LegacyAddins | Where-Object { $_.ProductId -eq $Addin.id } + if ($LegacyAddin) { $LegacyAddin.Name } + } + + $RemovedAddinsText = $RemovedAddins -join ', ' + Write-LogMessage -API 'Standards' -Tenant $Tenant -Message "Successfully removed legacy add-in(s): $RemovedAddinsText (deleted application $($App.displayName))" -Sev Info $RemediationPerformed = $true } catch { - $AddinName = ($LegacyAddins | Where-Object { $_.AssetId -eq $AddinToRemove.AppsourceAssetID }).Name - Write-LogMessage -API 'Standards' -Tenant $Tenant -Message "Failed to remove $AddinName add-in" -Sev Error -LogData $_ + Write-LogMessage -API 'Standards' -Tenant $Tenant -Message "Failed to remove application $($App.displayName)" -Sev Error -LogData $_ } } } @@ -112,13 +108,19 @@ function Invoke-CIPPStandardLegacyEmailReportAddins { # If we performed remediation and need to report/alert, get fresh state if ($RemediationPerformed -and ($Settings.alert -eq $true -or $Settings.report -eq $true)) { try { - $FreshApps = New-GraphGetRequest -scope 'https://admin.microsoft.com/.default' -TenantID $Tenant -Uri 'https://admin.microsoft.com/fd/addins/api/apps?workloads=AzureActiveDirectory,WXPO,MetaOS,Teams,SharePoint' - $FreshInstalledApps = $FreshApps.apps + $FreshApps = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/applications&select=addins" -TenantID $Tenant + $LegacyProductIds = $LegacyAddins | ForEach-Object { $_.ProductId } + $FreshInstalledApps = $FreshApps | Where-Object { + $app = $_ + $app.addIns | Where-Object { $_.id -in $LegacyProductIds } + } # Check fresh state $FreshInstalledLegacyAddins = [System.Collections.Generic.List[string]]::new() foreach ($LegacyAddin in $LegacyAddins) { - $InstalledAddin = $FreshInstalledApps | Where-Object { $_.assetId -eq $LegacyAddin.AssetId -or $_.productId -eq $LegacyAddin.ProductId } + $InstalledAddin = $FreshInstalledApps | Where-Object { + $_.addIns | Where-Object { $_.id -eq $LegacyAddin.ProductId } + } if ($InstalledAddin) { $FreshInstalledLegacyAddins.Add($LegacyAddin.Name) } From 4e8c7a9b1680cb4f9a8fb5ae34a3b22ab7c51351 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Mon, 9 Feb 2026 21:01:43 -0800 Subject: [PATCH 033/150] Update Get-CIPPAlertMFAAdmins.ps1 --- .../CIPPCore/Public/Alerts/Get-CIPPAlertMFAAdmins.ps1 | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertMFAAdmins.ps1 b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertMFAAdmins.ps1 index 32170d84b9d0..8cea32a93caa 100644 --- a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertMFAAdmins.ps1 +++ b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertMFAAdmins.ps1 @@ -20,6 +20,15 @@ function Get-CIPPAlertMFAAdmins { if (!$DuoActive) { $Users = New-GraphGETRequest -uri "https://graph.microsoft.com/beta/reports/authenticationMethods/userRegistrationDetails?`$top=999&filter=IsAdmin eq true and isMfaRegistered eq false and userType eq 'member'&`$select=id,userDisplayName,userPrincipalName,lastUpdatedDateTime,isMfaRegistered,IsAdmin" -tenantid $($TenantFilter) -AsApp $true | Where-Object { $_.userDisplayName -ne 'On-Premises Directory Synchronization Service Account' } + + # Filter out JIT admins if any users were found + if ($Users) { + $Schema = Get-CIPPSchemaExtensions | Where-Object { $_.id -match '_cippUser' } | Select-Object -First 1 + $JITAdmins = New-GraphGETRequest -uri "https://graph.microsoft.com/beta/users?`$select=id,$($Schema.id)&`$filter=$($Schema.id)/jitAdminEnabled eq true" -tenantid $TenantFilter -ComplexFilter + $JITAdminIds = $JITAdmins.id + $Users = $Users | Where-Object { $_.id -notin $JITAdminIds } + } + if ($Users.UserPrincipalName) { $AlertData = foreach ($user in $Users) { [PSCustomObject]@{ From cc5c2c8c2497c8a3147060fd990fc7f864335f64 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Tue, 10 Feb 2026 00:43:54 -0500 Subject: [PATCH 034/150] Add ParticipantGiveRequestControl to Teams policy Introduce the AllowParticipantGiveRequestControl setting to the Teams Global Meeting Policy standard. Updates include help/docs text, a new UI switch, inclusion in the Set-CsTeamsMeetingPolicy example, reading the property from Get-CsTeamsMeetingPolicy, adding it to the current/expected value maps, and including it in the remediation payload and comparison logic so the standard can correctly evaluate and enforce this setting. --- ...nvoke-CIPPStandardTeamsGlobalMeetingPolicy.ps1 | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardTeamsGlobalMeetingPolicy.ps1 b/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardTeamsGlobalMeetingPolicy.ps1 index 7c30eb607332..73b41f0eb62c 100644 --- a/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardTeamsGlobalMeetingPolicy.ps1 +++ b/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardTeamsGlobalMeetingPolicy.ps1 @@ -7,8 +7,8 @@ function Invoke-CIPPStandardTeamsGlobalMeetingPolicy { .SYNOPSIS (Label) Define Global Meeting Policy for Teams .DESCRIPTION - (Helptext) Defines the CIS recommended global meeting policy for Teams. This includes AllowAnonymousUsersToJoinMeeting, AllowAnonymousUsersToStartMeeting, AutoAdmittedUsers, AllowPSTNUsersToBypassLobby, MeetingChatEnabledType, DesignatedPresenterRoleMode, AllowExternalParticipantGiveRequestControl - (DocsDescription) Defines the CIS recommended global meeting policy for Teams. This includes AllowAnonymousUsersToJoinMeeting, AllowAnonymousUsersToStartMeeting, AutoAdmittedUsers, AllowPSTNUsersToBypassLobby, MeetingChatEnabledType, DesignatedPresenterRoleMode, AllowExternalParticipantGiveRequestControl + (Helptext) Defines the CIS recommended global meeting policy for Teams. This includes AllowAnonymousUsersToJoinMeeting, AllowAnonymousUsersToStartMeeting, AutoAdmittedUsers, AllowPSTNUsersToBypassLobby, MeetingChatEnabledType, DesignatedPresenterRoleMode, AllowExternalParticipantGiveRequestControl, AllowParticipantGiveRequestControl + (DocsDescription) Defines the CIS recommended global meeting policy for Teams. This includes AllowAnonymousUsersToJoinMeeting, AllowAnonymousUsersToStartMeeting, AutoAdmittedUsers, AllowPSTNUsersToBypassLobby, MeetingChatEnabledType, DesignatedPresenterRoleMode, AllowExternalParticipantGiveRequestControl, AllowParticipantGiveRequestControl .NOTES CAT Teams Standards @@ -27,12 +27,13 @@ function Invoke-CIPPStandardTeamsGlobalMeetingPolicy { {"type":"autoComplete","required":false,"multiple":false,"creatable":false,"name":"standards.TeamsGlobalMeetingPolicy.AutoAdmittedUsers","label":"Who can bypass the lobby?","helperText":"If left blank, the current value will not be changed.","options":[{"label":"Only organizers and co-organizers","value":"OrganizerOnly"},{"label":"People in organization excluding guests","value":"EveryoneInCompanyExcludingGuests"},{"label":"People who were invited","value":"InvitedUsers"}]} {"type":"autoComplete","required":true,"multiple":false,"creatable":false,"name":"standards.TeamsGlobalMeetingPolicy.MeetingChatEnabledType","label":"Meeting chat policy","options":[{"label":"On for everyone","value":"Enabled"},{"label":"On for everyone but anonymous users","value":"EnabledExceptAnonymous"},{"label":"Off for everyone","value":"Disabled"}]} {"type":"switch","name":"standards.TeamsGlobalMeetingPolicy.AllowExternalParticipantGiveRequestControl","label":"External participants can give or request control"} + {"type":"switch","name":"standards.TeamsGlobalMeetingPolicy.AllowParticipantGiveRequestControl","label":"Participants can give or request control"} IMPACT Low Impact ADDEDDATE 2024-11-12 POWERSHELLEQUIVALENT - Set-CsTeamsMeetingPolicy -AllowAnonymousUsersToJoinMeeting \$false -AllowAnonymousUsersToStartMeeting \$false -AutoAdmittedUsers \$AutoAdmittedUsers -AllowPSTNUsersToBypassLobby \$false -MeetingChatEnabledType EnabledExceptAnonymous -DesignatedPresenterRoleMode \$DesignatedPresenterRoleMode -AllowExternalParticipantGiveRequestControl \$false + Set-CsTeamsMeetingPolicy -AllowAnonymousUsersToJoinMeeting \$false -AllowAnonymousUsersToStartMeeting \$false -AutoAdmittedUsers \$AutoAdmittedUsers -AllowPSTNUsersToBypassLobby \$false -MeetingChatEnabledType EnabledExceptAnonymous -DesignatedPresenterRoleMode \$DesignatedPresenterRoleMode -AllowExternalParticipantGiveRequestControl \$false -AllowParticipantGiveRequestControl \$false RECOMMENDEDBY "CIS" UPDATECOMMENTBLOCK @@ -49,7 +50,7 @@ function Invoke-CIPPStandardTeamsGlobalMeetingPolicy { try { $CurrentState = New-TeamsRequest -TenantFilter $Tenant -Cmdlet 'Get-CsTeamsMeetingPolicy' -CmdParams @{Identity = 'Global' } | - Select-Object AllowAnonymousUsersToJoinMeeting, AllowAnonymousUsersToStartMeeting, AutoAdmittedUsers, AllowPSTNUsersToBypassLobby, MeetingChatEnabledType, DesignatedPresenterRoleMode, AllowExternalParticipantGiveRequestControl + Select-Object AllowAnonymousUsersToJoinMeeting, AllowAnonymousUsersToStartMeeting, AutoAdmittedUsers, AllowPSTNUsersToBypassLobby, MeetingChatEnabledType, DesignatedPresenterRoleMode, AllowExternalParticipantGiveRequestControl, AllowParticipantGiveRequestControl } catch { $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message Write-LogMessage -API 'Standards' -Tenant $Tenant -Message "Could not get the TeamsGlobalMeetingPolicy state for $Tenant. Error: $ErrorMessage" -Sev Error @@ -66,7 +67,8 @@ function Invoke-CIPPStandardTeamsGlobalMeetingPolicy { ($CurrentState.AllowPSTNUsersToBypassLobby -eq $false) -and ($CurrentState.MeetingChatEnabledType -eq $MeetingChatEnabledType) -and ($CurrentState.DesignatedPresenterRoleMode -eq $DesignatedPresenterRoleMode) -and - ($CurrentState.AllowExternalParticipantGiveRequestControl -eq $Settings.AllowExternalParticipantGiveRequestControl) + ($CurrentState.AllowExternalParticipantGiveRequestControl -eq $Settings.AllowExternalParticipantGiveRequestControl) -and + ($CurrentState.AllowParticipantGiveRequestControl -eq $Settings.AllowParticipantGiveRequestControl) if ($Settings.remediate -eq $true) { @@ -82,6 +84,7 @@ function Invoke-CIPPStandardTeamsGlobalMeetingPolicy { MeetingChatEnabledType = $MeetingChatEnabledType DesignatedPresenterRoleMode = $DesignatedPresenterRoleMode AllowExternalParticipantGiveRequestControl = $Settings.AllowExternalParticipantGiveRequestControl + AllowParticipantGiveRequestControl = $Settings.AllowParticipantGiveRequestControl } try { @@ -113,6 +116,7 @@ function Invoke-CIPPStandardTeamsGlobalMeetingPolicy { MeetingChatEnabledType = $CurrentState.MeetingChatEnabledType DesignatedPresenterRoleMode = $CurrentState.DesignatedPresenterRoleMode AllowExternalParticipantGiveRequestControl = $CurrentState.AllowExternalParticipantGiveRequestControl + AllowParticipantGiveRequestControl = $CurrentState.AllowParticipantGiveRequestControl } $ExpectedValue = @{ AllowAnonymousUsersToJoinMeeting = $Settings.AllowAnonymousUsersToJoinMeeting @@ -122,6 +126,7 @@ function Invoke-CIPPStandardTeamsGlobalMeetingPolicy { MeetingChatEnabledType = $MeetingChatEnabledType DesignatedPresenterRoleMode = $DesignatedPresenterRoleMode AllowExternalParticipantGiveRequestControl = $Settings.AllowExternalParticipantGiveRequestControl + AllowParticipantGiveRequestControl = $Settings.AllowParticipantGiveRequestControl } Set-CIPPStandardsCompareField -FieldName 'standards.TeamsGlobalMeetingPolicy' -CurrentValue $CurrentValue -ExpectedValue $ExpectedValue -Tenant $Tenant Add-CIPPBPAField -FieldName 'TeamsGlobalMeetingPolicy' -FieldValue $StateIsCorrect -StoreAs bool -Tenant $Tenant From 7db9133064f6870cdc13e5e826c92bec88939ea7 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Mon, 9 Feb 2026 21:47:49 -0800 Subject: [PATCH 035/150] Use reporting DB for signin report insead of lighthouse making it possible for all users to access --- .../Reports/Invoke-ListInactiveAccounts.ps1 | 136 ++++++++++++++++-- .../CIPPCore/Public/Set-CIPPDBCacheUsers.ps1 | 3 +- 2 files changed, 128 insertions(+), 11 deletions(-) diff --git a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Identity/Reports/Invoke-ListInactiveAccounts.ps1 b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Identity/Reports/Invoke-ListInactiveAccounts.ps1 index a7f74435d3dc..382218496777 100644 --- a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Identity/Reports/Invoke-ListInactiveAccounts.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Identity/Reports/Invoke-ListInactiveAccounts.ps1 @@ -7,21 +7,48 @@ Function Invoke-ListInactiveAccounts { #> [CmdletBinding()] param($Request, $TriggerMetadata) - # Convert the TenantFilter parameter to a list of tenant IDs for AllTenants or a single tenant ID + + $APIName = 'ListInactiveAccounts' $TenantFilter = $Request.Query.tenantFilter - if ($TenantFilter -eq 'AllTenants') { - $TenantFilter = (Get-Tenants).customerId - } else { - $TenantFilter = (Get-Tenants -TenantFilter $TenantFilter).customerId - } + $InactiveDays = if ($Request.Query.InactiveDays) { [int]$Request.Query.InactiveDays } else { 180 } try { - $GraphRequest = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/tenantRelationships/managedTenants/inactiveUsers?`$count=true" -tenantid $env:TenantID | Where-Object { $_.tenantId -in $TenantFilter } + $Lookup = (Get-Date).AddDays(-$InactiveDays).ToUniversalTime() + + if ($TenantFilter -eq 'AllTenants') { + # Get all tenants that have user data + $AllUserItems = Get-CIPPDbItem -TenantFilter 'allTenants' -Type 'Users' + $Tenants = @($AllUserItems | Where-Object { $_.RowKey -ne 'Users-Count' } | Select-Object -ExpandProperty PartitionKey -Unique) + + $TenantList = Get-Tenants -IncludeErrors + $Tenants = $Tenants | Where-Object { $TenantList.defaultDomainName -contains $_ } + + $AllResults = [System.Collections.Generic.List[PSCustomObject]]::new() + + foreach ($Tenant in $Tenants) { + try { + Write-Information "Processing tenant: $Tenant" + $TenantResults = Get-InactiveUsersFromDB -TenantFilter $Tenant -InactiveDays $InactiveDays -Lookup $Lookup + + foreach ($Result in $TenantResults) { + $AllResults.Add($Result) + } + } catch { + Write-LogMessage -API $APIName -tenant $Tenant -message "Failed to get inactive users: $($_.Exception.Message)" -sev Warning + } + } + + $GraphRequest = @($AllResults) + } else { + $GraphRequest = Get-InactiveUsersFromDB -TenantFilter $TenantFilter -InactiveDays $InactiveDays -Lookup $Lookup + } + $StatusCode = [HttpStatusCode]::OK } catch { - $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message - $StatusCode = [HttpStatusCode]::Forbidden - $GraphRequest = "Could not connect to Azure Lighthouse API: $($ErrorMessage)" + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API $APIName -tenant $TenantFilter -message "Failed to retrieve inactive accounts: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage + $StatusCode = [HttpStatusCode]::InternalServerError + $GraphRequest = @{ Error = $ErrorMessage.NormalizedError } } return ([HttpResponseContext]@{ @@ -29,3 +56,92 @@ Function Invoke-ListInactiveAccounts { Body = @($GraphRequest) }) } + +# Helper function to get inactive users from the database for a specific tenant +function Get-InactiveUsersFromDB { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter, + + [Parameter(Mandatory = $true)] + [int]$InactiveDays, + + [Parameter(Mandatory = $true)] + [DateTime]$Lookup + ) + + # Get users from database + $Users = New-CIPPDbRequest -TenantFilter $TenantFilter -Type 'Users' + + if (-not $Users) { + Write-Information "No user data found in database for tenant $TenantFilter" + return @() + } + + # Get tenant info for display name + $TenantInfo = Get-Tenants -TenantFilter $TenantFilter | Select-Object -First 1 + $TenantDisplayName = $TenantInfo.displayName ?? $TenantFilter + + $InactiveUsers = foreach ($User in $Users) { + # Skip disabled users by default + if ($User.accountEnabled -eq $false) { continue } + + # Skip guest users + if ($User.userType -eq 'Guest') { continue } + + # Determine last sign-in + $lastInteractive = $User.signInActivity.lastSignInDateTime + $lastNonInteractive = $User.signInActivity.lastNonInteractiveSignInDateTime + + $lastSignIn = $null + if ($lastInteractive -and $lastNonInteractive) { + $lastSignIn = if ([DateTime]$lastInteractive -gt [DateTime]$lastNonInteractive) { + $lastInteractive + } else { + $lastNonInteractive + } + } elseif ($lastInteractive) { + $lastSignIn = $lastInteractive + } elseif ($lastNonInteractive) { + $lastSignIn = $lastNonInteractive + } + + # Check if user is inactive + $isInactive = (-not $lastSignIn) -or ([DateTime]$lastSignIn -le $Lookup) + + if ($isInactive) { + # Calculate days since last sign-in + $daysSinceSignIn = if ($lastSignIn) { + [Math]::Round(((Get-Date) - [DateTime]$lastSignIn).TotalDays) + } else { + $null + } + + # Count assigned licenses + $numberOfAssignedLicenses = if ($User.assignedLicenses) { + $User.assignedLicenses.Count + } else { + 0 + } + + [PSCustomObject]@{ + tenantId = $TenantFilter + tenantDisplayName = $TenantDisplayName + azureAdUserId = $User.id + userPrincipalName = $User.userPrincipalName + displayName = $User.displayName + userType = $User.userType + createdDateTime = $User.createdDateTime + lastSignInDateTime = $lastInteractive + lastNonInteractiveSignInDateTime = $lastNonInteractive + lastRefreshedDateTime = (Get-Date).ToString('yyyy-MM-ddTHH:mm:ss.fffZ') + numberOfAssignedLicenses = $numberOfAssignedLicenses + daysSinceLastSignIn = $daysSinceSignIn + accountEnabled = $User.accountEnabled + } + } + } + + return @($InactiveUsers) +} diff --git a/Modules/CIPPCore/Public/Set-CIPPDBCacheUsers.ps1 b/Modules/CIPPCore/Public/Set-CIPPDBCacheUsers.ps1 index 72f498142664..6fd9623adb45 100644 --- a/Modules/CIPPCore/Public/Set-CIPPDBCacheUsers.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPDBCacheUsers.ps1 @@ -16,7 +16,8 @@ function Set-CIPPDBCacheUsers { Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching users' -sev Debug # Stream users directly from Graph API to batch processor - New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/users?$top=999' -tenantid $TenantFilter | + # Using $top=500 due to signInActivity limitation + New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/users?$top=500&$select=signInActivity' -tenantid $TenantFilter | Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'Users' -AddCount Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached users successfully' -sev Debug From 34a76da47b37f929705d348387f106618aa53b3c Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Tue, 10 Feb 2026 09:24:52 +0100 Subject: [PATCH 036/150] added props --- Modules/CIPPCore/Public/Compare-CIPPIntuneObject.ps1 | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Modules/CIPPCore/Public/Compare-CIPPIntuneObject.ps1 b/Modules/CIPPCore/Public/Compare-CIPPIntuneObject.ps1 index 7a51151cbc90..b974f4bafbe7 100644 --- a/Modules/CIPPCore/Public/Compare-CIPPIntuneObject.ps1 +++ b/Modules/CIPPCore/Public/Compare-CIPPIntuneObject.ps1 @@ -33,7 +33,8 @@ function Compare-CIPPIntuneObject { 'tenantFilter', 'agents', 'isSynced' - 'locationInfo' + 'locationInfo', + 'templateId' ) $excludeProps = $defaultExcludeProperties + $ExcludeProperties From 4fd9970c16b56e57f33cdb6eb88227ca1f6ae574 Mon Sep 17 00:00:00 2001 From: Steven van Beek Date: Tue, 10 Feb 2026 14:00:23 +0100 Subject: [PATCH 037/150] added new alerts --- .../Get-CIPPAlertInactiveGuestUsers.ps1 | 100 ++++++++++++++++++ .../Alerts/Get-CIPPAlertInactiveUsers.ps1 | 91 ++++++++++++++++ .../Alerts/Get-CIPPAlertStaleEntraDevices.ps1 | 93 ++++++++++++++++ 3 files changed, 284 insertions(+) create mode 100644 Modules/CIPPCore/Public/Alerts/Get-CIPPAlertInactiveGuestUsers.ps1 create mode 100644 Modules/CIPPCore/Public/Alerts/Get-CIPPAlertInactiveUsers.ps1 create mode 100644 Modules/CIPPCore/Public/Alerts/Get-CIPPAlertStaleEntraDevices.ps1 diff --git a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertInactiveGuestUsers.ps1 b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertInactiveGuestUsers.ps1 new file mode 100644 index 000000000000..1109cd5e5bc0 --- /dev/null +++ b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertInactiveGuestUsers.ps1 @@ -0,0 +1,100 @@ +function Get-CIPPAlertInactiveGuestUsers { + <# + .FUNCTIONALITY + Entrypoint + #> + [CmdletBinding()] + param ( + [Parameter(Mandatory = $false)] + [Alias('input')] + $InputValue, + [Parameter(Mandatory = $false)] + [switch]$IncludeNeverSignedIn, # Include users who have never signed in (default is to skip them), future use would allow this to be set in an alert configuration + $TenantFilter + ) + + try { + try { + $inactiveDays = 90 + $excludeDisabled = $false + + if ($InputValue -is [hashtable] -or $InputValue -is [pscustomobject]) { + $excludeDisabled = [bool]$InputValue.ExcludeDisabled + if ($null -ne $InputValue.DaysSinceLastLogin -and $InputValue.DaysSinceLastLogin -ne '') { + $parsedDays = 0 + if ([int]::TryParse($InputValue.DaysSinceLastLogin.ToString(), [ref]$parsedDays) -and $parsedDays -gt 0) { + $inactiveDays = $parsedDays + } + } + } + elseif ($InputValue -eq $true) { + # Backwards compatibility: legacy single-input boolean means exclude disabled users + $excludeDisabled = $true + } + + $Lookup = (Get-Date).AddDays(-$inactiveDays).ToUniversalTime() + Write-Host "Checking for guest users inactive since $Lookup (excluding disabled: $excludeDisabled)" + # Build base filter - cannot filter assignedLicenses server-side + $BaseFilter = if ($excludeDisabled) { 'accountEnabled eq true' } else { '' } + + $Uri = if ($BaseFilter) { + "https://graph.microsoft.com/beta/users?`$filter=$BaseFilter&`$select=id,UserPrincipalName,signInActivity,mail,userType,accountEnabled,assignedLicenses" + } + else { + "https://graph.microsoft.com/beta/users?`$select=id,UserPrincipalName,signInActivity,mail,userType,accountEnabled,assignedLicenses" + } + + $GraphRequest = New-GraphGetRequest -uri $Uri -scope 'https://graph.microsoft.com/.default' -tenantid $TenantFilter | + Where-Object { $_.userType -eq 'Guest' } + + $AlertData = foreach ($user in $GraphRequest) { + $lastInteractive = $user.signInActivity.lastSignInDateTime + $lastNonInteractive = $user.signInActivity.lastNonInteractiveSignInDateTime + + # Find most recent sign-in + $lastSignIn = $null + if ($lastInteractive -and $lastNonInteractive) { + $lastSignIn = if ([DateTime]$lastInteractive -gt [DateTime]$lastNonInteractive) { $lastInteractive } else { $lastNonInteractive } + } + elseif ($lastInteractive) { + $lastSignIn = $lastInteractive + } + elseif ($lastNonInteractive) { + $lastSignIn = $lastNonInteractive + } + + # Check if inactive + $isInactive = (-not $lastSignIn) -or ([DateTime]$lastSignIn -le $Lookup) + # Skip users who have never signed in by default (unless IncludeNeverSignedIn is specified) + if (-not $IncludeNeverSignedIn -and -not $lastSignIn) { continue } + # Only process inactive users + if ($isInactive) { + + if (-not $lastSignIn) { + $Message = 'Guest user {0} has never signed in.' -f $user.UserPrincipalName + } + else { + $daysSinceSignIn = [Math]::Round(((Get-Date) - [DateTime]$lastSignIn).TotalDays) + $Message = 'Guest user {0} has been inactive for {1} days. Last sign-in: {2}' -f $user.UserPrincipalName, $daysSinceSignIn, $lastSignIn + } + + + [PSCustomObject]@{ + UserPrincipalName = $user.UserPrincipalName + Id = $user.id + lastSignIn = $lastSignIn + DaysSinceLastSignIn = if ($daysSinceSignIn) { $daysSinceSignIn } else { 'N/A' } + Message = $Message + Tenant = $TenantFilter + } + } + } + + 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)" + } +} diff --git a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertInactiveUsers.ps1 b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertInactiveUsers.ps1 new file mode 100644 index 000000000000..0a42e8346cce --- /dev/null +++ b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertInactiveUsers.ps1 @@ -0,0 +1,91 @@ +function Get-CIPPAlertInactiveUsers { + <# + .FUNCTIONALITY + Entrypoint + #> + [CmdletBinding()] + param ( + [Parameter(Mandatory = $false)] + [Alias('input')] + $InputValue, + [Parameter(Mandatory = $false)] + [switch]$IncludeNeverSignedIn, # Include users who have never signed in (default is to skip them), future use would allow this to be set in an alert configuration + $TenantFilter + ) + + try { + try { + $inactiveDays = 90 + $excludeDisabled = $false + + if ($InputValue -is [hashtable] -or $InputValue -is [pscustomobject]) { + $excludeDisabled = [bool]$InputValue.ExcludeDisabled + if ($null -ne $InputValue.DaysSinceLastLogin -and $InputValue.DaysSinceLastLogin -ne '') { + $parsedDays = 0 + if ([int]::TryParse($InputValue.DaysSinceLastLogin.ToString(), [ref]$parsedDays) -and $parsedDays -gt 0) { + $inactiveDays = $parsedDays + } + } + } elseif ($InputValue -eq $true) { + # Backwards compatibility: legacy single-input boolean means exclude disabled users + $excludeDisabled = $true + } + + $Lookup = (Get-Date).AddDays(-$inactiveDays).ToUniversalTime() + Write-Host "Checking for users inactive since $Lookup (excluding disabled: $excludeDisabled)" + # Build base filter - cannot filter accountEnabled server-side + $BaseFilter = if ($excludeDisabled) { 'accountEnabled eq true' } else { '' } + + $Uri = if ($BaseFilter) { + "https://graph.microsoft.com/beta/users?`$filter=$BaseFilter&`$select=id,UserPrincipalName,signInActivity,mail,userType,accountEnabled,assignedLicenses" + } else { + "https://graph.microsoft.com/beta/users?`$select=id,UserPrincipalName,signInActivity,mail,userType,accountEnabled,assignedLicenses" + } + + $GraphRequest = New-GraphGetRequest -uri $Uri -scope 'https://graph.microsoft.com/.default' -tenantid $TenantFilter | + Where-Object { $_.userType -eq 'Member' } + + $AlertData = foreach ($user in $GraphRequest) { + $lastInteractive = $user.signInActivity.lastSignInDateTime + $lastNonInteractive = $user.signInActivity.lastNonInteractiveSignInDateTime + + # Find most recent sign-in + $lastSignIn = $null + if ($lastInteractive -and $lastNonInteractive) { + $lastSignIn = if ([DateTime]$lastInteractive -gt [DateTime]$lastNonInteractive) { $lastInteractive } else { $lastNonInteractive } + } elseif ($lastInteractive) { + $lastSignIn = $lastInteractive + } elseif ($lastNonInteractive) { + $lastSignIn = $lastNonInteractive + } + + # Check if inactive + $isInactive = (-not $lastSignIn) -or ([DateTime]$lastSignIn -le $Lookup) + # Skip users who have never signed in by default (unless IncludeNeverSignedIn is specified) + if (-not $IncludeNeverSignedIn -and -not $lastSignIn) { continue } + # Only process inactive users + if ($isInactive) { + if (-not $lastSignIn) { + $Message = 'User {0} has never signed in.' -f $user.UserPrincipalName + } else { + $daysSinceSignIn = [Math]::Round(((Get-Date) - [DateTime]$lastSignIn).TotalDays) + $Message = 'User {0} has been inactive for {1} days. Last sign-in: {2}' -f $user.UserPrincipalName, $daysSinceSignIn, $lastSignIn + } + + [PSCustomObject]@{ + UserPrincipalName = $user.UserPrincipalName + Id = $user.id + lastSignIn = $lastSignIn + DaysSinceLastSignIn = if ($daysSinceSignIn) { $daysSinceSignIn } else { 'N/A' } + Message = $Message + Tenant = $TenantFilter + } + } + } + + 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)" + } +} diff --git a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertStaleEntraDevices.ps1 b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertStaleEntraDevices.ps1 new file mode 100644 index 000000000000..2c308fb00e05 --- /dev/null +++ b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertStaleEntraDevices.ps1 @@ -0,0 +1,93 @@ +function Get-CIPPAlertStaleEntraDevices { + <# + .FUNCTIONALITY + Entrypoint + #> + [CmdletBinding()] + param ( + [Parameter(Mandatory = $false)] + [Alias('input')] + $InputValue, + $TenantFilter + ) + + try { + try { + $inactiveDays = 90 + + if ($InputValue -is [hashtable] -or $InputValue -is [pscustomobject]) { + $excludeDisabled = [bool]$InputValue.ExcludeDisabled + if ($null -ne $InputValue.DaysSinceLastActivity -and $InputValue.DaysSinceLastActivity -ne '') { + $parsedDays = 0 + if ([int]::TryParse($InputValue.DaysSinceLastActivity.ToString(), [ref]$parsedDays) -and $parsedDays -gt 0) { + $inactiveDays = $parsedDays + } + } + } + elseif ($InputValue -eq $true) { + # Backwards compatibility: legacy single-input boolean means exclude disabled users + $excludeDisabled = $true + } + + $Lookup = (Get-Date).AddDays(-$inactiveDays).ToUniversalTime() + Write-Host "Checking for inactive Entra devices since $Lookup (excluding disabled: $excludeDisabled)" + # Build base filter - cannot filter accountEnabled server-side + $BaseFilter = if ($excludeDisabled) { 'accountEnabled eq true' } else { '' } + + $Uri = if ($BaseFilter) { + "https://graph.microsoft.com/beta/devices?`$filter=$BaseFilter" + } + else { + "https://graph.microsoft.com/beta/devices" + } + + $GraphRequest = New-GraphGetRequest -uri $Uri -scope 'https://graph.microsoft.com/.default' -tenantid $TenantFilter + + $AlertData = foreach ($device in $GraphRequest) { + + $lastActivity = $device.approximateLastSignInDateTime + + $isInactive = (-not $lastActivity) -or ([DateTime]$lastActivity -le $Lookup) + # Only process stale Entra devices + if ($isInactive) { + + if (-not $lastActivity) { + + $Message = 'Device {0} has never been active' -f $device.displayName + } + else { + $daysSinceLastActivity = [Math]::Round(((Get-Date) - [DateTime]$lastActivity).TotalDays) + $Message = 'Device {0} has been inactive for {1} days. Last activity: {2}' -f $device.displayName, $daysSinceLastActivity, $lastActivity + } + + if ($device.TrustType -eq "Workplace") { $TrustType = "Entra registered" } + elseif ($device.TrustType -eq "AzureAd") { $TrustType = "Entra joined" } + elseif ($device.TrustType -eq "ServerAd") { $TrustType = "Entra hybrid joined" } + + [PSCustomObject]@{ + DeviceName = if ($device.displayName) { $device.displayName } else { 'N/A' } + Id = if ($device.id) { $device.id } else { 'N/A' } + deviceOwnership = if ($device.deviceOwnership) { $device.deviceOwnership } else { 'N/A' } + operatingSystem = if ($device.operatingSystem) { $device.operatingSystem } else { 'N/A' } + enrollmentType = if ($device.enrollmentType) { $device.enrollmentType } else { 'N/A' } + Enabled = if ($device.accountEnabled) { $device.accountEnabled } else { 'N/A' } + Managed = if ($device.isManaged) { $device.isManaged } else { 'N/A' } + Complaint = if ($device.isCompliant) { $device.isCompliant } else { 'N/A' } + JoinType = $TrustType + lastActivity = if ($lastActivity) { $lastActivity } else { 'N/A' } + DaysSinceLastActivity = if ($daysSinceLastActivity) { $daysSinceLastActivity } else { 'N/A' } + RegisteredDateTime = if ($device.createdDateTime) { $device.createdDateTime } else { 'N/A' } + Message = $Message + Tenant = $TenantFilter + } + } + } + + 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)" + } +} From c2de084839dbeb2f4d342a7dc747ca0c4a6d15df Mon Sep 17 00:00:00 2001 From: James Tarran Date: Tue, 10 Feb 2026 15:22:37 +0000 Subject: [PATCH 038/150] Update Search-GitHub.ps1 - Fixed forks not appearing by adding a switch to include them when building the query --- Modules/CippExtensions/Public/GitHub/Search-GitHub.ps1 | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Modules/CippExtensions/Public/GitHub/Search-GitHub.ps1 b/Modules/CippExtensions/Public/GitHub/Search-GitHub.ps1 index c630d524c149..c44b0037ea1d 100644 --- a/Modules/CippExtensions/Public/GitHub/Search-GitHub.ps1 +++ b/Modules/CippExtensions/Public/GitHub/Search-GitHub.ps1 @@ -5,6 +5,7 @@ function Search-GitHub { [string]$User, [string]$Org, [string]$Path, + [bool]$includeforks = $false, [string[]]$SearchTerm, [string]$Language, [ValidateSet('code', 'commits', 'issues', 'users', 'repositories', 'topics', 'labels')] @@ -46,6 +47,7 @@ function Search-GitHub { if ($Language) { $QueryParts.Add("language:$Language") } + $QueryParts.Add("fork:$($includeforks.ToString().ToLower())") $Query = $QueryParts -join ' ' Write-Information "Query: $Query" From facce9bf5a80658a65a0aa9bbeb57ff492fe3d9a Mon Sep 17 00:00:00 2001 From: James Tarran Date: Tue, 10 Feb 2026 16:02:43 +0000 Subject: [PATCH 039/150] Search-GitHub.ps1 - Fixed forks not appearing by adding a switch to include them when building the query From 916e41dc1400d9e2d6a58d7061b2f6fa5b0690d6 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Tue, 10 Feb 2026 11:35:59 -0500 Subject: [PATCH 040/150] Optimize github api calls for if extension is not enabled --- .../Tools/GitHub/Invoke-ExecGitHubAction.ps1 | 127 ++++++++---------- .../Public/GitHub/Search-GitHub.ps1 | 8 +- 2 files changed, 64 insertions(+), 71 deletions(-) diff --git a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Tools/GitHub/Invoke-ExecGitHubAction.ps1 b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Tools/GitHub/Invoke-ExecGitHubAction.ps1 index f03b641719dd..709e3a6b076f 100644 --- a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Tools/GitHub/Invoke-ExecGitHubAction.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Tools/GitHub/Invoke-ExecGitHubAction.ps1 @@ -22,85 +22,76 @@ function Invoke-ExecGitHubAction { $SplatParams = $Parameters | Select-Object -ExcludeProperty Action, TenantFilter | ConvertTo-Json | ConvertFrom-Json -AsHashtable - $Table = Get-CIPPTable -TableName Extensionsconfig - $Configuration = ((Get-CIPPAzDataTableEntity @Table).config | ConvertFrom-Json -ErrorAction SilentlyContinue).GitHub - - if (!$Configuration.Enabled) { - $Response = Invoke-RestMethod -Uri 'https://cippy.azurewebsites.net/api/ExecGitHubAction' -Method POST -Body ($Parameters | ConvertTo-Json -Depth 10) -ContentType 'application/json' - $Results = $Response.Results - $Metadata = $Response.Metadata - } else { - switch ($Action) { - 'Search' { - $SearchResults = Search-GitHub @SplatParams - $Results = @($SearchResults.items) - $Metadata = $SearchResults | Select-Object -Property total_count, incomplete_results - } - 'GetFileContents' { - $Results = Get-GitHubFileContents @SplatParams - } - 'GetBranches' { - $Results = @(Get-GitHubBranch @SplatParams) - } - 'GetOrgs' { - try { - $Orgs = Invoke-GitHubApiRequest -Path 'user/orgs' - $Results = @($Orgs) - } catch { - $Results = @{ - resultText = 'You may not have permission to view organizations, check your PAT scopes and try again - {0}' -f $_.Exception.Message - state = 'error' - } + switch ($Action) { + 'Search' { + $SearchResults = Search-GitHub @SplatParams + $Results = @($SearchResults.items) + $Metadata = $SearchResults | Select-Object -Property total_count, incomplete_results + } + 'GetFileContents' { + $Results = Get-GitHubFileContents @SplatParams + } + 'GetBranches' { + $Results = @(Get-GitHubBranch @SplatParams) + } + 'GetOrgs' { + try { + $Orgs = Invoke-GitHubApiRequest -Path 'user/orgs' + $Results = @($Orgs) + } catch { + $Results = @{ + resultText = 'You may not have permission to view organizations, check your PAT scopes and try again - {0}' -f $_.Exception.Message + state = 'error' } } - 'GetFileTree' { - $Files = (Get-GitHubFileTree @SplatParams).tree | Where-Object { $_.path -match '.json$' } | Select-Object *, @{n = 'html_url'; e = { "https://github.com/$($SplatParams.FullName)/tree/$($SplatParams.Branch)/$($_.path)" } } - $Results = @($Files) - } - 'ImportTemplate' { - $Results = Import-CommunityTemplate @SplatParams - } - 'CreateRepo' { - try { - Write-Information "Creating repository '$($SplatParams.Name)'" - $Repo = New-GitHubRepo @SplatParams - if ($Repo.id) { - $Table = Get-CIPPTable -TableName CommunityRepos - $RepoEntity = @{ - PartitionKey = 'CommunityRepos' - RowKey = [string]$Repo.id - Name = [string]($Repo.name -replace ' ', '-') - Description = [string]$Repo.description - URL = [string]$Repo.html_url - FullName = [string]$Repo.full_name - Owner = [string]$Repo.owner.login - Visibility = [string]$Repo.visibility - WriteAccess = [bool]$Repo.permissions.push - DefaultBranch = [string]$Repo.default_branch - Permissions = [string]($Repo.permissions | ConvertTo-Json -Compress) - } - Add-CIPPAzDataTableEntity @Table -Entity $RepoEntity -Force | Out-Null - - $Results = @{ - resultText = "Repository '$($Repo.name)' created" - state = 'success' - } + } + 'GetFileTree' { + $Files = (Get-GitHubFileTree @SplatParams).tree | Where-Object { $_.path -match '.json$' } | Select-Object *, @{n = 'html_url'; e = { "https://github.com/$($SplatParams.FullName)/tree/$($SplatParams.Branch)/$($_.path)" } } + $Results = @($Files) + } + 'ImportTemplate' { + $Results = Import-CommunityTemplate @SplatParams + } + 'CreateRepo' { + try { + Write-Information "Creating repository '$($SplatParams.Name)'" + $Repo = New-GitHubRepo @SplatParams + if ($Repo.id) { + $Table = Get-CIPPTable -TableName CommunityRepos + $RepoEntity = @{ + PartitionKey = 'CommunityRepos' + RowKey = [string]$Repo.id + Name = [string]($Repo.name -replace ' ', '-') + Description = [string]$Repo.description + URL = [string]$Repo.html_url + FullName = [string]$Repo.full_name + Owner = [string]$Repo.owner.login + Visibility = [string]$Repo.visibility + WriteAccess = [bool]$Repo.permissions.push + DefaultBranch = [string]$Repo.default_branch + Permissions = [string]($Repo.permissions | ConvertTo-Json -Compress) } - } catch { - Write-Information (Get-CippException -Exception $_ | ConvertTo-Json) + Add-CIPPAzDataTableEntity @Table -Entity $RepoEntity -Force | Out-Null + $Results = @{ - resultText = 'You may not have permission to create repositories, check your PAT scopes and try again - {0}' -f $_.Exception.Message - state = 'error' + resultText = "Repository '$($Repo.name)' created" + state = 'success' } } - } - default { + } catch { + Write-Information (Get-CippException -Exception $_ | ConvertTo-Json) $Results = @{ - resultText = "Unknown action '$Action'" + resultText = 'You may not have permission to create repositories, check your PAT scopes and try again - {0}' -f $_.Exception.Message state = 'error' } } } + default { + $Results = @{ + resultText = "Unknown action '$Action'" + state = 'error' + } + } } $Body = @{ diff --git a/Modules/CippExtensions/Public/GitHub/Search-GitHub.ps1 b/Modules/CippExtensions/Public/GitHub/Search-GitHub.ps1 index c44b0037ea1d..f85131a38154 100644 --- a/Modules/CippExtensions/Public/GitHub/Search-GitHub.ps1 +++ b/Modules/CippExtensions/Public/GitHub/Search-GitHub.ps1 @@ -1,11 +1,11 @@ function Search-GitHub { [CmdletBinding()] - Param ( + param ( [string[]]$Repository, [string]$User, [string]$Org, [string]$Path, - [bool]$includeforks = $false, + [switch]$IncludeForks, [string[]]$SearchTerm, [string]$Language, [ValidateSet('code', 'commits', 'issues', 'users', 'repositories', 'topics', 'labels')] @@ -47,7 +47,9 @@ function Search-GitHub { if ($Language) { $QueryParts.Add("language:$Language") } - $QueryParts.Add("fork:$($includeforks.ToString().ToLower())") + if ($IncludeForks.IsPresent) { + $QueryParts.Add('fork:true') + } $Query = $QueryParts -join ' ' Write-Information "Query: $Query" From 19f9d7f8a01ef29504606c9fa4ee7edb2ba4cccd Mon Sep 17 00:00:00 2001 From: mpressley-np Date: Tue, 10 Feb 2026 12:42:13 -0500 Subject: [PATCH 041/150] Fix for Issue#5340 https://github.com/KelvinTegelaar/CIPP/issues/5340 --- .../Public/Standards/Invoke-CIPPStandardOauthConsentLowSec.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardOauthConsentLowSec.ps1 b/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardOauthConsentLowSec.ps1 index 2bce9422a74b..e28c87dc27e9 100644 --- a/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardOauthConsentLowSec.ps1 +++ b/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardOauthConsentLowSec.ps1 @@ -119,7 +119,7 @@ function Invoke-CIPPStandardOauthConsentLowSec { } $ExpectedValue = @{ - permissionGrantPolicyIdsAssignedToDefaultUserRole = @('managePermissionGrantsForSelf.microsoft-user-default-low') + permissionGrantPolicyIdsAssignedToDefaultUserRole = @('ManagePermissionGrantsForSelf.microsoft-user-default-low') } Add-CIPPBPAField -FieldName 'OauthConsentLowSec' -FieldValue $State.permissionGrantPolicyIdsAssignedToDefaultUserRole -StoreAs bool -Tenant $tenant Set-CIPPStandardsCompareField -FieldName 'standards.OauthConsentLowSec' -CurrentValue $CurrentValue -ExpectedValue $ExpectedValue -Tenant $tenant From abc4d992007eb4ee4fc98e47b4603abafc0dd79e Mon Sep 17 00:00:00 2001 From: John Duprey Date: Tue, 10 Feb 2026 12:53:19 -0500 Subject: [PATCH 042/150] Add queue tracking and mailbox rules report Add queue tracking support across CIPP DB cache flows and introduce mailbox rules reporting. Key changes: Update-CippQueueEntry: accept TotalTasks and IncrementTotalTasks to set or increment task counts. Propagate QueueId through Push-ExecCIPPDBCache, Invoke-ExecCIPPDBCache and orchestration batches (create New-CippQueueEntry with TotalTasks and include QueueId in batch/response). Include 'MailboxRules' in Push-CIPPDBCacheData list. Add Get-CIPPMailboxRulesReport to read mailbox rules from the reporting DB (supports AllTenants). Enhance Invoke-ListMailboxRules to optionally use the report DB via UseReportDB, restructure queue/orchestration logic and add error handling. Many Set-CIPPDBCache* functions updated to accept an optional QueueId parameter so cache jobs can report progress to the queue. --- .../CippQueue/Update-CippQueueEntry.ps1 | 17 +- .../CIPPDBCache/Push-ExecCIPPDBCache.ps1 | 13 +- .../Push-CIPPDBCacheData.ps1 | 1 + .../CIPP/Core/Invoke-ExecCIPPDBCache.ps1 | 28 +++- .../Invoke-ListMailboxRules.ps1 | 150 +++++++++++------- .../Public/Get-CIPPMailboxRulesReport.ps1 | 82 ++++++++++ ...t-CIPPDBCacheAdminConsentRequestPolicy.ps1 | 6 +- .../Set-CIPPDBCacheAppRoleAssignments.ps1 | 6 +- .../CIPPCore/Public/Set-CIPPDBCacheApps.ps1 | 6 +- ...t-CIPPDBCacheAuthenticationFlowsPolicy.ps1 | 6 +- ...CIPPDBCacheAuthenticationMethodsPolicy.ps1 | 6 +- .../Set-CIPPDBCacheAuthorizationPolicy.ps1 | 6 +- .../Set-CIPPDBCacheB2BManagementPolicy.ps1 | 6 +- .../Public/Set-CIPPDBCacheCASMailboxes.ps1 | 6 +- ...t-CIPPDBCacheConditionalAccessPolicies.ps1 | 6 +- ...CacheCredentialUserRegistrationDetails.ps1 | 6 +- ...Set-CIPPDBCacheCrossTenantAccessPolicy.ps1 | 6 +- ...-CIPPDBCacheDefaultAppManagementPolicy.ps1 | 6 +- ...et-CIPPDBCacheDeviceRegistrationPolicy.ps1 | 6 +- .../Public/Set-CIPPDBCacheDeviceSettings.ps1 | 6 +- .../Public/Set-CIPPDBCacheDevices.ps1 | 6 +- ...et-CIPPDBCacheDirectoryRecommendations.ps1 | 6 +- .../Public/Set-CIPPDBCacheDomains.ps1 | 6 +- .../Set-CIPPDBCacheExoAcceptedDomains.ps1 | 6 +- .../Set-CIPPDBCacheExoAdminAuditLogConfig.ps1 | 6 +- .../Set-CIPPDBCacheExoAntiPhishPolicies.ps1 | 6 +- .../Set-CIPPDBCacheExoAntiPhishPolicy.ps1 | 6 +- .../Set-CIPPDBCacheExoAtpPolicyForO365.ps1 | 6 +- .../Set-CIPPDBCacheExoDkimSigningConfig.ps1 | 6 +- ...IPPDBCacheExoHostedContentFilterPolicy.ps1 | 6 +- ...CacheExoHostedOutboundSpamFilterPolicy.ps1 | 6 +- ...et-CIPPDBCacheExoMalwareFilterPolicies.ps1 | 6 +- .../Set-CIPPDBCacheExoMalwareFilterPolicy.ps1 | 6 +- .../Set-CIPPDBCacheExoOrganizationConfig.ps1 | 6 +- ...Set-CIPPDBCacheExoPresetSecurityPolicy.ps1 | 6 +- .../Set-CIPPDBCacheExoQuarantinePolicy.ps1 | 6 +- .../Public/Set-CIPPDBCacheExoRemoteDomain.ps1 | 6 +- ...t-CIPPDBCacheExoSafeAttachmentPolicies.ps1 | 6 +- ...Set-CIPPDBCacheExoSafeAttachmentPolicy.ps1 | 6 +- .../Set-CIPPDBCacheExoSafeLinksPolicies.ps1 | 6 +- .../Set-CIPPDBCacheExoSafeLinksPolicy.ps1 | 6 +- .../Set-CIPPDBCacheExoSharingPolicy.ps1 | 6 +- ...Set-CIPPDBCacheExoTenantAllowBlockList.ps1 | 6 +- .../Set-CIPPDBCacheExoTransportRules.ps1 | 6 +- .../CIPPCore/Public/Set-CIPPDBCacheGroups.ps1 | 6 +- .../CIPPCore/Public/Set-CIPPDBCacheGuests.ps1 | 6 +- ...CIPPDBCacheIntuneAppProtectionPolicies.ps1 | 6 +- .../Public/Set-CIPPDBCacheIntunePolicies.ps1 | 6 +- .../Public/Set-CIPPDBCacheLicenseOverview.ps1 | 6 +- .../Public/Set-CIPPDBCacheMFAState.ps1 | 6 +- .../Public/Set-CIPPDBCacheMailboxRules.ps1 | 60 +++++++ .../Public/Set-CIPPDBCacheMailboxUsage.ps1 | 6 +- .../Public/Set-CIPPDBCacheMailboxes.ps1 | 20 ++- ...PPDBCacheManagedDeviceEncryptionStates.ps1 | 6 +- .../Public/Set-CIPPDBCacheManagedDevices.ps1 | 6 +- .../Set-CIPPDBCacheOAuth2PermissionGrants.ps1 | 6 +- .../Public/Set-CIPPDBCacheOneDriveUsage.ps1 | 6 +- .../Public/Set-CIPPDBCacheOrganization.ps1 | 6 +- .../Public/Set-CIPPDBCachePIMSettings.ps1 | 6 +- .../Public/Set-CIPPDBCacheRiskDetections.ps1 | 6 +- .../Set-CIPPDBCacheRiskyServicePrincipals.ps1 | 6 +- .../Public/Set-CIPPDBCacheRiskyUsers.ps1 | 6 +- ...DBCacheRoleAssignmentScheduleInstances.ps1 | 3 +- ...et-CIPPDBCacheRoleEligibilitySchedules.ps1 | 6 +- .../Set-CIPPDBCacheRoleManagementPolicies.ps1 | 6 +- .../CIPPCore/Public/Set-CIPPDBCacheRoles.ps1 | 6 +- .../Public/Set-CIPPDBCacheSecureScore.ps1 | 6 +- ...PDBCacheServicePrincipalRiskDetections.ps1 | 6 +- .../Set-CIPPDBCacheServicePrincipals.ps1 | 6 +- .../Public/Set-CIPPDBCacheSettings.ps1 | 6 +- ...Set-CIPPDBCacheUserRegistrationDetails.ps1 | 6 +- .../CIPPCore/Public/Set-CIPPDBCacheUsers.ps1 | 6 +- .../Public/Set-CIPPDbCacheTestData.ps1 | 4 + 73 files changed, 624 insertions(+), 132 deletions(-) create mode 100644 Modules/CIPPCore/Public/Get-CIPPMailboxRulesReport.ps1 create mode 100644 Modules/CIPPCore/Public/Set-CIPPDBCacheMailboxRules.ps1 diff --git a/Modules/CIPPCore/Public/CippQueue/Update-CippQueueEntry.ps1 b/Modules/CIPPCore/Public/CippQueue/Update-CippQueueEntry.ps1 index e0202b01baf3..8184320508a8 100644 --- a/Modules/CIPPCore/Public/CippQueue/Update-CippQueueEntry.ps1 +++ b/Modules/CIPPCore/Public/CippQueue/Update-CippQueueEntry.ps1 @@ -3,11 +3,13 @@ function Update-CippQueueEntry { .FUNCTIONALITY Internal #> - Param( + param( [Parameter(Mandatory = $true)] $RowKey, $Status, - $Name + $Name, + $TotalTasks, + [switch]$IncrementTotalTasks ) $CippQueue = Get-CippTable -TableName CippQueue @@ -22,6 +24,15 @@ function Update-CippQueueEntry { if ($Name) { $QueueEntry.Name = $Name } + if ($TotalTasks) { + if ($IncrementTotalTasks) { + # Increment the existing total + $QueueEntry.TotalTasks = [int]$QueueEntry.TotalTasks + [int]$TotalTasks + } else { + # Set the total directly + $QueueEntry.TotalTasks = $TotalTasks + } + } Add-CIPPAzDataTableEntity @CippQueue -Entity $QueueEntry -Force $QueueEntry } else { @@ -30,4 +41,4 @@ function Update-CippQueueEntry { } else { return $false } -} \ No newline at end of file +} diff --git a/Modules/CIPPCore/Public/Entrypoints/Activity Triggers/CIPPDBCache/Push-ExecCIPPDBCache.ps1 b/Modules/CIPPCore/Public/Entrypoints/Activity Triggers/CIPPDBCache/Push-ExecCIPPDBCache.ps1 index a6a68cfc8510..7c4f04c772e0 100644 --- a/Modules/CIPPCore/Public/Entrypoints/Activity Triggers/CIPPDBCache/Push-ExecCIPPDBCache.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/Activity Triggers/CIPPDBCache/Push-ExecCIPPDBCache.ps1 @@ -14,6 +14,7 @@ function Push-ExecCIPPDBCache { $Name = $Item.Name $TenantFilter = $Item.TenantFilter + $QueueId = $Item.QueueId try { Write-Information "Collecting $Name for tenant $TenantFilter" @@ -27,8 +28,18 @@ function Push-ExecCIPPDBCache { throw "Function $FullFunctionName does not exist" } + # Build parameters for the cache function + $CacheFunctionParams = @{ + TenantFilter = $TenantFilter + } + + # Add QueueId if provided + if ($QueueId) { + $CacheFunctionParams.QueueId = $QueueId + } + # Execute the cache function - & $FullFunctionName -TenantFilter $TenantFilter + & $FullFunctionName @CacheFunctionParams Write-Information "Completed $Name for tenant $TenantFilter" return "Successfully executed $Name for tenant $TenantFilter" diff --git a/Modules/CIPPCore/Public/Entrypoints/Activity Triggers/Push-CIPPDBCacheData.ps1 b/Modules/CIPPCore/Public/Entrypoints/Activity Triggers/Push-CIPPDBCacheData.ps1 index 6bdf8f889ddf..2f18e8e92b2d 100644 --- a/Modules/CIPPCore/Public/Entrypoints/Activity Triggers/Push-CIPPDBCacheData.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/Activity Triggers/Push-CIPPDBCacheData.ps1 @@ -99,6 +99,7 @@ function Push-CIPPDBCacheData { 'ExoPresetSecurityPolicy' 'ExoTenantAllowBlockList' 'Mailboxes' + 'MailboxRules' 'CASMailboxes' 'MailboxUsage' 'OneDriveUsage' diff --git a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecCIPPDBCache.ps1 b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecCIPPDBCache.ps1 index 92e4249af55b..dd526d2a574b 100644 --- a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecCIPPDBCache.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecCIPPDBCache.ps1 @@ -32,31 +32,46 @@ function Invoke-ExecCIPPDBCache { Write-LogMessage -API $APIName -tenant $TenantFilter -message "Starting CIPP DB cache for $Name" -sev Info + # Create queue entry for tracking + $QueueName = if ($TenantFilter -eq 'AllTenants') { + "$Name Cache Sync (All Tenants)" + } else { + "$Name Cache Sync ($TenantFilter)" + } + # Handle AllTenants - create a batch for each tenant if ($TenantFilter -eq 'AllTenants') { $TenantList = Get-Tenants -IncludeErrors + $Queue = New-CippQueueEntry -Name $QueueName -TotalTasks ($TenantList | Measure-Object).Count + $Batch = $TenantList | ForEach-Object { [PSCustomObject]@{ FunctionName = 'ExecCIPPDBCache' + QueueName = "$Name Cache - $($_.defaultDomainName)" Name = $Name TenantFilter = $_.defaultDomainName + QueueId = $Queue.RowKey } } - + $InputObject = [PSCustomObject]@{ Batch = @($Batch) OrchestratorName = "CIPPDBCache_${Name}_AllTenants" SkipLog = $false } - + Write-LogMessage -API $APIName -tenant $TenantFilter -message "Starting CIPP DB cache for $Name across $($TenantList.Count) tenants" -sev Info } else { # Single tenant + $Queue = New-CippQueueEntry -Name $QueueName -TotalTasks 1 + $InputObject = [PSCustomObject]@{ Batch = @([PSCustomObject]@{ + QueueName = "$Name Cache - $TenantFilter" FunctionName = 'ExecCIPPDBCache' Name = $Name TenantFilter = $TenantFilter + QueueId = $Queue.RowKey }) OrchestratorName = "CIPPDBCache_${Name}_$TenantFilter" SkipLog = $false @@ -67,12 +82,19 @@ function Invoke-ExecCIPPDBCache { Write-LogMessage -API $APIName -tenant $TenantFilter -message "Started CIPP DB cache orchestrator for $Name with instance ID: $InstanceId" -sev Info + $ResultsMessage = if ($TenantFilter -eq 'AllTenants') { + "Successfully started cache operation for $Name for all tenants" + } else { + "Successfully started cache operation for $Name on tenant $TenantFilter" + } + $Body = [PSCustomObject]@{ - Results = "Successfully started cache operation for $Name$(if ($TenantFilter -eq 'AllTenants') { ' for all tenants' } else { " on tenant $TenantFilter" })" + Results = $ResultsMessage Metadata = @{ Name = $Name Tenant = $TenantFilter InstanceId = $InstanceId + QueueId = $Queue.RowKey } } $StatusCode = [HttpStatusCode]::OK diff --git a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ListMailboxRules.ps1 b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ListMailboxRules.ps1 index 648d7251deee..c20d575be6bb 100644 --- a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ListMailboxRules.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ListMailboxRules.ps1 @@ -9,77 +9,107 @@ function Invoke-ListMailboxRules { param($Request, $TriggerMetadata) # Interact with query parameters or the body of the request. $TenantFilter = $Request.Query.tenantFilter + $UseReportDB = $Request.Query.UseReportDB - $Table = Get-CIPPTable -TableName cachembxrules - if ($TenantFilter -ne 'AllTenants') { - $Table.Filter = "PartitionKey eq 'MailboxRules' and Tenant eq '$TenantFilter'" - } else { - $Table.Filter = "PartitionKey eq 'MailboxRules'" - } - - Write-Information 'Getting cached mailbox rules' - $Rows = Get-CIPPAzDataTableEntity @Table | Where-Object -Property Timestamp -GT (Get-Date).AddHours(-1) - $PartitionKey = 'MailboxRules' - $QueueReference = '{0}-{1}' -f $TenantFilter, $PartitionKey - $RunningQueue = Invoke-ListCippQueue -Reference $QueueReference | Where-Object { $_.Status -notmatch 'Completed' -and $_.Status -notmatch 'Failed' } + try { + # If UseReportDB is specified, retrieve from report database + if ($UseReportDB -eq 'true') { + try { + $GraphRequest = Get-CIPPMailboxRulesReport -TenantFilter $TenantFilter -ErrorAction Stop + $StatusCode = [HttpStatusCode]::OK + } catch { + Write-Host "Error retrieving mailbox rules from report database: $($_.Exception.Message)" + $StatusCode = [HttpStatusCode]::InternalServerError + $GraphRequest = $_.Exception.Message + } - $Metadata = @{} - # If a queue is running, we will not start a new one - if ($RunningQueue -and !$Rows) { - Write-Information "Queue is already running for $TenantFilter" - $Metadata = [PSCustomObject]@{ - QueueMessage = "Still loading data for $TenantFilter. Please check back in a few more minutes" - QueueId = $RunningQueue.RowKey + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = @($GraphRequest) + }) } - } elseif ((!$Rows -and !$RunningQueue) -or ($TenantFilter -eq 'AllTenants' -and ($Rows | Measure-Object).Count -eq 1)) { - Write-Information "No cached mailbox rules found for $TenantFilter, starting new orchestration" - if ($TenantFilter -eq 'AllTenants') { - $Tenants = Get-Tenants -IncludeErrors | Select-Object defaultDomainName - $Type = 'All Tenants' + + # Original cache table logic + $Table = Get-CIPPTable -TableName cachembxrules + if ($TenantFilter -ne 'AllTenants') { + $Table.Filter = "PartitionKey eq 'MailboxRules' and Tenant eq '$TenantFilter'" } else { - $Tenants = @(@{ defaultDomainName = $TenantFilter }) - $Type = $TenantFilter - } - $Queue = New-CippQueueEntry -Name "Mailbox Rules ($Type)" -Reference $QueueReference -TotalTasks ($Tenants | Measure-Object).Count - # If no rows are found and no queue is running, we will start a new one - $Metadata = [PSCustomObject]@{ - QueueMessage = "Loading data for $TenantFilter. Please check back in 1 minute" - QueueId = $Queue.RowKey + $Table.Filter = "PartitionKey eq 'MailboxRules'" } - $Batch = $Tenants | Select-Object defaultDomainName, @{Name = 'FunctionName'; Expression = { 'ListMailboxRulesQueue' } }, @{Name = 'QueueName'; Expression = { $_.defaultDomainName } }, @{Name = 'QueueId'; Expression = { $Queue.RowKey } } - if (($Batch | Measure-Object).Count -gt 0) { - $InputObject = [PSCustomObject]@{ - OrchestratorName = 'ListMailboxRulesOrchestrator' - Batch = @($Batch) - SkipLog = $true + Write-Information 'Getting cached mailbox rules' + $Rows = Get-CIPPAzDataTableEntity @Table | Where-Object -Property Timestamp -GT (Get-Date).AddHours(-1) + $PartitionKey = 'MailboxRules' + $QueueReference = '{0}-{1}' -f $TenantFilter, $PartitionKey + $RunningQueue = Invoke-ListCippQueue -Reference $QueueReference | Where-Object { $_.Status -notmatch 'Completed' -and $_.Status -notmatch 'Failed' } + + $Metadata = @{} + # If a queue is running, we will not start a new one + if ($RunningQueue -and !$Rows) { + Write-Information "Queue is already running for $TenantFilter" + $Metadata = [PSCustomObject]@{ + QueueMessage = "Still loading data for $TenantFilter. Please check back in a few more minutes" + QueueId = $RunningQueue.RowKey + } + } elseif ((!$Rows -and !$RunningQueue) -or ($TenantFilter -eq 'AllTenants' -and ($Rows | Measure-Object).Count -eq 1)) { + Write-Information "No cached mailbox rules found for $TenantFilter, starting new orchestration" + if ($TenantFilter -eq 'AllTenants') { + $Tenants = Get-Tenants -IncludeErrors | Select-Object defaultDomainName + $Type = 'All Tenants' + } else { + $Tenants = @(@{ defaultDomainName = $TenantFilter }) + $Type = $TenantFilter + } + $Queue = New-CippQueueEntry -Name "Mailbox Rules ($Type)" -Reference $QueueReference -TotalTasks ($Tenants | Measure-Object).Count + # If no rows are found and no queue is running, we will start a new one + $Metadata = [PSCustomObject]@{ + QueueMessage = "Loading data for $TenantFilter. Please check back in 1 minute" + QueueId = $Queue.RowKey } - #Write-Host ($InputObject | ConvertTo-Json) - Start-NewOrchestration -FunctionName 'CIPPOrchestrator' -InputObject ($InputObject | ConvertTo-Json -Depth 5 -Compress) - Write-Host "Started mailbox rules orchestration with ID = '$InstanceId'" - } - } else { - $Metadata = [PSCustomObject]@{ - QueueId = $RunningQueue.RowKey ?? $null + $Batch = $Tenants | Select-Object defaultDomainName, @{Name = 'FunctionName'; Expression = { 'ListMailboxRulesQueue' } }, @{Name = 'QueueName'; Expression = { $_.defaultDomainName } }, @{Name = 'QueueId'; Expression = { $Queue.RowKey } } + if (($Batch | Measure-Object).Count -gt 0) { + $InputObject = [PSCustomObject]@{ + OrchestratorName = 'ListMailboxRulesOrchestrator' + Batch = @($Batch) + SkipLog = $true + } + #Write-Host ($InputObject | ConvertTo-Json) + Start-NewOrchestration -FunctionName 'CIPPOrchestrator' -InputObject ($InputObject | ConvertTo-Json -Depth 5 -Compress) + Write-Host "Started mailbox rules orchestration with ID = '$InstanceId'" + } + + } else { + $Metadata = [PSCustomObject]@{ + QueueId = $RunningQueue.RowKey ?? $null + } + $GraphRequest = $Rows | ForEach-Object { + $NewObj = $_.Rules | ConvertFrom-Json -ErrorAction SilentlyContinue + $NewObj | Add-Member -NotePropertyName 'Tenant' -NotePropertyValue $_.Tenant -Force + $NewObj + } } - $GraphRequest = $Rows | ForEach-Object { - $NewObj = $_.Rules | ConvertFrom-Json -ErrorAction SilentlyContinue - $NewObj | Add-Member -NotePropertyName 'Tenant' -NotePropertyValue $_.Tenant -Force - $NewObj + + # If no results are found, we will return an empty message to prevent null reference errors in the frontend + $GraphRequest = $GraphRequest ?? @() + $Body = @{ + Results = @($GraphRequest) + Metadata = $Metadata } - } - # If no results are found, we will return an empty message to prevent null reference errors in the frontend - $GraphRequest = $GraphRequest ?? @() - $Body = @{ - Results = @($GraphRequest) - Metadata = $Metadata - } + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::OK + Body = $Body + }) - return ([HttpResponseContext]@{ - StatusCode = [HttpStatusCode]::OK - Body = $Body - }) + } catch { + $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message + $StatusCode = [HttpStatusCode]::Forbidden + $GraphRequest = $ErrorMessage + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = @($GraphRequest) + }) + } } diff --git a/Modules/CIPPCore/Public/Get-CIPPMailboxRulesReport.ps1 b/Modules/CIPPCore/Public/Get-CIPPMailboxRulesReport.ps1 new file mode 100644 index 000000000000..641e97965452 --- /dev/null +++ b/Modules/CIPPCore/Public/Get-CIPPMailboxRulesReport.ps1 @@ -0,0 +1,82 @@ +function Get-CIPPMailboxRulesReport { + <# + .SYNOPSIS + Generates a mailbox rules report from the CIPP Reporting database + + .DESCRIPTION + Retrieves mailbox rules data for a tenant from the reporting database + + .PARAMETER TenantFilter + The tenant to generate the report for + + .EXAMPLE + Get-CIPPMailboxRulesReport -TenantFilter 'contoso.onmicrosoft.com' + Gets mailbox rules for all users in the tenant + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter + ) + + try { + + # Handle AllTenants + if ($TenantFilter -eq 'AllTenants') { + # Get all tenants that have mailbox rules data + $AllRulesItems = Get-CIPPDbItem -TenantFilter 'allTenants' -Type 'MailboxRules' + $Tenants = @($AllRulesItems | Where-Object { $_.RowKey -ne 'MailboxRules-Count' } | Select-Object -ExpandProperty PartitionKey -Unique) + + $TenantList = Get-Tenants -IncludeErrors + $Tenants = $Tenants | Where-Object { $TenantList.defaultDomainName -contains $_ } + + $AllResults = [System.Collections.Generic.List[PSCustomObject]]::new() + foreach ($Tenant in $Tenants) { + try { + $TenantResults = Get-CIPPMailboxRulesReport -TenantFilter $Tenant + foreach ($Result in $TenantResults) { + # Add Tenant property to each result if not already present + if (-not $Result.Tenant) { + $Result | Add-Member -NotePropertyName 'Tenant' -NotePropertyValue $Tenant -Force + } + $AllResults.Add($Result) + } + } catch { + Write-LogMessage -API 'MailboxRulesReport' -tenant $Tenant -message "Failed to get report for tenant: $($_.Exception.Message)" -sev Warning + } + } + return $AllResults + } + + # Get mailbox rules from reporting DB + $RulesItems = Get-CIPPDbItem -TenantFilter $TenantFilter -Type 'MailboxRules' | Where-Object { $_.RowKey -ne 'MailboxRules-Count' } + if (-not $RulesItems) { + throw 'No mailbox rules data found in reporting database. Sync the report data first.' + } + + # Get the most recent cache timestamp + $CacheTimestamp = ($RulesItems | Where-Object { $_.Timestamp } | Sort-Object Timestamp -Descending | Select-Object -First 1).Timestamp + + # Parse mailbox rules data + $AllRules = [System.Collections.Generic.List[PSCustomObject]]::new() + foreach ($Item in $RulesItems | Where-Object { $_.RowKey -ne 'MailboxRules-Count' }) { + $Rule = $Item.Data | ConvertFrom-Json + + # Add cache timestamp to the rule + $Rule | Add-Member -NotePropertyName 'CacheTimestamp' -NotePropertyValue $CacheTimestamp -Force -ErrorAction SilentlyContinue + + # Ensure Tenant property is set + if (-not $Rule.Tenant) { + $Rule | Add-Member -NotePropertyName 'Tenant' -NotePropertyValue $TenantFilter -Force -ErrorAction SilentlyContinue + } + + $AllRules.Add($Rule) + } + + return $AllRules + + } catch { + Write-LogMessage -API 'MailboxRulesReport' -tenant $TenantFilter -message "Failed to get mailbox rules report: $($_.Exception.Message)" -sev Error -LogData (Get-CippException -Exception $_) + throw $_ + } +} diff --git a/Modules/CIPPCore/Public/Set-CIPPDBCacheAdminConsentRequestPolicy.ps1 b/Modules/CIPPCore/Public/Set-CIPPDBCacheAdminConsentRequestPolicy.ps1 index 945d69f854cd..32f3e523e6a8 100644 --- a/Modules/CIPPCore/Public/Set-CIPPDBCacheAdminConsentRequestPolicy.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPDBCacheAdminConsentRequestPolicy.ps1 @@ -5,11 +5,15 @@ function Set-CIPPDBCacheAdminConsentRequestPolicy { .PARAMETER TenantFilter The tenant to cache consent policy for + + .PARAMETER QueueId + The queue ID to update with total tasks (optional) #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] - [string]$TenantFilter + [string]$TenantFilter, + [string]$QueueId ) try { diff --git a/Modules/CIPPCore/Public/Set-CIPPDBCacheAppRoleAssignments.ps1 b/Modules/CIPPCore/Public/Set-CIPPDBCacheAppRoleAssignments.ps1 index 3ba077a2cdeb..93433d6c27f9 100644 --- a/Modules/CIPPCore/Public/Set-CIPPDBCacheAppRoleAssignments.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPDBCacheAppRoleAssignments.ps1 @@ -5,11 +5,15 @@ function Set-CIPPDBCacheAppRoleAssignments { .PARAMETER TenantFilter The tenant to cache app role assignments for + + .PARAMETER QueueId + The queue ID to update with total tasks (optional) #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] - [string]$TenantFilter + [string]$TenantFilter, + [string]$QueueId ) try { diff --git a/Modules/CIPPCore/Public/Set-CIPPDBCacheApps.ps1 b/Modules/CIPPCore/Public/Set-CIPPDBCacheApps.ps1 index a0f1c667be2f..ef8acc12acf4 100644 --- a/Modules/CIPPCore/Public/Set-CIPPDBCacheApps.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPDBCacheApps.ps1 @@ -5,11 +5,15 @@ function Set-CIPPDBCacheApps { .PARAMETER TenantFilter The tenant to cache applications for + + .PARAMETER QueueId + The queue ID to update with total tasks (optional) #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] - [string]$TenantFilter + [string]$TenantFilter, + [string]$QueueId ) try { diff --git a/Modules/CIPPCore/Public/Set-CIPPDBCacheAuthenticationFlowsPolicy.ps1 b/Modules/CIPPCore/Public/Set-CIPPDBCacheAuthenticationFlowsPolicy.ps1 index 7b75a2b23eaa..da9738675345 100644 --- a/Modules/CIPPCore/Public/Set-CIPPDBCacheAuthenticationFlowsPolicy.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPDBCacheAuthenticationFlowsPolicy.ps1 @@ -5,11 +5,15 @@ function Set-CIPPDBCacheAuthenticationFlowsPolicy { .PARAMETER TenantFilter The tenant to cache authentication flows policy for + + .PARAMETER QueueId + The queue ID to update with total tasks (optional) #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] - [string]$TenantFilter + [string]$TenantFilter, + [string]$QueueId ) try { diff --git a/Modules/CIPPCore/Public/Set-CIPPDBCacheAuthenticationMethodsPolicy.ps1 b/Modules/CIPPCore/Public/Set-CIPPDBCacheAuthenticationMethodsPolicy.ps1 index 98ea20dd05d7..a4146e9c6c69 100644 --- a/Modules/CIPPCore/Public/Set-CIPPDBCacheAuthenticationMethodsPolicy.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPDBCacheAuthenticationMethodsPolicy.ps1 @@ -5,11 +5,15 @@ function Set-CIPPDBCacheAuthenticationMethodsPolicy { .PARAMETER TenantFilter The tenant to cache authentication methods policy for + + .PARAMETER QueueId + The queue ID to update with total tasks (optional) #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] - [string]$TenantFilter + [string]$TenantFilter, + [string]$QueueId ) try { diff --git a/Modules/CIPPCore/Public/Set-CIPPDBCacheAuthorizationPolicy.ps1 b/Modules/CIPPCore/Public/Set-CIPPDBCacheAuthorizationPolicy.ps1 index ca6c92bfe624..d416c3fb2dd9 100644 --- a/Modules/CIPPCore/Public/Set-CIPPDBCacheAuthorizationPolicy.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPDBCacheAuthorizationPolicy.ps1 @@ -5,11 +5,15 @@ function Set-CIPPDBCacheAuthorizationPolicy { .PARAMETER TenantFilter The tenant to cache authorization policy for + + .PARAMETER QueueId + The queue ID to update with total tasks (optional) #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] - [string]$TenantFilter + [string]$TenantFilter, + [string]$QueueId ) try { diff --git a/Modules/CIPPCore/Public/Set-CIPPDBCacheB2BManagementPolicy.ps1 b/Modules/CIPPCore/Public/Set-CIPPDBCacheB2BManagementPolicy.ps1 index f00d7d4c8fc0..d970a12e2953 100644 --- a/Modules/CIPPCore/Public/Set-CIPPDBCacheB2BManagementPolicy.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPDBCacheB2BManagementPolicy.ps1 @@ -5,11 +5,15 @@ function Set-CIPPDBCacheB2BManagementPolicy { .PARAMETER TenantFilter The tenant to cache B2B management policy for + + .PARAMETER QueueId + The queue ID to update with total tasks (optional) #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] - [string]$TenantFilter + [string]$TenantFilter, + [string]$QueueId ) try { diff --git a/Modules/CIPPCore/Public/Set-CIPPDBCacheCASMailboxes.ps1 b/Modules/CIPPCore/Public/Set-CIPPDBCacheCASMailboxes.ps1 index 02c12125f6e4..3912da85e7c3 100644 --- a/Modules/CIPPCore/Public/Set-CIPPDBCacheCASMailboxes.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPDBCacheCASMailboxes.ps1 @@ -5,11 +5,15 @@ function Set-CIPPDBCacheCASMailboxes { .PARAMETER TenantFilter The tenant to cache CAS mailboxes for + + .PARAMETER QueueId + The queue ID to update with total tasks (optional) #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] - [string]$TenantFilter + [string]$TenantFilter, + [string]$QueueId ) try { diff --git a/Modules/CIPPCore/Public/Set-CIPPDBCacheConditionalAccessPolicies.ps1 b/Modules/CIPPCore/Public/Set-CIPPDBCacheConditionalAccessPolicies.ps1 index 729644ed5d40..bb516877c6c8 100644 --- a/Modules/CIPPCore/Public/Set-CIPPDBCacheConditionalAccessPolicies.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPDBCacheConditionalAccessPolicies.ps1 @@ -5,11 +5,15 @@ function Set-CIPPDBCacheConditionalAccessPolicies { .PARAMETER TenantFilter The tenant to cache CA policies for + + .PARAMETER QueueId + The queue ID to update with total tasks (optional) #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] - [string]$TenantFilter + [string]$TenantFilter, + [string]$QueueId ) try { diff --git a/Modules/CIPPCore/Public/Set-CIPPDBCacheCredentialUserRegistrationDetails.ps1 b/Modules/CIPPCore/Public/Set-CIPPDBCacheCredentialUserRegistrationDetails.ps1 index 888c398ffea3..5242dffdbbf7 100644 --- a/Modules/CIPPCore/Public/Set-CIPPDBCacheCredentialUserRegistrationDetails.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPDBCacheCredentialUserRegistrationDetails.ps1 @@ -5,11 +5,15 @@ function Set-CIPPDBCacheCredentialUserRegistrationDetails { .PARAMETER TenantFilter The tenant to cache credential user registration details for + + .PARAMETER QueueId + The queue ID to update with total tasks (optional) #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] - [string]$TenantFilter + [string]$TenantFilter, + [string]$QueueId ) try { diff --git a/Modules/CIPPCore/Public/Set-CIPPDBCacheCrossTenantAccessPolicy.ps1 b/Modules/CIPPCore/Public/Set-CIPPDBCacheCrossTenantAccessPolicy.ps1 index cc4203420b96..4fe87f882ee1 100644 --- a/Modules/CIPPCore/Public/Set-CIPPDBCacheCrossTenantAccessPolicy.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPDBCacheCrossTenantAccessPolicy.ps1 @@ -5,11 +5,15 @@ function Set-CIPPDBCacheCrossTenantAccessPolicy { .PARAMETER TenantFilter The tenant to cache policy for + + .PARAMETER QueueId + The queue ID to update with total tasks (optional) #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] - [string]$TenantFilter + [string]$TenantFilter, + [string]$QueueId ) try { diff --git a/Modules/CIPPCore/Public/Set-CIPPDBCacheDefaultAppManagementPolicy.ps1 b/Modules/CIPPCore/Public/Set-CIPPDBCacheDefaultAppManagementPolicy.ps1 index c053f36435a4..b0730af22a26 100644 --- a/Modules/CIPPCore/Public/Set-CIPPDBCacheDefaultAppManagementPolicy.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPDBCacheDefaultAppManagementPolicy.ps1 @@ -5,11 +5,15 @@ function Set-CIPPDBCacheDefaultAppManagementPolicy { .PARAMETER TenantFilter The tenant to cache policy for + + .PARAMETER QueueId + The queue ID to update with total tasks (optional) #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] - [string]$TenantFilter + [string]$TenantFilter, + [string]$QueueId ) try { diff --git a/Modules/CIPPCore/Public/Set-CIPPDBCacheDeviceRegistrationPolicy.ps1 b/Modules/CIPPCore/Public/Set-CIPPDBCacheDeviceRegistrationPolicy.ps1 index 3a9eada7c7a6..c9f445cd62d8 100644 --- a/Modules/CIPPCore/Public/Set-CIPPDBCacheDeviceRegistrationPolicy.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPDBCacheDeviceRegistrationPolicy.ps1 @@ -5,11 +5,15 @@ function Set-CIPPDBCacheDeviceRegistrationPolicy { .PARAMETER TenantFilter The tenant to cache device registration policy for + + .PARAMETER QueueId + The queue ID to update with total tasks (optional) #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] - [string]$TenantFilter + [string]$TenantFilter, + [string]$QueueId ) try { diff --git a/Modules/CIPPCore/Public/Set-CIPPDBCacheDeviceSettings.ps1 b/Modules/CIPPCore/Public/Set-CIPPDBCacheDeviceSettings.ps1 index 7845f72ffa8a..5a0a3dbe9207 100644 --- a/Modules/CIPPCore/Public/Set-CIPPDBCacheDeviceSettings.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPDBCacheDeviceSettings.ps1 @@ -5,11 +5,15 @@ function Set-CIPPDBCacheDeviceSettings { .PARAMETER TenantFilter The tenant to cache device settings for + + .PARAMETER QueueId + The queue ID to update with total tasks (optional) #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] - [string]$TenantFilter + [string]$TenantFilter, + [string]$QueueId ) try { diff --git a/Modules/CIPPCore/Public/Set-CIPPDBCacheDevices.ps1 b/Modules/CIPPCore/Public/Set-CIPPDBCacheDevices.ps1 index 63996ee51294..c0d056617209 100644 --- a/Modules/CIPPCore/Public/Set-CIPPDBCacheDevices.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPDBCacheDevices.ps1 @@ -5,11 +5,15 @@ function Set-CIPPDBCacheDevices { .PARAMETER TenantFilter The tenant to cache devices for + + .PARAMETER QueueId + The queue ID to update with total tasks (optional) #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] - [string]$TenantFilter + [string]$TenantFilter, + [string]$QueueId ) try { diff --git a/Modules/CIPPCore/Public/Set-CIPPDBCacheDirectoryRecommendations.ps1 b/Modules/CIPPCore/Public/Set-CIPPDBCacheDirectoryRecommendations.ps1 index 616c7ab82503..1a1e973cd695 100644 --- a/Modules/CIPPCore/Public/Set-CIPPDBCacheDirectoryRecommendations.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPDBCacheDirectoryRecommendations.ps1 @@ -5,11 +5,15 @@ function Set-CIPPDBCacheDirectoryRecommendations { .PARAMETER TenantFilter The tenant to cache recommendations for + + .PARAMETER QueueId + The queue ID to update with total tasks (optional) #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] - [string]$TenantFilter + [string]$TenantFilter, + [string]$QueueId ) try { diff --git a/Modules/CIPPCore/Public/Set-CIPPDBCacheDomains.ps1 b/Modules/CIPPCore/Public/Set-CIPPDBCacheDomains.ps1 index b546382b3103..d15be42d43fe 100644 --- a/Modules/CIPPCore/Public/Set-CIPPDBCacheDomains.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPDBCacheDomains.ps1 @@ -5,11 +5,15 @@ function Set-CIPPDBCacheDomains { .PARAMETER TenantFilter The tenant to cache domains for + + .PARAMETER QueueId + The queue ID to update with total tasks (optional) #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] - [string]$TenantFilter + [string]$TenantFilter, + [string]$QueueId ) try { diff --git a/Modules/CIPPCore/Public/Set-CIPPDBCacheExoAcceptedDomains.ps1 b/Modules/CIPPCore/Public/Set-CIPPDBCacheExoAcceptedDomains.ps1 index cbe7bd854481..b2af3cdbe3b8 100644 --- a/Modules/CIPPCore/Public/Set-CIPPDBCacheExoAcceptedDomains.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPDBCacheExoAcceptedDomains.ps1 @@ -5,11 +5,15 @@ function Set-CIPPDBCacheExoAcceptedDomains { .PARAMETER TenantFilter The tenant to cache accepted domains for + + .PARAMETER QueueId + The queue ID to update with total tasks (optional) #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] - [string]$TenantFilter + [string]$TenantFilter, + [string]$QueueId ) try { diff --git a/Modules/CIPPCore/Public/Set-CIPPDBCacheExoAdminAuditLogConfig.ps1 b/Modules/CIPPCore/Public/Set-CIPPDBCacheExoAdminAuditLogConfig.ps1 index 892e471647fe..2c0b2a10e863 100644 --- a/Modules/CIPPCore/Public/Set-CIPPDBCacheExoAdminAuditLogConfig.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPDBCacheExoAdminAuditLogConfig.ps1 @@ -5,11 +5,15 @@ function Set-CIPPDBCacheExoAdminAuditLogConfig { .PARAMETER TenantFilter The tenant to cache admin audit log config for + + .PARAMETER QueueId + The queue ID to update with total tasks (optional) #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] - [string]$TenantFilter + [string]$TenantFilter, + [string]$QueueId ) try { diff --git a/Modules/CIPPCore/Public/Set-CIPPDBCacheExoAntiPhishPolicies.ps1 b/Modules/CIPPCore/Public/Set-CIPPDBCacheExoAntiPhishPolicies.ps1 index 98a525d1f0a0..f35ba843e566 100644 --- a/Modules/CIPPCore/Public/Set-CIPPDBCacheExoAntiPhishPolicies.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPDBCacheExoAntiPhishPolicies.ps1 @@ -5,11 +5,15 @@ function Set-CIPPDBCacheExoAntiPhishPolicies { .PARAMETER TenantFilter The tenant to cache Anti-Phishing data for + + .PARAMETER QueueId + The queue ID to update with total tasks (optional) #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] - [string]$TenantFilter + [string]$TenantFilter, + [string]$QueueId ) try { diff --git a/Modules/CIPPCore/Public/Set-CIPPDBCacheExoAntiPhishPolicy.ps1 b/Modules/CIPPCore/Public/Set-CIPPDBCacheExoAntiPhishPolicy.ps1 index b65b084e9ae1..ea5ce3b211aa 100644 --- a/Modules/CIPPCore/Public/Set-CIPPDBCacheExoAntiPhishPolicy.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPDBCacheExoAntiPhishPolicy.ps1 @@ -5,11 +5,15 @@ function Set-CIPPDBCacheExoAntiPhishPolicy { .PARAMETER TenantFilter The tenant to cache Anti-Phish policy data for + + .PARAMETER QueueId + The queue ID to update with total tasks (optional) #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] - [string]$TenantFilter + [string]$TenantFilter, + [string]$QueueId ) try { diff --git a/Modules/CIPPCore/Public/Set-CIPPDBCacheExoAtpPolicyForO365.ps1 b/Modules/CIPPCore/Public/Set-CIPPDBCacheExoAtpPolicyForO365.ps1 index ee0b2203fa0b..c48fd4baab3a 100644 --- a/Modules/CIPPCore/Public/Set-CIPPDBCacheExoAtpPolicyForO365.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPDBCacheExoAtpPolicyForO365.ps1 @@ -5,11 +5,15 @@ function Set-CIPPDBCacheExoAtpPolicyForO365 { .PARAMETER TenantFilter The tenant to cache ATP policy data for + + .PARAMETER QueueId + The queue ID to update with total tasks (optional) #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] - [string]$TenantFilter + [string]$TenantFilter, + [string]$QueueId ) try { diff --git a/Modules/CIPPCore/Public/Set-CIPPDBCacheExoDkimSigningConfig.ps1 b/Modules/CIPPCore/Public/Set-CIPPDBCacheExoDkimSigningConfig.ps1 index fb72c35dec68..aa7ea3bd4561 100644 --- a/Modules/CIPPCore/Public/Set-CIPPDBCacheExoDkimSigningConfig.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPDBCacheExoDkimSigningConfig.ps1 @@ -5,11 +5,15 @@ function Set-CIPPDBCacheExoDkimSigningConfig { .PARAMETER TenantFilter The tenant to cache DKIM configuration for + + .PARAMETER QueueId + The queue ID to update with total tasks (optional) #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] - [string]$TenantFilter + [string]$TenantFilter, + [string]$QueueId ) try { diff --git a/Modules/CIPPCore/Public/Set-CIPPDBCacheExoHostedContentFilterPolicy.ps1 b/Modules/CIPPCore/Public/Set-CIPPDBCacheExoHostedContentFilterPolicy.ps1 index b6bb5fd5c571..cd71f9a00bc5 100644 --- a/Modules/CIPPCore/Public/Set-CIPPDBCacheExoHostedContentFilterPolicy.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPDBCacheExoHostedContentFilterPolicy.ps1 @@ -5,11 +5,15 @@ function Set-CIPPDBCacheExoHostedContentFilterPolicy { .PARAMETER TenantFilter The tenant to cache Hosted Content Filter data for + + .PARAMETER QueueId + The queue ID to update with total tasks (optional) #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] - [string]$TenantFilter + [string]$TenantFilter, + [string]$QueueId ) try { diff --git a/Modules/CIPPCore/Public/Set-CIPPDBCacheExoHostedOutboundSpamFilterPolicy.ps1 b/Modules/CIPPCore/Public/Set-CIPPDBCacheExoHostedOutboundSpamFilterPolicy.ps1 index 06201cd38b63..a550c9031c8e 100644 --- a/Modules/CIPPCore/Public/Set-CIPPDBCacheExoHostedOutboundSpamFilterPolicy.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPDBCacheExoHostedOutboundSpamFilterPolicy.ps1 @@ -5,11 +5,15 @@ function Set-CIPPDBCacheExoHostedOutboundSpamFilterPolicy { .PARAMETER TenantFilter The tenant to cache Hosted Outbound Spam Filter data for + + .PARAMETER QueueId + The queue ID to update with total tasks (optional) #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] - [string]$TenantFilter + [string]$TenantFilter, + [string]$QueueId ) try { diff --git a/Modules/CIPPCore/Public/Set-CIPPDBCacheExoMalwareFilterPolicies.ps1 b/Modules/CIPPCore/Public/Set-CIPPDBCacheExoMalwareFilterPolicies.ps1 index f50d64d610b8..23f6b1f775ef 100644 --- a/Modules/CIPPCore/Public/Set-CIPPDBCacheExoMalwareFilterPolicies.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPDBCacheExoMalwareFilterPolicies.ps1 @@ -5,11 +5,15 @@ function Set-CIPPDBCacheExoMalwareFilterPolicies { .PARAMETER TenantFilter The tenant to cache Malware Filter data for + + .PARAMETER QueueId + The queue ID to update with total tasks (optional) #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] - [string]$TenantFilter + [string]$TenantFilter, + [string]$QueueId ) try { diff --git a/Modules/CIPPCore/Public/Set-CIPPDBCacheExoMalwareFilterPolicy.ps1 b/Modules/CIPPCore/Public/Set-CIPPDBCacheExoMalwareFilterPolicy.ps1 index 194501e09e9a..ff2ba00a090d 100644 --- a/Modules/CIPPCore/Public/Set-CIPPDBCacheExoMalwareFilterPolicy.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPDBCacheExoMalwareFilterPolicy.ps1 @@ -5,11 +5,15 @@ function Set-CIPPDBCacheExoMalwareFilterPolicy { .PARAMETER TenantFilter The tenant to cache Malware Filter policy data for + + .PARAMETER QueueId + The queue ID to update with total tasks (optional) #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] - [string]$TenantFilter + [string]$TenantFilter, + [string]$QueueId ) try { diff --git a/Modules/CIPPCore/Public/Set-CIPPDBCacheExoOrganizationConfig.ps1 b/Modules/CIPPCore/Public/Set-CIPPDBCacheExoOrganizationConfig.ps1 index 6138bd21a3e3..27a8c481a7da 100644 --- a/Modules/CIPPCore/Public/Set-CIPPDBCacheExoOrganizationConfig.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPDBCacheExoOrganizationConfig.ps1 @@ -5,11 +5,15 @@ function Set-CIPPDBCacheExoOrganizationConfig { .PARAMETER TenantFilter The tenant to cache organization configuration for + + .PARAMETER QueueId + The queue ID to update with total tasks (optional) #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] - [string]$TenantFilter + [string]$TenantFilter, + [string]$QueueId ) try { diff --git a/Modules/CIPPCore/Public/Set-CIPPDBCacheExoPresetSecurityPolicy.ps1 b/Modules/CIPPCore/Public/Set-CIPPDBCacheExoPresetSecurityPolicy.ps1 index a10092fba9ce..3d9def8909c5 100644 --- a/Modules/CIPPCore/Public/Set-CIPPDBCacheExoPresetSecurityPolicy.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPDBCacheExoPresetSecurityPolicy.ps1 @@ -5,11 +5,15 @@ function Set-CIPPDBCacheExoPresetSecurityPolicy { .PARAMETER TenantFilter The tenant to cache preset security policies for + + .PARAMETER QueueId + The queue ID to update with total tasks (optional) #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] - [string]$TenantFilter + [string]$TenantFilter, + [string]$QueueId ) try { diff --git a/Modules/CIPPCore/Public/Set-CIPPDBCacheExoQuarantinePolicy.ps1 b/Modules/CIPPCore/Public/Set-CIPPDBCacheExoQuarantinePolicy.ps1 index 2ef8bf63639a..b4bcdd7ac011 100644 --- a/Modules/CIPPCore/Public/Set-CIPPDBCacheExoQuarantinePolicy.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPDBCacheExoQuarantinePolicy.ps1 @@ -5,11 +5,15 @@ function Set-CIPPDBCacheExoQuarantinePolicy { .PARAMETER TenantFilter The tenant to cache Quarantine policy data for + + .PARAMETER QueueId + The queue ID to update with total tasks (optional) #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] - [string]$TenantFilter + [string]$TenantFilter, + [string]$QueueId ) try { diff --git a/Modules/CIPPCore/Public/Set-CIPPDBCacheExoRemoteDomain.ps1 b/Modules/CIPPCore/Public/Set-CIPPDBCacheExoRemoteDomain.ps1 index 692ba803c6de..e9b32337a9ee 100644 --- a/Modules/CIPPCore/Public/Set-CIPPDBCacheExoRemoteDomain.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPDBCacheExoRemoteDomain.ps1 @@ -5,11 +5,15 @@ function Set-CIPPDBCacheExoRemoteDomain { .PARAMETER TenantFilter The tenant to cache Remote Domain data for + + .PARAMETER QueueId + The queue ID to update with total tasks (optional) #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] - [string]$TenantFilter + [string]$TenantFilter, + [string]$QueueId ) try { diff --git a/Modules/CIPPCore/Public/Set-CIPPDBCacheExoSafeAttachmentPolicies.ps1 b/Modules/CIPPCore/Public/Set-CIPPDBCacheExoSafeAttachmentPolicies.ps1 index 172e86887e66..28d8f587afc3 100644 --- a/Modules/CIPPCore/Public/Set-CIPPDBCacheExoSafeAttachmentPolicies.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPDBCacheExoSafeAttachmentPolicies.ps1 @@ -5,11 +5,15 @@ function Set-CIPPDBCacheExoSafeAttachmentPolicies { .PARAMETER TenantFilter The tenant to cache Safe Attachment data for + + .PARAMETER QueueId + The queue ID to update with total tasks (optional) #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] - [string]$TenantFilter + [string]$TenantFilter, + [string]$QueueId ) try { diff --git a/Modules/CIPPCore/Public/Set-CIPPDBCacheExoSafeAttachmentPolicy.ps1 b/Modules/CIPPCore/Public/Set-CIPPDBCacheExoSafeAttachmentPolicy.ps1 index 6ebe371e5291..fc68dea68d01 100644 --- a/Modules/CIPPCore/Public/Set-CIPPDBCacheExoSafeAttachmentPolicy.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPDBCacheExoSafeAttachmentPolicy.ps1 @@ -5,11 +5,15 @@ function Set-CIPPDBCacheExoSafeAttachmentPolicy { .PARAMETER TenantFilter The tenant to cache Safe Attachment policy data for + + .PARAMETER QueueId + The queue ID to update with total tasks (optional) #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] - [string]$TenantFilter + [string]$TenantFilter, + [string]$QueueId ) try { diff --git a/Modules/CIPPCore/Public/Set-CIPPDBCacheExoSafeLinksPolicies.ps1 b/Modules/CIPPCore/Public/Set-CIPPDBCacheExoSafeLinksPolicies.ps1 index c06fb2f08971..f78468844344 100644 --- a/Modules/CIPPCore/Public/Set-CIPPDBCacheExoSafeLinksPolicies.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPDBCacheExoSafeLinksPolicies.ps1 @@ -5,11 +5,15 @@ function Set-CIPPDBCacheExoSafeLinksPolicies { .PARAMETER TenantFilter The tenant to cache Safe Links data for + + .PARAMETER QueueId + The queue ID to update with total tasks (optional) #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] - [string]$TenantFilter + [string]$TenantFilter, + [string]$QueueId ) try { diff --git a/Modules/CIPPCore/Public/Set-CIPPDBCacheExoSafeLinksPolicy.ps1 b/Modules/CIPPCore/Public/Set-CIPPDBCacheExoSafeLinksPolicy.ps1 index a3252b245bcd..5498a9cb5385 100644 --- a/Modules/CIPPCore/Public/Set-CIPPDBCacheExoSafeLinksPolicy.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPDBCacheExoSafeLinksPolicy.ps1 @@ -5,11 +5,15 @@ function Set-CIPPDBCacheExoSafeLinksPolicy { .PARAMETER TenantFilter The tenant to cache Safe Links policy data for + + .PARAMETER QueueId + The queue ID to update with total tasks (optional) #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] - [string]$TenantFilter + [string]$TenantFilter, + [string]$QueueId ) try { diff --git a/Modules/CIPPCore/Public/Set-CIPPDBCacheExoSharingPolicy.ps1 b/Modules/CIPPCore/Public/Set-CIPPDBCacheExoSharingPolicy.ps1 index bd4c28da68f5..ffaaed92ed0a 100644 --- a/Modules/CIPPCore/Public/Set-CIPPDBCacheExoSharingPolicy.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPDBCacheExoSharingPolicy.ps1 @@ -5,11 +5,15 @@ function Set-CIPPDBCacheExoSharingPolicy { .PARAMETER TenantFilter The tenant to cache sharing policies for + + .PARAMETER QueueId + The queue ID to update with total tasks (optional) #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] - [string]$TenantFilter + [string]$TenantFilter, + [string]$QueueId ) try { diff --git a/Modules/CIPPCore/Public/Set-CIPPDBCacheExoTenantAllowBlockList.ps1 b/Modules/CIPPCore/Public/Set-CIPPDBCacheExoTenantAllowBlockList.ps1 index 62b4385a5c8a..3906bf3d55f8 100644 --- a/Modules/CIPPCore/Public/Set-CIPPDBCacheExoTenantAllowBlockList.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPDBCacheExoTenantAllowBlockList.ps1 @@ -5,11 +5,15 @@ function Set-CIPPDBCacheExoTenantAllowBlockList { .PARAMETER TenantFilter The tenant to cache tenant allow/block list for + + .PARAMETER QueueId + The queue ID to update with total tasks (optional) #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] - [string]$TenantFilter + [string]$TenantFilter, + [string]$QueueId ) try { diff --git a/Modules/CIPPCore/Public/Set-CIPPDBCacheExoTransportRules.ps1 b/Modules/CIPPCore/Public/Set-CIPPDBCacheExoTransportRules.ps1 index 6f83273af842..f08860ac00c6 100644 --- a/Modules/CIPPCore/Public/Set-CIPPDBCacheExoTransportRules.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPDBCacheExoTransportRules.ps1 @@ -5,11 +5,15 @@ function Set-CIPPDBCacheExoTransportRules { .PARAMETER TenantFilter The tenant to cache Transport Rules for + + .PARAMETER QueueId + The queue ID to update with total tasks (optional) #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] - [string]$TenantFilter + [string]$TenantFilter, + [string]$QueueId ) try { diff --git a/Modules/CIPPCore/Public/Set-CIPPDBCacheGroups.ps1 b/Modules/CIPPCore/Public/Set-CIPPDBCacheGroups.ps1 index d3be98c3f17e..b153085bc954 100644 --- a/Modules/CIPPCore/Public/Set-CIPPDBCacheGroups.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPDBCacheGroups.ps1 @@ -5,11 +5,15 @@ function Set-CIPPDBCacheGroups { .PARAMETER TenantFilter The tenant to cache groups for + + .PARAMETER QueueId + The queue ID to update with total tasks (optional) #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] - [string]$TenantFilter + [string]$TenantFilter, + [string]$QueueId ) try { diff --git a/Modules/CIPPCore/Public/Set-CIPPDBCacheGuests.ps1 b/Modules/CIPPCore/Public/Set-CIPPDBCacheGuests.ps1 index 36abaabef11d..867d45b791cd 100644 --- a/Modules/CIPPCore/Public/Set-CIPPDBCacheGuests.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPDBCacheGuests.ps1 @@ -5,11 +5,15 @@ function Set-CIPPDBCacheGuests { .PARAMETER TenantFilter The tenant to cache guest users for + + .PARAMETER QueueId + The queue ID to update with total tasks (optional) #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] - [string]$TenantFilter + [string]$TenantFilter, + [string]$QueueId ) try { diff --git a/Modules/CIPPCore/Public/Set-CIPPDBCacheIntuneAppProtectionPolicies.ps1 b/Modules/CIPPCore/Public/Set-CIPPDBCacheIntuneAppProtectionPolicies.ps1 index 1906b3bcdfda..c43e17e6f389 100644 --- a/Modules/CIPPCore/Public/Set-CIPPDBCacheIntuneAppProtectionPolicies.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPDBCacheIntuneAppProtectionPolicies.ps1 @@ -5,11 +5,15 @@ function Set-CIPPDBCacheIntuneAppProtectionPolicies { .PARAMETER TenantFilter The tenant to cache app protection policies for + + .PARAMETER QueueId + The queue ID to update with total tasks (optional) #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] - [string]$TenantFilter + [string]$TenantFilter, + [string]$QueueId ) try { diff --git a/Modules/CIPPCore/Public/Set-CIPPDBCacheIntunePolicies.ps1 b/Modules/CIPPCore/Public/Set-CIPPDBCacheIntunePolicies.ps1 index 51c2642f1397..c20f90891d1e 100644 --- a/Modules/CIPPCore/Public/Set-CIPPDBCacheIntunePolicies.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPDBCacheIntunePolicies.ps1 @@ -5,11 +5,15 @@ function Set-CIPPDBCacheIntunePolicies { .PARAMETER TenantFilter The tenant to cache Intune policies for + + .PARAMETER QueueId + The queue ID to update with total tasks (optional) #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] - [string]$TenantFilter + [string]$TenantFilter, + [string]$QueueId ) try { diff --git a/Modules/CIPPCore/Public/Set-CIPPDBCacheLicenseOverview.ps1 b/Modules/CIPPCore/Public/Set-CIPPDBCacheLicenseOverview.ps1 index 5ba1ef461f0b..a4034eac1c56 100644 --- a/Modules/CIPPCore/Public/Set-CIPPDBCacheLicenseOverview.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPDBCacheLicenseOverview.ps1 @@ -5,11 +5,15 @@ function Set-CIPPDBCacheLicenseOverview { .PARAMETER TenantFilter The tenant to cache license overview for + + .PARAMETER QueueId + The queue ID to update with total tasks (optional) #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] - [string]$TenantFilter + [string]$TenantFilter, + [string]$QueueId ) try { diff --git a/Modules/CIPPCore/Public/Set-CIPPDBCacheMFAState.ps1 b/Modules/CIPPCore/Public/Set-CIPPDBCacheMFAState.ps1 index 40fd5bb12ddf..ccd489fe14ce 100644 --- a/Modules/CIPPCore/Public/Set-CIPPDBCacheMFAState.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPDBCacheMFAState.ps1 @@ -5,11 +5,15 @@ function Set-CIPPDBCacheMFAState { .PARAMETER TenantFilter The tenant to cache MFA state for + + .PARAMETER QueueId + The queue ID to update with total tasks (optional) #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] - [string]$TenantFilter + [string]$TenantFilter, + [string]$QueueId ) try { diff --git a/Modules/CIPPCore/Public/Set-CIPPDBCacheMailboxRules.ps1 b/Modules/CIPPCore/Public/Set-CIPPDBCacheMailboxRules.ps1 new file mode 100644 index 000000000000..e99c47bdf7a4 --- /dev/null +++ b/Modules/CIPPCore/Public/Set-CIPPDBCacheMailboxRules.ps1 @@ -0,0 +1,60 @@ +function Set-CIPPDBCacheMailboxRules { + <# + .SYNOPSIS + Caches mailbox rules for a tenant + + .PARAMETER TenantFilter + The tenant to cache mailbox rules 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 mailbox rules' -sev Debug + + # Get mailboxes + $Mailboxes = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-Mailbox' -Select 'userPrincipalName,GUID' + $Request = $Mailboxes | ForEach-Object { + @{ + OperationGuid = $_.UserPrincipalName + CmdletInput = @{ + CmdletName = 'Get-InboxRule' + Parameters = @{ + Mailbox = $_.UserPrincipalName + } + } + } + } + + $Rules = New-ExoBulkRequest -tenantid $TenantFilter -cmdletArray @($Request) | Where-Object { $_.Identity } + + if (($Rules | Measure-Object).Count -gt 0) { + $MailboxRules = foreach ($Rule in $Rules) { + $Rule | Add-Member -NotePropertyName 'Tenant' -NotePropertyValue $TenantFilter -Force + $Rule | Add-Member -NotePropertyName 'UserPrincipalName' -NotePropertyValue $Rule.OperationGuid -Force + $Rule + } + + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'MailboxRules' -Data @($MailboxRules) + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'MailboxRules' -Data @($MailboxRules) -Count + + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Cached $($MailboxRules.Count) mailbox rules successfully" -sev Debug + } else { + # Cache empty result to indicate successful check with no rules + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'MailboxRules' -Data @() + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'MailboxRules' -Data @() -Count + + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'No mailbox rules found' -sev Debug + } + + } catch { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Failed to cache mailbox rules: $($_.Exception.Message)" -sev Error -LogData (Get-CippException -Exception $_) + } +} diff --git a/Modules/CIPPCore/Public/Set-CIPPDBCacheMailboxUsage.ps1 b/Modules/CIPPCore/Public/Set-CIPPDBCacheMailboxUsage.ps1 index 0b8e91fbc176..bd13f0b2da02 100644 --- a/Modules/CIPPCore/Public/Set-CIPPDBCacheMailboxUsage.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPDBCacheMailboxUsage.ps1 @@ -5,11 +5,15 @@ function Set-CIPPDBCacheMailboxUsage { .PARAMETER TenantFilter The tenant to cache mailbox usage for + + .PARAMETER QueueId + The queue ID to update with total tasks (optional) #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] - [string]$TenantFilter + [string]$TenantFilter, + [string]$QueueId ) try { diff --git a/Modules/CIPPCore/Public/Set-CIPPDBCacheMailboxes.ps1 b/Modules/CIPPCore/Public/Set-CIPPDBCacheMailboxes.ps1 index 3716fa9a5c06..e965fa7e1e7b 100644 --- a/Modules/CIPPCore/Public/Set-CIPPDBCacheMailboxes.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPDBCacheMailboxes.ps1 @@ -5,11 +5,15 @@ function Set-CIPPDBCacheMailboxes { .PARAMETER TenantFilter The tenant to cache mailboxes for + + .PARAMETER QueueId + The queue ID to update with total tasks #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] - [string]$TenantFilter + [string]$TenantFilter, + [string]$QueueId ) try { @@ -68,6 +72,7 @@ function Set-CIPPDBCacheMailboxes { # Add mailbox permissions batch $Batches.Add([PSCustomObject]@{ FunctionName = 'GetMailboxPermissionsBatch' + QueueName = "Mailbox Permissions Batch $BatchNumber/$TotalBatches - $TenantFilter" TenantFilter = $TenantFilter Mailboxes = $BatchMailboxUPNs BatchNumber = $BatchNumber @@ -77,6 +82,7 @@ function Set-CIPPDBCacheMailboxes { # Add calendar permissions batch for the same mailboxes $Batches.Add([PSCustomObject]@{ FunctionName = 'GetCalendarPermissionsBatch' + QueueName = "Calendar Permissions Batch $BatchNumber/$TotalBatches - $TenantFilter" TenantFilter = $TenantFilter Mailboxes = $BatchMailboxUPNs BatchNumber = $BatchNumber @@ -88,6 +94,12 @@ function Set-CIPPDBCacheMailboxes { $MailboxPermBatches = $Batches | Where-Object { $_.FunctionName -eq 'GetMailboxPermissionsBatch' } $CalendarPermBatches = $Batches | Where-Object { $_.FunctionName -eq 'GetCalendarPermissionsBatch' } + # Update queue with additional tasks if QueueId is provided + if ($QueueId) { + Update-CippQueueEntry -RowKey $QueueId -TotalTasks $Batches.Count -IncrementTotalTasks + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Updated queue $QueueId with $($Batches.Count) additional tasks" -sev Debug + } + # Start single orchestrator for both mailbox and calendar permissions $InputObject = [PSCustomObject]@{ Batch = @($Batches) @@ -99,6 +111,12 @@ function Set-CIPPDBCacheMailboxes { } } } + if ($QueueId) { + # Add QueueId to each batch item + foreach ($Batch in $Batches) { + $Batch | Add-Member -NotePropertyName 'QueueId' -NotePropertyValue $QueueId -Force + } + } Start-NewOrchestration -FunctionName 'CIPPOrchestrator' -InputObject ($InputObject | ConvertTo-Json -Compress -Depth 5) Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Started mailbox and calendar permission caching orchestrator with $($Batches.Count) batches" -sev Debug } else { diff --git a/Modules/CIPPCore/Public/Set-CIPPDBCacheManagedDeviceEncryptionStates.ps1 b/Modules/CIPPCore/Public/Set-CIPPDBCacheManagedDeviceEncryptionStates.ps1 index 4e4f78f33c2a..7dd8764854ff 100644 --- a/Modules/CIPPCore/Public/Set-CIPPDBCacheManagedDeviceEncryptionStates.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPDBCacheManagedDeviceEncryptionStates.ps1 @@ -5,11 +5,15 @@ function Set-CIPPDBCacheManagedDeviceEncryptionStates { .PARAMETER TenantFilter The tenant to cache managed device encryption states for + + .PARAMETER QueueId + The queue ID to update with total tasks (optional) #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] - [string]$TenantFilter + [string]$TenantFilter, + [string]$QueueId ) try { diff --git a/Modules/CIPPCore/Public/Set-CIPPDBCacheManagedDevices.ps1 b/Modules/CIPPCore/Public/Set-CIPPDBCacheManagedDevices.ps1 index ee724f9343af..509a136a4fec 100644 --- a/Modules/CIPPCore/Public/Set-CIPPDBCacheManagedDevices.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPDBCacheManagedDevices.ps1 @@ -5,11 +5,15 @@ function Set-CIPPDBCacheManagedDevices { .PARAMETER TenantFilter The tenant to cache managed devices for + + .PARAMETER QueueId + The queue ID to update with total tasks (optional) #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] - [string]$TenantFilter + [string]$TenantFilter, + [string]$QueueId ) try { diff --git a/Modules/CIPPCore/Public/Set-CIPPDBCacheOAuth2PermissionGrants.ps1 b/Modules/CIPPCore/Public/Set-CIPPDBCacheOAuth2PermissionGrants.ps1 index 78ea6366ae5d..6fe33e93d378 100644 --- a/Modules/CIPPCore/Public/Set-CIPPDBCacheOAuth2PermissionGrants.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPDBCacheOAuth2PermissionGrants.ps1 @@ -5,11 +5,15 @@ function Set-CIPPDBCacheOAuth2PermissionGrants { .PARAMETER TenantFilter The tenant to cache OAuth2 permission grants for + + .PARAMETER QueueId + The queue ID to update with total tasks (optional) #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] - [string]$TenantFilter + [string]$TenantFilter, + [string]$QueueId ) try { diff --git a/Modules/CIPPCore/Public/Set-CIPPDBCacheOneDriveUsage.ps1 b/Modules/CIPPCore/Public/Set-CIPPDBCacheOneDriveUsage.ps1 index 4df2d347f02e..60753ef0aedc 100644 --- a/Modules/CIPPCore/Public/Set-CIPPDBCacheOneDriveUsage.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPDBCacheOneDriveUsage.ps1 @@ -5,11 +5,15 @@ function Set-CIPPDBCacheOneDriveUsage { .PARAMETER TenantFilter The tenant to cache OneDrive usage for + + .PARAMETER QueueId + The queue ID to update with total tasks (optional) #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] - [string]$TenantFilter + [string]$TenantFilter, + [string]$QueueId ) try { diff --git a/Modules/CIPPCore/Public/Set-CIPPDBCacheOrganization.ps1 b/Modules/CIPPCore/Public/Set-CIPPDBCacheOrganization.ps1 index 4710caf76427..3860aad8fe1e 100644 --- a/Modules/CIPPCore/Public/Set-CIPPDBCacheOrganization.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPDBCacheOrganization.ps1 @@ -5,11 +5,15 @@ function Set-CIPPDBCacheOrganization { .PARAMETER TenantFilter The tenant to cache organization data for + + .PARAMETER QueueId + The queue ID to update with total tasks (optional) #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] - [string]$TenantFilter + [string]$TenantFilter, + [string]$QueueId ) try { diff --git a/Modules/CIPPCore/Public/Set-CIPPDBCachePIMSettings.ps1 b/Modules/CIPPCore/Public/Set-CIPPDBCachePIMSettings.ps1 index 224d357389d6..768525f69e94 100644 --- a/Modules/CIPPCore/Public/Set-CIPPDBCachePIMSettings.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPDBCachePIMSettings.ps1 @@ -5,11 +5,15 @@ function Set-CIPPDBCachePIMSettings { .PARAMETER TenantFilter The tenant to cache PIM settings for + + .PARAMETER QueueId + The queue ID to update with total tasks (optional) #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] - [string]$TenantFilter + [string]$TenantFilter, + [string]$QueueId ) try { diff --git a/Modules/CIPPCore/Public/Set-CIPPDBCacheRiskDetections.ps1 b/Modules/CIPPCore/Public/Set-CIPPDBCacheRiskDetections.ps1 index 2acc5fa2f099..f7878e2f9edb 100644 --- a/Modules/CIPPCore/Public/Set-CIPPDBCacheRiskDetections.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPDBCacheRiskDetections.ps1 @@ -5,11 +5,15 @@ function Set-CIPPDBCacheRiskDetections { .PARAMETER TenantFilter The tenant to cache risk detections for + + .PARAMETER QueueId + The queue ID to update with total tasks (optional) #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] - [string]$TenantFilter + [string]$TenantFilter, + [string]$QueueId ) try { diff --git a/Modules/CIPPCore/Public/Set-CIPPDBCacheRiskyServicePrincipals.ps1 b/Modules/CIPPCore/Public/Set-CIPPDBCacheRiskyServicePrincipals.ps1 index 09092dd18716..4efcfce693a1 100644 --- a/Modules/CIPPCore/Public/Set-CIPPDBCacheRiskyServicePrincipals.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPDBCacheRiskyServicePrincipals.ps1 @@ -5,11 +5,15 @@ function Set-CIPPDBCacheRiskyServicePrincipals { .PARAMETER TenantFilter The tenant to cache risky service principals for + + .PARAMETER QueueId + The queue ID to update with total tasks (optional) #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] - [string]$TenantFilter + [string]$TenantFilter, + [string]$QueueId ) try { diff --git a/Modules/CIPPCore/Public/Set-CIPPDBCacheRiskyUsers.ps1 b/Modules/CIPPCore/Public/Set-CIPPDBCacheRiskyUsers.ps1 index 813dff9da5ac..6e29032f0b03 100644 --- a/Modules/CIPPCore/Public/Set-CIPPDBCacheRiskyUsers.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPDBCacheRiskyUsers.ps1 @@ -5,11 +5,15 @@ function Set-CIPPDBCacheRiskyUsers { .PARAMETER TenantFilter The tenant to cache risky users for + + .PARAMETER QueueId + The queue ID to update with total tasks (optional) #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] - [string]$TenantFilter + [string]$TenantFilter, + [string]$QueueId ) try { diff --git a/Modules/CIPPCore/Public/Set-CIPPDBCacheRoleAssignmentScheduleInstances.ps1 b/Modules/CIPPCore/Public/Set-CIPPDBCacheRoleAssignmentScheduleInstances.ps1 index 6d269f2cf17c..5900d0620871 100644 --- a/Modules/CIPPCore/Public/Set-CIPPDBCacheRoleAssignmentScheduleInstances.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPDBCacheRoleAssignmentScheduleInstances.ps1 @@ -2,7 +2,8 @@ function Set-CIPPDBCacheRoleAssignmentScheduleInstances { [CmdletBinding()] param ( [Parameter(Mandatory = $true)] - [string]$TenantFilter + [string]$TenantFilter, + [string]$QueueId ) try { diff --git a/Modules/CIPPCore/Public/Set-CIPPDBCacheRoleEligibilitySchedules.ps1 b/Modules/CIPPCore/Public/Set-CIPPDBCacheRoleEligibilitySchedules.ps1 index a4f559b5bd6b..f13c8def21ea 100644 --- a/Modules/CIPPCore/Public/Set-CIPPDBCacheRoleEligibilitySchedules.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPDBCacheRoleEligibilitySchedules.ps1 @@ -5,11 +5,15 @@ function Set-CIPPDBCacheRoleEligibilitySchedules { .PARAMETER TenantFilter The tenant to cache role eligibility schedules for + + .PARAMETER QueueId + The queue ID to update with total tasks (optional) #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] - [string]$TenantFilter + [string]$TenantFilter, + [string]$QueueId ) try { diff --git a/Modules/CIPPCore/Public/Set-CIPPDBCacheRoleManagementPolicies.ps1 b/Modules/CIPPCore/Public/Set-CIPPDBCacheRoleManagementPolicies.ps1 index a2c3f97b99d5..e162b3659f7c 100644 --- a/Modules/CIPPCore/Public/Set-CIPPDBCacheRoleManagementPolicies.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPDBCacheRoleManagementPolicies.ps1 @@ -5,11 +5,15 @@ function Set-CIPPDBCacheRoleManagementPolicies { .PARAMETER TenantFilter The tenant to cache role management policies for + + .PARAMETER QueueId + The queue ID to update with total tasks (optional) #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] - [string]$TenantFilter + [string]$TenantFilter, + [string]$QueueId ) try { diff --git a/Modules/CIPPCore/Public/Set-CIPPDBCacheRoles.ps1 b/Modules/CIPPCore/Public/Set-CIPPDBCacheRoles.ps1 index f9f6bd66c77d..bcc10c993b20 100644 --- a/Modules/CIPPCore/Public/Set-CIPPDBCacheRoles.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPDBCacheRoles.ps1 @@ -5,11 +5,15 @@ function Set-CIPPDBCacheRoles { .PARAMETER TenantFilter The tenant to cache role data for + + .PARAMETER QueueId + The queue ID to update with total tasks (optional) #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] - [string]$TenantFilter + [string]$TenantFilter, + [string]$QueueId ) try { diff --git a/Modules/CIPPCore/Public/Set-CIPPDBCacheSecureScore.ps1 b/Modules/CIPPCore/Public/Set-CIPPDBCacheSecureScore.ps1 index de3eab54f0ed..03bb7565d471 100644 --- a/Modules/CIPPCore/Public/Set-CIPPDBCacheSecureScore.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPDBCacheSecureScore.ps1 @@ -5,11 +5,15 @@ function Set-CIPPDBCacheSecureScore { .PARAMETER TenantFilter The tenant to cache secure score for + + .PARAMETER QueueId + The queue ID to update with total tasks (optional) #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] - [string]$TenantFilter + [string]$TenantFilter, + [string]$QueueId ) try { diff --git a/Modules/CIPPCore/Public/Set-CIPPDBCacheServicePrincipalRiskDetections.ps1 b/Modules/CIPPCore/Public/Set-CIPPDBCacheServicePrincipalRiskDetections.ps1 index a437723e0abd..bcd4cfba838e 100644 --- a/Modules/CIPPCore/Public/Set-CIPPDBCacheServicePrincipalRiskDetections.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPDBCacheServicePrincipalRiskDetections.ps1 @@ -5,11 +5,15 @@ function Set-CIPPDBCacheServicePrincipalRiskDetections { .PARAMETER TenantFilter The tenant to cache service principal risk detections for + + .PARAMETER QueueId + The queue ID to update with total tasks (optional) #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] - [string]$TenantFilter + [string]$TenantFilter, + [string]$QueueId ) try { diff --git a/Modules/CIPPCore/Public/Set-CIPPDBCacheServicePrincipals.ps1 b/Modules/CIPPCore/Public/Set-CIPPDBCacheServicePrincipals.ps1 index b91941940e66..af887bf31342 100644 --- a/Modules/CIPPCore/Public/Set-CIPPDBCacheServicePrincipals.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPDBCacheServicePrincipals.ps1 @@ -5,11 +5,15 @@ function Set-CIPPDBCacheServicePrincipals { .PARAMETER TenantFilter The tenant to cache service principals for + + .PARAMETER QueueId + The queue ID to update with total tasks (optional) #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] - [string]$TenantFilter + [string]$TenantFilter, + [string]$QueueId ) try { diff --git a/Modules/CIPPCore/Public/Set-CIPPDBCacheSettings.ps1 b/Modules/CIPPCore/Public/Set-CIPPDBCacheSettings.ps1 index 76c682ba3adb..5bb7cec33ca5 100644 --- a/Modules/CIPPCore/Public/Set-CIPPDBCacheSettings.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPDBCacheSettings.ps1 @@ -5,11 +5,15 @@ function Set-CIPPDBCacheSettings { .PARAMETER TenantFilter The tenant to cache settings for + + .PARAMETER QueueId + The queue ID to update with total tasks (optional) #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] - [string]$TenantFilter + [string]$TenantFilter, + [string]$QueueId ) try { diff --git a/Modules/CIPPCore/Public/Set-CIPPDBCacheUserRegistrationDetails.ps1 b/Modules/CIPPCore/Public/Set-CIPPDBCacheUserRegistrationDetails.ps1 index 818a441dc0f3..64e596f669c9 100644 --- a/Modules/CIPPCore/Public/Set-CIPPDBCacheUserRegistrationDetails.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPDBCacheUserRegistrationDetails.ps1 @@ -5,11 +5,15 @@ function Set-CIPPDBCacheUserRegistrationDetails { .PARAMETER TenantFilter The tenant to cache user registration details for + + .PARAMETER QueueId + The queue ID to update with total tasks (optional) #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] - [string]$TenantFilter + [string]$TenantFilter, + [string]$QueueId ) try { diff --git a/Modules/CIPPCore/Public/Set-CIPPDBCacheUsers.ps1 b/Modules/CIPPCore/Public/Set-CIPPDBCacheUsers.ps1 index 72f498142664..8462dbd75e7f 100644 --- a/Modules/CIPPCore/Public/Set-CIPPDBCacheUsers.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPDBCacheUsers.ps1 @@ -5,11 +5,15 @@ function Set-CIPPDBCacheUsers { .PARAMETER TenantFilter The tenant to cache users for + + .PARAMETER QueueId + The queue ID to update with total tasks (optional) #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] - [string]$TenantFilter + [string]$TenantFilter, + [string]$QueueId ) try { diff --git a/Modules/CIPPCore/Public/Set-CIPPDbCacheTestData.ps1 b/Modules/CIPPCore/Public/Set-CIPPDbCacheTestData.ps1 index 5a5f6b2094f1..10302dd69d17 100644 --- a/Modules/CIPPCore/Public/Set-CIPPDbCacheTestData.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPDbCacheTestData.ps1 @@ -9,6 +9,9 @@ function Set-CIPPDbCacheTestData { .PARAMETER TenantFilter The tenant to use for test data + .PARAMETER QueueId + The queue ID to update with total tasks (optional) + .PARAMETER Count Number of test objects to generate (default: 50000) #> @@ -16,6 +19,7 @@ function Set-CIPPDbCacheTestData { param( [Parameter(Mandatory = $true)] [string]$TenantFilter, + [string]$QueueId, [Parameter(Mandatory = $false)] [int]$Count = 50000 From ec8f481eb09d260e402d4e7136d6c5dab4d2b204 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Tue, 10 Feb 2026 15:05:13 -0500 Subject: [PATCH 043/150] Cache mailbox rules batching and DB update ops Introduce batched mailbox-rules caching and add DB item update/remove helpers. Adds Push-GetMailboxRulesBatch and Push-StoreMailboxRules activity functions to fetch and aggregate mailbox rules in batches, and new Update-CIPPDbItem / Remove-CIPPDbItem cmdlets to support partial updates and removals in the CippReportingDB. Enhance Set-CIPPDBCacheMailboxes to accept a Types parameter (Permissions, CalendarPermissions, Rules) and spawn separate orchestrators for permission and rules batches; remove the old Set-CIPPDBCacheMailboxRules implementation. Propagate Types through Invoke-ExecCIPPDBCache and Push-ExecCIPPDBCache, improve logging, and update Set-CIPPMailboxRule / Remove-CIPPMailboxRule to keep the cache in sync when rules are changed or deleted. --- .../CIPPDBCache/Push-ExecCIPPDBCache.ps1 | 8 + .../Push-CIPPDBCacheData.ps1 | 1 - .../Push-GetMailboxRulesBatch.ps1 | 55 ++++++ .../Push-StoreMailboxRules.ps1 | 68 +++++++ .../CIPP/Core/Invoke-ExecCIPPDBCache.ps1 | 39 ++-- Modules/CIPPCore/Public/Remove-CIPPDbItem.ps1 | 76 ++++++++ .../Public/Remove-CIPPMailboxRule.ps1 | 10 +- .../Public/Set-CIPPDBCacheMailboxRules.ps1 | 60 ------ .../Public/Set-CIPPDBCacheMailboxes.ps1 | 183 ++++++++++++------ .../CIPPCore/Public/Set-CIPPMailboxRule.ps1 | 11 ++ Modules/CIPPCore/Public/Update-CIPPDbItem.ps1 | 97 ++++++++++ 11 files changed, 467 insertions(+), 141 deletions(-) create mode 100644 Modules/CIPPCore/Public/Entrypoints/Activity Triggers/Push-GetMailboxRulesBatch.ps1 create mode 100644 Modules/CIPPCore/Public/Entrypoints/Activity Triggers/Push-StoreMailboxRules.ps1 create mode 100644 Modules/CIPPCore/Public/Remove-CIPPDbItem.ps1 delete mode 100644 Modules/CIPPCore/Public/Set-CIPPDBCacheMailboxRules.ps1 create mode 100644 Modules/CIPPCore/Public/Update-CIPPDbItem.ps1 diff --git a/Modules/CIPPCore/Public/Entrypoints/Activity Triggers/CIPPDBCache/Push-ExecCIPPDBCache.ps1 b/Modules/CIPPCore/Public/Entrypoints/Activity Triggers/CIPPDBCache/Push-ExecCIPPDBCache.ps1 index 7c4f04c772e0..ac069351da91 100644 --- a/Modules/CIPPCore/Public/Entrypoints/Activity Triggers/CIPPDBCache/Push-ExecCIPPDBCache.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/Activity Triggers/CIPPDBCache/Push-ExecCIPPDBCache.ps1 @@ -15,6 +15,7 @@ function Push-ExecCIPPDBCache { $Name = $Item.Name $TenantFilter = $Item.TenantFilter $QueueId = $Item.QueueId + $Types = $Item.Types try { Write-Information "Collecting $Name for tenant $TenantFilter" @@ -38,6 +39,13 @@ function Push-ExecCIPPDBCache { $CacheFunctionParams.QueueId = $QueueId } + # Add Types if provided (for Mailboxes function) + if ($Types) { + $CacheFunctionParams.Types = $Types + } + + Write-Information "Executing $FullFunctionName with parameters: $(($CacheFunctionParams.GetEnumerator() | ForEach-Object { "$($_.Key)=$($_.Value)" }) -join ', '))" + # Execute the cache function & $FullFunctionName @CacheFunctionParams diff --git a/Modules/CIPPCore/Public/Entrypoints/Activity Triggers/Push-CIPPDBCacheData.ps1 b/Modules/CIPPCore/Public/Entrypoints/Activity Triggers/Push-CIPPDBCacheData.ps1 index 2f18e8e92b2d..6bdf8f889ddf 100644 --- a/Modules/CIPPCore/Public/Entrypoints/Activity Triggers/Push-CIPPDBCacheData.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/Activity Triggers/Push-CIPPDBCacheData.ps1 @@ -99,7 +99,6 @@ function Push-CIPPDBCacheData { 'ExoPresetSecurityPolicy' 'ExoTenantAllowBlockList' 'Mailboxes' - 'MailboxRules' 'CASMailboxes' 'MailboxUsage' 'OneDriveUsage' diff --git a/Modules/CIPPCore/Public/Entrypoints/Activity Triggers/Push-GetMailboxRulesBatch.ps1 b/Modules/CIPPCore/Public/Entrypoints/Activity Triggers/Push-GetMailboxRulesBatch.ps1 new file mode 100644 index 000000000000..8ba597b4e1db --- /dev/null +++ b/Modules/CIPPCore/Public/Entrypoints/Activity Triggers/Push-GetMailboxRulesBatch.ps1 @@ -0,0 +1,55 @@ +function Push-GetMailboxRulesBatch { + <# + .SYNOPSIS + Caches mailbox rules for a batch of mailboxes + + .PARAMETER InputObject + The batch object containing TenantFilter and Mailboxes array + #> + [CmdletBinding()] + param($Item) + + $TenantFilter = $Item.TenantFilter + $Mailboxes = $Item.Mailboxes + $BatchNumber = $Item.BatchNumber + $TotalBatches = $Item.TotalBatches + $QueueId = $Item.QueueId + + try { + Write-Information "Processing mailbox rules batch $BatchNumber/$TotalBatches for tenant $TenantFilter with $($Mailboxes.Count) mailboxes" + + # Build bulk request for mailbox rules + $Request = $Mailboxes | ForEach-Object { + @{ + OperationGuid = $_ + CmdletInput = @{ + CmdletName = 'Get-InboxRule' + Parameters = @{ + Mailbox = $_ + } + } + } + } + + $Rules = New-ExoBulkRequest -tenantid $TenantFilter -cmdletArray @($Request) | Where-Object { $_.Identity } + + Write-Information "Retrieved $($Rules.Count) rules from batch $BatchNumber/$TotalBatches" + + # Add metadata and return for aggregation + if (($Rules | Measure-Object).Count -gt 0) { + $RulesWithMetadata = foreach ($Rule in $Rules) { + $Rule | Add-Member -NotePropertyName 'Tenant' -NotePropertyValue $TenantFilter -Force + $Rule | Add-Member -NotePropertyName 'UserPrincipalName' -NotePropertyValue $Rule.OperationGuid -Force + $Rule + } + return , $RulesWithMetadata + } else { + Write-Information "No rules found in batch $BatchNumber/$TotalBatches" + return , @() + } + + } catch { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Failed to process mailbox rules batch $BatchNumber/$TotalBatches : $($_.Exception.Message)" -sev Error -LogData (Get-CippException -Exception $_) + throw + } +} diff --git a/Modules/CIPPCore/Public/Entrypoints/Activity Triggers/Push-StoreMailboxRules.ps1 b/Modules/CIPPCore/Public/Entrypoints/Activity Triggers/Push-StoreMailboxRules.ps1 new file mode 100644 index 000000000000..c6938db264cd --- /dev/null +++ b/Modules/CIPPCore/Public/Entrypoints/Activity Triggers/Push-StoreMailboxRules.ps1 @@ -0,0 +1,68 @@ +function Push-StoreMailboxRules { + <# + .SYNOPSIS + Post-execution function to aggregate and store all mailbox rules + + .DESCRIPTION + Collects results from all batches and stores them in the reporting database + + .FUNCTIONALITY + Entrypoint + #> + [CmdletBinding()] + param($Item) + + $TenantFilter = $Item.Parameters.TenantFilter + $Results = $Item.Results + + try { + Write-Information "Storing mailbox rules for tenant $TenantFilter" + Write-Information "Received $($Results.Count) batch results" + + # Aggregate all rules from batches + $AllRules = [System.Collections.Generic.List[object]]::new() + + foreach ($BatchResult in $Results) { + # Activity functions may return an array + $ActualResult = $BatchResult + if ($BatchResult -is [array] -and $BatchResult.Count -gt 0) { + Write-Information "Result is array with $($BatchResult.Count) elements" + # If first element is array of rules, use it + if ($BatchResult[0] -is [array]) { + $ActualResult = $BatchResult[0] + } else { + $ActualResult = $BatchResult + } + } + + if ($ActualResult) { + if ($ActualResult -is [array]) { + Write-Information "Adding $($ActualResult.Count) rules from batch" + $AllRules.AddRange($ActualResult) + } else { + Write-Information 'Adding 1 rule from batch' + $AllRules.Add($ActualResult) + } + } + } + + Write-Information "Aggregated $($AllRules.Count) total mailbox rules" + + # Store all rules + if ($AllRules.Count -gt 0) { + $AllRules | Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'MailboxRules' -AddCount + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Cached $($AllRules.Count) mailbox rules" -sev Info + } else { + # Store empty result to indicate successful check with no rules + @() | Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'MailboxRules' -AddCount + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'No mailbox rules found to cache' -sev Info + } + + return + + } catch { + $ErrorMsg = "Failed to store mailbox rules for tenant $TenantFilter : $($_.Exception.Message)" + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message $ErrorMsg -sev Error + throw $ErrorMsg + } +} diff --git a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecCIPPDBCache.ps1 b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecCIPPDBCache.ps1 index dd526d2a574b..34ef1b3e1029 100644 --- a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecCIPPDBCache.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecCIPPDBCache.ps1 @@ -11,8 +11,9 @@ function Invoke-ExecCIPPDBCache { $APIName = $Request.Params.CIPPEndpoint $TenantFilter = $Request.Query.TenantFilter $Name = $Request.Query.Name + $Types = $Request.Query.Types - Write-Information "ExecCIPPDBCache called with Name: '$Name', TenantFilter: '$TenantFilter'" + Write-Information "ExecCIPPDBCache called with Name: '$Name', TenantFilter: '$TenantFilter', Types: '$Types'" try { if ([string]::IsNullOrEmpty($Name)) { @@ -30,8 +31,6 @@ function Invoke-ExecCIPPDBCache { throw "Cache function '$FunctionName' not found" } - Write-LogMessage -API $APIName -tenant $TenantFilter -message "Starting CIPP DB cache for $Name" -sev Info - # Create queue entry for tracking $QueueName = if ($TenantFilter -eq 'AllTenants') { "$Name Cache Sync (All Tenants)" @@ -45,13 +44,18 @@ function Invoke-ExecCIPPDBCache { $Queue = New-CippQueueEntry -Name $QueueName -TotalTasks ($TenantList | Measure-Object).Count $Batch = $TenantList | ForEach-Object { - [PSCustomObject]@{ + $BatchItem = [PSCustomObject]@{ FunctionName = 'ExecCIPPDBCache' - QueueName = "$Name Cache - $($_.defaultDomainName)" Name = $Name + QueueName = "$Name Cache - $($_.defaultDomainName)" TenantFilter = $_.defaultDomainName QueueId = $Queue.RowKey } + # Add Types parameter if provided + if ($Types) { + $BatchItem | Add-Member -NotePropertyName 'Types' -NotePropertyValue @($Types -split ',') -Force + } + $BatchItem } $InputObject = [PSCustomObject]@{ @@ -60,28 +64,33 @@ function Invoke-ExecCIPPDBCache { SkipLog = $false } - Write-LogMessage -API $APIName -tenant $TenantFilter -message "Starting CIPP DB cache for $Name across $($TenantList.Count) tenants" -sev Info + Write-LogMessage -Headers $Request.Headers -API $APIName -tenant $TenantFilter -message "Starting CIPP DB cache for $Name across $($TenantList.Count) tenants" -sev Info } else { # Single tenant $Queue = New-CippQueueEntry -Name $QueueName -TotalTasks 1 + $BatchItem = [PSCustomObject]@{ + FunctionName = 'ExecCIPPDBCache' + Name = $Name + QueueName = "$Name Cache - $TenantFilter" + TenantFilter = $TenantFilter + QueueId = $Queue.RowKey + } + # Add Types parameter if provided + if ($Types) { + $BatchItem | Add-Member -NotePropertyName 'Types' -NotePropertyValue @($Types -split ',') -Force + } + $InputObject = [PSCustomObject]@{ - Batch = @([PSCustomObject]@{ - QueueName = "$Name Cache - $TenantFilter" - FunctionName = 'ExecCIPPDBCache' - Name = $Name - TenantFilter = $TenantFilter - QueueId = $Queue.RowKey - }) + Batch = @($BatchItem) OrchestratorName = "CIPPDBCache_${Name}_$TenantFilter" SkipLog = $false } + Write-LogMessage -Headers $Request.Headers -API $APIName -tenant $TenantFilter -message "Starting CIPP DB cache for $Name on tenant $TenantFilter" -sev Info } $InstanceId = Start-NewOrchestration -FunctionName 'CIPPOrchestrator' -InputObject ($InputObject | ConvertTo-Json -Compress -Depth 5) - Write-LogMessage -API $APIName -tenant $TenantFilter -message "Started CIPP DB cache orchestrator for $Name with instance ID: $InstanceId" -sev Info - $ResultsMessage = if ($TenantFilter -eq 'AllTenants') { "Successfully started cache operation for $Name for all tenants" } else { diff --git a/Modules/CIPPCore/Public/Remove-CIPPDbItem.ps1 b/Modules/CIPPCore/Public/Remove-CIPPDbItem.ps1 new file mode 100644 index 000000000000..fcb2196e4a23 --- /dev/null +++ b/Modules/CIPPCore/Public/Remove-CIPPDbItem.ps1 @@ -0,0 +1,76 @@ +function Remove-CIPPDbItem { + <# + .SYNOPSIS + Remove an item from the CIPP Reporting database + + .DESCRIPTION + Removes a specific item from the CippReportingDB table using partition key (tenant) and row key (item ID) + + .PARAMETER TenantFilter + The tenant domain or GUID (partition key) + + .PARAMETER Type + The type of data being removed (used to find and update count) + + .PARAMETER ItemId + The item ID or identifier to remove (used in row key) + + .EXAMPLE + Remove-CIPPDbItem -TenantFilter 'contoso.onmicrosoft.com' -Type 'MailboxRules' -ItemId 'rule-id-123' + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter, + + [Parameter(Mandatory = $true)] + [string]$Type, + + [Parameter(Mandatory = $true)] + [string]$ItemId + ) + + try { + $Table = Get-CippTable -tablename 'CippReportingDB' + + # Sanitize the ItemId for RowKey (same as in Add-CIPPDbItem) + $SanitizedId = $ItemId -replace '[/\\#?]', '_' -replace '[\u0000-\u001F\u007F-\u009F]', '' + $RowKey = "$Type-$SanitizedId" + + # Try to get the entity + $Filter = "PartitionKey eq '$TenantFilter' and RowKey eq '$RowKey'" + $Entity = Get-CIPPAzDataTableEntity @Table -Filter $Filter + + if ($Entity) { + # Remove the entity + Remove-AzDataTableEntity @Table -Entity $Entity -Force + Write-LogMessage -API 'CIPPDbItem' -tenant $TenantFilter -message "Removed $Type item with ID: $ItemId" -sev Debug + + # Always decrement count + try { + $CountRowKey = "$Type-Count" + $CountFilter = "PartitionKey eq '$TenantFilter' and RowKey eq '$CountRowKey'" + $CountEntity = Get-CIPPAzDataTableEntity @Table -Filter $CountFilter + + if ($CountEntity -and $CountEntity.DataCount -gt 0) { + $CountEntity.DataCount = [int]$CountEntity.DataCount - 1 + Add-CIPPAzDataTableEntity @Table -Entity @{ + PartitionKey = $CountEntity.PartitionKey + RowKey = $CountEntity.RowKey + DataCount = $CountEntity.DataCount + ETag = $CountEntity.ETag + } -Force | Out-Null + Write-LogMessage -API 'CIPPDbItem' -tenant $TenantFilter -message "Decremented $Type count to $($CountEntity.DataCount)" -sev Debug + } + } catch { + Write-LogMessage -API 'CIPPDbItem' -tenant $TenantFilter -message "Failed to decrement count for $Type : $($_.Exception.Message)" -sev Warning + } + } else { + Write-LogMessage -API 'CIPPDbItem' -tenant $TenantFilter -message "Item not found for removal: $Type with ID $ItemId" -sev Debug + } + + } catch { + Write-LogMessage -API 'CIPPDbItem' -tenant $TenantFilter -message "Failed to remove $Type item: $($_.Exception.Message)" -sev Error -LogData (Get-CippException -Exception $_) + throw + } +} diff --git a/Modules/CIPPCore/Public/Remove-CIPPMailboxRule.ps1 b/Modules/CIPPCore/Public/Remove-CIPPMailboxRule.ps1 index 579c7f2d4801..1f470021a92b 100644 --- a/Modules/CIPPCore/Public/Remove-CIPPMailboxRule.ps1 +++ b/Modules/CIPPCore/Public/Remove-CIPPMailboxRule.ps1 @@ -22,7 +22,7 @@ function Remove-CIPPMailboxRule { Write-LogMessage -headers $Headers -API $APIName -message $Message -Sev 'Info' -tenant $TenantFilter return $Message } else { - ForEach ($rule in $Rules) { + foreach ($rule in $Rules) { $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Remove-InboxRule' -Anchor $Username -cmdParams @{Identity = $rule.Identity } } $Message = "Successfully deleted all rules for $($Username)" @@ -41,6 +41,14 @@ function Remove-CIPPMailboxRule { $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Remove-InboxRule' -Anchor $Username -cmdParams @{Identity = $RuleId } $Message = "Successfully deleted mailbox rule $($RuleName) for $($Username)" Write-LogMessage -headers $Headers -API $APIName -message "Deleted mailbox rule $($RuleName) for $($Username)" -Sev 'Info' -tenant $TenantFilter + + # Remove from cache if it exists + try { + Remove-CIPPDbItem -TenantFilter $TenantFilter -Type 'MailboxRules' -ItemId $RuleId + } catch { + Write-LogMessage -headers $Headers -API $APIName -message "Rule deleted but failed to remove from cache: $($_.Exception.Message)" -Sev 'Warning' -tenant $TenantFilter + } + return $Message } catch { $ErrorMessage = Get-CippException -Exception $_ diff --git a/Modules/CIPPCore/Public/Set-CIPPDBCacheMailboxRules.ps1 b/Modules/CIPPCore/Public/Set-CIPPDBCacheMailboxRules.ps1 deleted file mode 100644 index e99c47bdf7a4..000000000000 --- a/Modules/CIPPCore/Public/Set-CIPPDBCacheMailboxRules.ps1 +++ /dev/null @@ -1,60 +0,0 @@ -function Set-CIPPDBCacheMailboxRules { - <# - .SYNOPSIS - Caches mailbox rules for a tenant - - .PARAMETER TenantFilter - The tenant to cache mailbox rules 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 mailbox rules' -sev Debug - - # Get mailboxes - $Mailboxes = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-Mailbox' -Select 'userPrincipalName,GUID' - $Request = $Mailboxes | ForEach-Object { - @{ - OperationGuid = $_.UserPrincipalName - CmdletInput = @{ - CmdletName = 'Get-InboxRule' - Parameters = @{ - Mailbox = $_.UserPrincipalName - } - } - } - } - - $Rules = New-ExoBulkRequest -tenantid $TenantFilter -cmdletArray @($Request) | Where-Object { $_.Identity } - - if (($Rules | Measure-Object).Count -gt 0) { - $MailboxRules = foreach ($Rule in $Rules) { - $Rule | Add-Member -NotePropertyName 'Tenant' -NotePropertyValue $TenantFilter -Force - $Rule | Add-Member -NotePropertyName 'UserPrincipalName' -NotePropertyValue $Rule.OperationGuid -Force - $Rule - } - - Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'MailboxRules' -Data @($MailboxRules) - Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'MailboxRules' -Data @($MailboxRules) -Count - - Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Cached $($MailboxRules.Count) mailbox rules successfully" -sev Debug - } else { - # Cache empty result to indicate successful check with no rules - Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'MailboxRules' -Data @() - Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'MailboxRules' -Data @() -Count - - Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'No mailbox rules found' -sev Debug - } - - } catch { - Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Failed to cache mailbox rules: $($_.Exception.Message)" -sev Error -LogData (Get-CippException -Exception $_) - } -} diff --git a/Modules/CIPPCore/Public/Set-CIPPDBCacheMailboxes.ps1 b/Modules/CIPPCore/Public/Set-CIPPDBCacheMailboxes.ps1 index e965fa7e1e7b..d42279de2ec3 100644 --- a/Modules/CIPPCore/Public/Set-CIPPDBCacheMailboxes.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPDBCacheMailboxes.ps1 @@ -1,19 +1,25 @@ function Set-CIPPDBCacheMailboxes { <# .SYNOPSIS - Caches all mailboxes, CAS mailboxes, and mailbox permissions for a tenant + Caches all mailboxes and optionally related data (permissions, rules) for a tenant .PARAMETER TenantFilter The tenant to cache mailboxes for .PARAMETER QueueId The queue ID to update with total tasks + + .PARAMETER Types + Optional array of types to cache. Valid values: 'All', 'Permissions', 'CalendarPermissions', 'Rules' + If not specified, defaults to 'All' which caches all types. #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] [string]$TenantFilter, - [string]$QueueId + [string]$QueueId, + [ValidateSet('All', 'Permissions', 'CalendarPermissions', 'Rules')] + [string[]]$Types = @('All') ) try { @@ -52,75 +58,123 @@ function Set-CIPPDBCacheMailboxes { Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Cached $($Mailboxes.Count) mailboxes successfully" -sev Debug - # Start orchestrator to cache mailbox permissions in batches - $MailboxCount = ($Mailboxes | Measure-Object).Count - if ($MailboxCount -gt 0) { - Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Starting mailbox permission caching for $MailboxCount mailboxes" -sev Debug - - # Create batches of 10 mailboxes each for both mailbox and calendar permissions - $BatchSize = 10 - $Batches = [System.Collections.Generic.List[object]]::new() - $TotalBatches = [Math]::Ceiling($Mailboxes.Count / $BatchSize) - - for ($i = 0; $i -lt $Mailboxes.Count; $i += $BatchSize) { - $BatchMailboxes = $Mailboxes[$i..[Math]::Min($i + $BatchSize - 1, $Mailboxes.Count - 1)] - - # Only send UPN to batch function to reduce payload size - $BatchMailboxUPNs = $BatchMailboxes | Select-Object -ExpandProperty UPN - $BatchNumber = [Math]::Floor($i / $BatchSize) + 1 - - # Add mailbox permissions batch - $Batches.Add([PSCustomObject]@{ - FunctionName = 'GetMailboxPermissionsBatch' - QueueName = "Mailbox Permissions Batch $BatchNumber/$TotalBatches - $TenantFilter" - TenantFilter = $TenantFilter - Mailboxes = $BatchMailboxUPNs - BatchNumber = $BatchNumber - TotalBatches = $TotalBatches - }) - - # Add calendar permissions batch for the same mailboxes - $Batches.Add([PSCustomObject]@{ - FunctionName = 'GetCalendarPermissionsBatch' - QueueName = "Calendar Permissions Batch $BatchNumber/$TotalBatches - $TenantFilter" - TenantFilter = $TenantFilter - Mailboxes = $BatchMailboxUPNs - BatchNumber = $BatchNumber - TotalBatches = $TotalBatches - }) - } + # Expand 'All' to all available types + if ($Types -contains 'All') { + $Types = @('Permissions', 'CalendarPermissions', 'Rules') + } - # Split batches into mailbox and calendar permissions for separate post-execution - $MailboxPermBatches = $Batches | Where-Object { $_.FunctionName -eq 'GetMailboxPermissionsBatch' } - $CalendarPermBatches = $Batches | Where-Object { $_.FunctionName -eq 'GetCalendarPermissionsBatch' } + # Process additional types if specified + if ($Types -and $Types.Count -gt 0) { + $MailboxCount = ($Mailboxes | Measure-Object).Count + if ($MailboxCount -gt 0) { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Starting batch caching for types: $($Types -join ', ')" -sev Debug + Write-Information "Starting batch caching for types: $($Types -join ', ')" + + # Create batches based on selected types + $BatchSize = 10 + $TotalBatches = [Math]::Ceiling($Mailboxes.Count / $BatchSize) + + # Separate batches for permissions and rules + $PermissionBatches = [System.Collections.Generic.List[object]]::new() + $RuleBatches = [System.Collections.Generic.List[object]]::new() + + for ($i = 0; $i -lt $Mailboxes.Count; $i += $BatchSize) { + $BatchMailboxes = $Mailboxes[$i..[Math]::Min($i + $BatchSize - 1, $Mailboxes.Count - 1)] + $BatchMailboxUPNs = $BatchMailboxes | Select-Object -ExpandProperty UPN + $BatchNumber = [Math]::Floor($i / $BatchSize) + 1 + + # Add mailbox permissions batch if requested + if ($Types -contains 'Permissions') { + $PermissionBatches.Add([PSCustomObject]@{ + FunctionName = 'GetMailboxPermissionsBatch' + QueueName = "Mailbox Permissions Batch $BatchNumber/$TotalBatches - $TenantFilter" + TenantFilter = $TenantFilter + Mailboxes = $BatchMailboxUPNs + BatchNumber = $BatchNumber + TotalBatches = $TotalBatches + }) + } - # Update queue with additional tasks if QueueId is provided - if ($QueueId) { - Update-CippQueueEntry -RowKey $QueueId -TotalTasks $Batches.Count -IncrementTotalTasks - Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Updated queue $QueueId with $($Batches.Count) additional tasks" -sev Debug - } + # Add calendar permissions batch if requested + if ($Types -contains 'CalendarPermissions') { + $PermissionBatches.Add([PSCustomObject]@{ + FunctionName = 'GetCalendarPermissionsBatch' + QueueName = "Calendar Permissions Batch $BatchNumber/$TotalBatches - $TenantFilter" + TenantFilter = $TenantFilter + Mailboxes = $BatchMailboxUPNs + BatchNumber = $BatchNumber + TotalBatches = $TotalBatches + }) + } - # Start single orchestrator for both mailbox and calendar permissions - $InputObject = [PSCustomObject]@{ - Batch = @($Batches) - OrchestratorName = "MailboxPermissions_$TenantFilter" - PostExecution = @{ - FunctionName = 'StoreMailboxPermissions' - Parameters = @{ - TenantFilter = $TenantFilter + # Add mailbox rules batch if requested + if ($Types -contains 'Rules') { + $RuleBatches.Add([PSCustomObject]@{ + FunctionName = 'GetMailboxRulesBatch' + QueueName = "Mailbox Rules Batch $BatchNumber/$TotalBatches - $TenantFilter" + TenantFilter = $TenantFilter + Mailboxes = $BatchMailboxUPNs + BatchNumber = $BatchNumber + TotalBatches = $TotalBatches + }) } } - } - if ($QueueId) { - # Add QueueId to each batch item - foreach ($Batch in $Batches) { - $Batch | Add-Member -NotePropertyName 'QueueId' -NotePropertyValue $QueueId -Force + + # Add QueueId to batch items if provided + if ($QueueId) { + foreach ($Batch in $PermissionBatches) { + $Batch | Add-Member -NotePropertyName 'QueueId' -NotePropertyValue $QueueId -Force + } + foreach ($Batch in $RuleBatches) { + $Batch | Add-Member -NotePropertyName 'QueueId' -NotePropertyValue $QueueId -Force + } } + + # Update queue with total additional tasks if QueueId is provided + $TotalBatchCount = $PermissionBatches.Count + $RuleBatches.Count + if ($QueueId -and $TotalBatchCount -gt 0) { + Update-CippQueueEntry -RowKey $QueueId -TotalTasks $TotalBatchCount -IncrementTotalTasks + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Updated queue $QueueId with $TotalBatchCount additional tasks" -sev Debug + Write-Information "Updated queue $QueueId with $TotalBatchCount additional tasks" + } + + # Start separate orchestrator for permissions if we have permission batches + if ($PermissionBatches.Count -gt 0) { + $PermissionInputObject = [PSCustomObject]@{ + Batch = @($PermissionBatches) + OrchestratorName = "MailboxPermissions_$TenantFilter" + PostExecution = @{ + FunctionName = 'StoreMailboxPermissions' + Parameters = @{ + TenantFilter = $TenantFilter + } + } + } + Write-Information "Starting permissions caching orchestrator with $($PermissionBatches.Count) batches" + Start-NewOrchestration -FunctionName 'CIPPOrchestrator' -InputObject ($PermissionInputObject | ConvertTo-Json -Compress -Depth 5) + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Started permission caching orchestrator with $($PermissionBatches.Count) batches" -sev Debug + } + + # Start separate orchestrator for rules if we have rule batches + if ($RuleBatches.Count -gt 0) { + $RuleInputObject = [PSCustomObject]@{ + Batch = @($RuleBatches) + OrchestratorName = "MailboxRules_$TenantFilter" + PostExecution = @{ + FunctionName = 'StoreMailboxRules' + Parameters = @{ + TenantFilter = $TenantFilter + } + } + } + Write-Information "Starting rules caching orchestrator with $($RuleBatches.Count) batches" + Start-NewOrchestration -FunctionName 'CIPPOrchestrator' -InputObject ($RuleInputObject | ConvertTo-Json -Compress -Depth 5) + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Started rules caching orchestrator with $($RuleBatches.Count) batches" -sev Debug + } + + } else { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'No mailboxes found to cache additional data for' -sev Debug } - Start-NewOrchestration -FunctionName 'CIPPOrchestrator' -InputObject ($InputObject | ConvertTo-Json -Compress -Depth 5) - Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Started mailbox and calendar permission caching orchestrator with $($Batches.Count) batches" -sev Debug - } else { - Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'No mailboxes found to cache permissions for' -sev Debug } # Clear mailbox data to free memory @@ -129,5 +183,6 @@ function Set-CIPPDBCacheMailboxes { } catch { Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Failed to cache mailboxes: $($_.Exception.Message)" -sev Error + Write-Information "Failed to cache mailboxes: $($_.Exception.Message)" } } diff --git a/Modules/CIPPCore/Public/Set-CIPPMailboxRule.ps1 b/Modules/CIPPCore/Public/Set-CIPPMailboxRule.ps1 index 311a02400c6f..e99ead09a19d 100644 --- a/Modules/CIPPCore/Public/Set-CIPPMailboxRule.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPMailboxRule.ps1 @@ -24,6 +24,17 @@ try { $null = New-ExoRequest -tenantid $TenantFilter -cmdlet "$State-InboxRule" -Anchor $Username -cmdParams @{Identity = $RuleId; Mailbox = $UserId } Write-LogMessage -headers $Headers -API $APIName -message "Successfully set mailbox rule $($RuleName) for $($Username) to $($State)d" -Sev 'Info' -tenant $TenantFilter + + # Update the cached rule if it exists (without calling Exchange again) + try { + $EnabledValue = $State -eq 'Enable' + Update-CIPPDbItem -TenantFilter $TenantFilter -Type 'MailboxRules' -ItemId $RuleId -PropertyUpdates @{ + Enabled = $EnabledValue + } + } catch { + Write-LogMessage -headers $Headers -API $APIName -message "Rule updated but failed to update cache: $($_.Exception.Message)" -Sev 'Warning' -tenant $TenantFilter + } + return "Successfully set mailbox rule $($RuleName) for $($Username) to $($State)d" } catch { $ErrorMessage = Get-CippException -Exception $_ diff --git a/Modules/CIPPCore/Public/Update-CIPPDbItem.ps1 b/Modules/CIPPCore/Public/Update-CIPPDbItem.ps1 new file mode 100644 index 000000000000..59928b4dd79a --- /dev/null +++ b/Modules/CIPPCore/Public/Update-CIPPDbItem.ps1 @@ -0,0 +1,97 @@ +function Update-CIPPDbItem { + <# + .SYNOPSIS + Update a single item in the CIPP Reporting database + + .DESCRIPTION + Updates a single item in the CippReportingDB table by finding it by ItemId and updating its Data field. + Supports full object replacement or partial property updates. + + .PARAMETER TenantFilter + The tenant domain or GUID (used as partition key) + + .PARAMETER Type + The type of data being stored (used in row key) + + .PARAMETER ItemId + The unique identifier for the item to update + + .PARAMETER InputObject + The updated object to store (will be converted to JSON). Used for full replacement. + + .PARAMETER PropertyUpdates + Hashtable of property names and values to update in the existing cached object. More efficient than full replacement. + + .EXAMPLE + Update-CIPPDbItem -TenantFilter 'contoso.onmicrosoft.com' -Type 'MailboxRules' -ItemId 'rule-guid' -InputObject $UpdatedRule + + .EXAMPLE + Update-CIPPDbItem -TenantFilter 'contoso.onmicrosoft.com' -Type 'MailboxRules' -ItemId 'rule-guid' -PropertyUpdates @{Enabled = $true} + #> + [CmdletBinding(DefaultParameterSetName = 'FullObject')] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter, + + [Parameter(Mandatory = $true)] + [string]$Type, + + [Parameter(Mandatory = $true)] + [string]$ItemId, + + [Parameter(Mandatory = $true, ParameterSetName = 'FullObject')] + $InputObject, + + [Parameter(Mandatory = $true, ParameterSetName = 'PartialUpdate')] + [hashtable]$PropertyUpdates + ) + + try { + $Table = Get-CippTable -tablename 'CippReportingDB' + + # Format RowKey + $RowKey = "$Type-$ItemId" -replace '[/\\#?]', '_' -replace '[\u0000-\u001F\u007F-\u009F]', '' + + # Get existing entity + $Filter = "PartitionKey eq '$TenantFilter' and RowKey eq '$RowKey'" + $ExistingEntity = Get-CIPPAzDataTableEntity @Table -Filter $Filter + + if (-not $ExistingEntity) { + Write-Information "[CIPPDbItem] Item not found for update: Tenant=$TenantFilter, Type=$Type, ItemId=$ItemId." + return + } + + # Determine the data to store + if ($PSCmdlet.ParameterSetName -eq 'PartialUpdate') { + # Parse existing data and update specific properties + $ExistingData = $ExistingEntity.Data | ConvertFrom-Json + foreach ($key in $PropertyUpdates.GetEnumerator()) { + $ExistingData.($key.Name) = $key.Value + } + $DataToStore = $ExistingData + } else { + # Full object replacement + $DataToStore = $InputObject + } + + # Update entity + $Entity = @{ + PartitionKey = $TenantFilter + RowKey = $RowKey + Data = [string]($DataToStore | ConvertTo-Json -Depth 10 -Compress) + Type = $Type + ETag = $ExistingEntity.ETag + } + + Add-CIPPAzDataTableEntity @Table -Entity $Entity -Force | Out-Null + + Write-LogMessage -API 'CIPPDbItem' -tenant $TenantFilter ` + -message "Updated cached item: $Type - $ItemId" -sev Debug + + } catch { + Write-LogMessage -API 'CIPPDbItem' -tenant $TenantFilter ` + -message "Failed to update item $Type - $ItemId : $($_.Exception.Message)" -sev Error ` + -LogData (Get-CippException -Exception $_) + throw + } +} From 5fb46b7beb54c22609df6980f2372bd29f045cc4 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Tue, 10 Feb 2026 15:57:55 -0500 Subject: [PATCH 044/150] make dev environment more cross platform friendly --- Tools/Initialize-DevEnvironment.ps1 | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/Tools/Initialize-DevEnvironment.ps1 b/Tools/Initialize-DevEnvironment.ps1 index bc8fd6193f91..10421bb5a64b 100644 --- a/Tools/Initialize-DevEnvironment.ps1 +++ b/Tools/Initialize-DevEnvironment.ps1 @@ -1,18 +1,22 @@ +Write-Host 'Initializing development environment...' -ForegroundColor Green $CippRoot = (Get-Item $PSScriptRoot).Parent.FullName ### Read the local.settings.json file and convert to a PowerShell object. $CIPPSettings = Get-Content (Join-Path $CippRoot 'local.settings.json') | ConvertFrom-Json | Select-Object -ExpandProperty Values ### Loop through the settings and set environment variables for each. -$ValidKeys = @('TenantID', 'ApplicationID', 'ApplicationSecret', 'RefreshToken', 'AzureWebJobsStorage', 'PartnerTenantAvailable', 'SetFromProfile') +$ValidKeys = @('AzureWebJobsStorage', 'PartnerTenantAvailable', 'SetFromProfile') foreach ($Key in $CIPPSettings.PSObject.Properties.Name) { if ($ValidKeys -contains $Key) { [Environment]::SetEnvironmentVariable($Key, $CippSettings.$Key) } } -$PowerShellWorkerRoot = Join-Path $env:ProgramFiles 'Microsoft\Azure Functions Core Tools\workers\powershell\7.4\Microsoft.Azure.Functions.PowerShellWorker.dll' -if ((Test-Path $PowerShellWorkerRoot) -and !('Microsoft.Azure.Functions.PowerShellWorker' -as [type])) { - Write-Information "Loading PowerShell Worker from $PowerShellWorkerRoot" - Add-Type -Path $PowerShellWorkerRoot +# if windows +if ($IsWindows) { + $PowerShellWorkerRoot = Join-Path $env:ProgramFiles 'Microsoft\Azure Functions Core Tools\workers\powershell\7.4\Microsoft.Azure.Functions.PowerShellWorker.dll' + if ((Test-Path $PowerShellWorkerRoot) -and !('Microsoft.Azure.Functions.PowerShellWorker' -as [type])) { + Write-Information "Loading PowerShell Worker from $PowerShellWorkerRoot" + Add-Type -Path $PowerShellWorkerRoot + } } # Remove previously loaded modules to force reloading if new code changes were made @@ -28,4 +32,9 @@ Import-Module ( Join-Path $CippRoot 'Modules\DNSHealth' ) Import-Module ( Join-Path $CippRoot 'Modules\CIPPCore' ) Import-Module ( Join-Path $CippRoot 'Modules\CippExtensions' ) -Get-CIPPAuthentication +$Auth = Get-CIPPAuthentication +if ($Auth) { + Write-Host 'Development environment initialized successfully!' -ForegroundColor Green +} else { + Write-Host 'Failed to initialize development environment. Please check the error messages above.' -ForegroundColor Red +} From 5ab450f42bb3a4323c083ca233ed9a9f7f5146a9 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Tue, 10 Feb 2026 16:01:17 -0500 Subject: [PATCH 045/150] Initialize excluded licenses if table empty Add a guard that detects when the ExcludedLicenses table is empty and initializes it. When no excluded SKUs are found, the code logs an informational message, calls Initialize-CIPPExcludedLicenses, and reloads the excluded SKU list so downstream processing has the expected data. --- Modules/CIPPCore/Public/Get-CIPPLicenseOverview.ps1 | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/Modules/CIPPCore/Public/Get-CIPPLicenseOverview.ps1 b/Modules/CIPPCore/Public/Get-CIPPLicenseOverview.ps1 index 2e838aa0a63a..de1835ea1496 100644 --- a/Modules/CIPPCore/Public/Get-CIPPLicenseOverview.ps1 +++ b/Modules/CIPPCore/Public/Get-CIPPLicenseOverview.ps1 @@ -56,6 +56,13 @@ function Get-CIPPLicenseOverview { $LicenseTable = Get-CIPPTable -TableName ExcludedLicenses $ExcludedSkuList = Get-CIPPAzDataTableEntity @LicenseTable + # If no excluded licenses exist, initialize them + if ($ExcludedSkuList.Count -lt 1) { + Write-Information 'Excluded licenses table is empty. Initializing from config file.' + $null = Initialize-CIPPExcludedLicenses + $ExcludedSkuList = Get-CIPPAzDataTableEntity @Table + } + $AllLicensedUsers = @(($Results | Where-Object { $_.id -eq 'licensedUsers' }).body.value) $UsersBySku = @{} foreach ($User in $AllLicensedUsers) { From 1309dd466bf2ccdcf3b21c6e3db37016cd566cf5 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Tue, 10 Feb 2026 16:01:47 -0500 Subject: [PATCH 046/150] Update Get-CIPPLicenseOverview.ps1 --- Modules/CIPPCore/Public/Get-CIPPLicenseOverview.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Modules/CIPPCore/Public/Get-CIPPLicenseOverview.ps1 b/Modules/CIPPCore/Public/Get-CIPPLicenseOverview.ps1 index de1835ea1496..bfa4b519de08 100644 --- a/Modules/CIPPCore/Public/Get-CIPPLicenseOverview.ps1 +++ b/Modules/CIPPCore/Public/Get-CIPPLicenseOverview.ps1 @@ -60,7 +60,7 @@ function Get-CIPPLicenseOverview { if ($ExcludedSkuList.Count -lt 1) { Write-Information 'Excluded licenses table is empty. Initializing from config file.' $null = Initialize-CIPPExcludedLicenses - $ExcludedSkuList = Get-CIPPAzDataTableEntity @Table + $ExcludedSkuList = Get-CIPPAzDataTableEntity @LicenseTable } $AllLicensedUsers = @(($Results | Where-Object { $_.id -eq 'licensedUsers' }).body.value) From 1de577f45033dc86aa82c670111991ee3faf6282 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Tue, 10 Feb 2026 18:08:37 -0500 Subject: [PATCH 047/150] Add cleanup rule for CippStandardsReports table Insert a new CleanupRule into Start-TableCleanup to run TableCleanupTask against the CippStandardsReports table. The rule deletes entities with Timestamp older than 7 days (UTC) in batches up to 10,000, returning PartitionKey, RowKey and ETag for each item. This ensures old standards report entries are pruned automatically. --- .../Entrypoints/Timer Functions/Start-TableCleanup.ps1 | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-TableCleanup.ps1 b/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-TableCleanup.ps1 index 35a101109294..74f620e133e1 100644 --- a/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-TableCleanup.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-TableCleanup.ps1 @@ -65,6 +65,16 @@ function Start-TableCleanup { Property = @('PartitionKey', 'RowKey', 'ETag') } } + @{ + FunctionName = 'TableCleanupTask' + Type = 'CleanupRule' + TableName = 'CippStandardsReports' + DataTableProps = @{ + Filter = "Timestamp lt datetime'$((Get-Date).AddDays(-7).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ'))'" + First = 10000 + Property = @('PartitionKey', 'RowKey', 'ETag') + } + } @{ FunctionName = 'TableCleanupTask' Type = 'DeleteTable' From c1d27e1c73705b5e7d5c854a8060f55e1bb0e0c2 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Tue, 10 Feb 2026 18:08:46 -0500 Subject: [PATCH 048/150] cleanup logging --- .../Orchestrator Functions/Start-UserTasksOrchestrator.ps1 | 5 ----- 1 file changed, 5 deletions(-) diff --git a/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-UserTasksOrchestrator.ps1 b/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-UserTasksOrchestrator.ps1 index b677afdd2071..a9a2f4ab3da5 100644 --- a/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-UserTasksOrchestrator.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-UserTasksOrchestrator.ps1 @@ -151,11 +151,6 @@ function Start-UserTasksOrchestrator { # Process each tenant batch separately foreach ($ProcessedBatch in $ProcessedBatches) { $TenantName = $ProcessedBatch[0].Parameters.TenantFilter - Write-Information "Processing batch for tenant: $TenantName with $($ProcessedBatch.Count) tasks..." - Write-Information 'Tasks by command:' - $ProcessedBatch | Group-Object -Property Command | ForEach-Object { - Write-Information " - $($_.Name): $($_.Count)" - } # Create queue entry for each tenant batch $Queue = New-CippQueueEntry -Name "Scheduled Tasks - $TenantName" From 36443a228519cea2c21a2c74f83517634c4994e4 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Tue, 10 Feb 2026 21:06:03 -0500 Subject: [PATCH 049/150] Add feature flag support and endpoints Introduce feature flag infrastructure and HTTP entrypoints. Adds Get-CIPPFeatureFlag and Set-CIPPFeatureFlag to load defaults from lib/data/FeatureFlags.json, initialize missing table entries, and persist state to the FeatureFlags table (Set respects AllowUserToggle and records LastModified). Adds Invoke-ListFeatureFlags and Invoke-ExecFeatureFlag HTTP handlers to list and get/set flags. Integrates feature flag checks into New-CippCoreRequest (returns 503 for disabled endpoints), Start-BPAOrchestrator (no-op when BestPracticeAnalyser is disabled), and Get-CIPPTimerFunctions (filters timers tied to disabled flags). Includes basic logging and error handling for the new flows. --- .../CIPP/Core/Invoke-ExecFeatureFlag.ps1 | 70 +++++++++ .../CIPP/Core/Invoke-ListFeatureFlags.ps1 | 31 ++++ .../HTTP Functions/New-CippCoreRequest.ps1 | 15 ++ .../Start-BPAOrchestrator.ps1 | 6 + .../CIPPCore/Public/Get-CIPPFeatureFlag.ps1 | 137 ++++++++++++++++++ .../Public/Get-CIPPTimerFunctions.ps1 | 9 +- .../CIPPCore/Public/Set-CIPPFeatureFlag.ps1 | 68 +++++++++ Modules/CIPPCore/lib/data/FeatureFlags.json | 25 ++++ 8 files changed, 360 insertions(+), 1 deletion(-) create mode 100644 Modules/CIPPCore/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecFeatureFlag.ps1 create mode 100644 Modules/CIPPCore/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ListFeatureFlags.ps1 create mode 100644 Modules/CIPPCore/Public/Get-CIPPFeatureFlag.ps1 create mode 100644 Modules/CIPPCore/Public/Set-CIPPFeatureFlag.ps1 create mode 100644 Modules/CIPPCore/lib/data/FeatureFlags.json diff --git a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecFeatureFlag.ps1 b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecFeatureFlag.ps1 new file mode 100644 index 000000000000..b5961417a54c --- /dev/null +++ b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecFeatureFlag.ps1 @@ -0,0 +1,70 @@ +function Invoke-ExecFeatureFlag { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + CIPP.AppSettings.ReadWrite + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + try { + $Action = $Request.Body.Action + $Id = $Request.Body.Id + $Enabled = $Request.Body.Enabled + + Write-LogMessage -API 'ExecFeatureFlag' -message "Processing feature flag action: $Action for $Id" -sev 'Info' + + switch ($Action) { + 'Set' { + if ([string]::IsNullOrEmpty($Id)) { + throw 'Feature flag Id is required' + } + + if ($null -eq $Enabled) { + throw 'Enabled state is required' + } + + # Use Set-CIPPFeatureFlag to update the flag + $Result = Set-CIPPFeatureFlag -Id $Id -Enabled ([bool]$Enabled) + + if ($Result) { + Write-LogMessage -API 'ExecFeatureFlag' -message "Successfully updated feature flag $Id to $Enabled" -sev 'Info' + $StatusCode = [HttpStatusCode]::OK + $Body = @{ + Results = "Successfully updated feature flag '$Id' to Enabled=$Enabled" + } + } else { + throw "Failed to update feature flag '$Id'" + } + } + 'Get' { + if ([string]::IsNullOrEmpty($Id)) { + # Get all flags + $Flags = Get-CIPPFeatureFlag + } else { + # Get specific flag + $Flags = Get-CIPPFeatureFlag -Id $Id + } + + $StatusCode = [HttpStatusCode]::OK + $Body = $Flags + } + default { + throw "Invalid action: $Action. Valid actions are 'Set' or 'Get'" + } + } + } catch { + Write-LogMessage -API 'ExecFeatureFlag' -message "Failed to process feature flag: $($_.Exception.Message)" -sev 'Error' + $StatusCode = [HttpStatusCode]::BadRequest + $Body = @{ + error = $_.Exception.Message + details = $_.Exception + } + } + + return [HttpResponseContext]@{ + StatusCode = $StatusCode + Body = $Body + } +} diff --git a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ListFeatureFlags.ps1 b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ListFeatureFlags.ps1 new file mode 100644 index 000000000000..3b236cafbb4d --- /dev/null +++ b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ListFeatureFlags.ps1 @@ -0,0 +1,31 @@ +function Invoke-ListFeatureFlags { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + CIPP.Core.Read + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + try { + Write-LogMessage -API 'ListFeatureFlags' -message 'Accessed feature flags list' -sev 'Debug' + + $FeatureFlags = Get-CIPPFeatureFlag + + $StatusCode = [HttpStatusCode]::OK + $Body = @($FeatureFlags) + } catch { + Write-LogMessage -API 'ListFeatureFlags' -message "Failed to retrieve feature flags: $($_.Exception.Message)" -sev 'Error' + $StatusCode = [HttpStatusCode]::InternalServerError + $Body = @{ + error = $_.Exception.Message + details = $_.Exception + } + } + + return [HttpResponseContext]@{ + StatusCode = $StatusCode + Body = $Body + } +} diff --git a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/New-CippCoreRequest.ps1 b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/New-CippCoreRequest.ps1 index 70586323cd32..87fc325b6c1e 100644 --- a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/New-CippCoreRequest.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/New-CippCoreRequest.ps1 @@ -43,6 +43,21 @@ function New-CippCoreRequest { $FunctionName = 'Invoke-{0}' -f $Request.Params.CIPPEndpoint Write-Information "API Endpoint: $($Request.Params.CIPPEndpoint) | Frontend Version: $($Request.Headers.'X-CIPP-Version' ?? 'Not specified')" + # Check if endpoint is disabled via feature flags + $FeatureFlags = Get-CIPPFeatureFlag + $DisabledEndpoint = $FeatureFlags | Where-Object { + $_.Enabled -eq $false -and $_.Endpoints -contains $Request.Params.CIPPEndpoint + } | Select-Object -First 1 + + if ($DisabledEndpoint) { + Write-Information "Endpoint $($Request.Params.CIPPEndpoint) is disabled via feature flag: $($DisabledEndpoint.Name)" + $HttpTotalStopwatch.Stop() + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::ServiceUnavailable + Body = "This feature has been disabled: $($DisabledEndpoint.Description)" + }) + } + if ($Request.Headers.'X-CIPP-Version') { $Table = Get-CippTable -tablename 'Version' $FrontendVer = Get-CIPPAzDataTableEntity @Table -Filter "PartitionKey eq 'Version' and RowKey eq 'frontend'" diff --git a/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-BPAOrchestrator.ps1 b/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-BPAOrchestrator.ps1 index 71e79f07a75a..41dd759b0c36 100644 --- a/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-BPAOrchestrator.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-BPAOrchestrator.ps1 @@ -14,6 +14,12 @@ function Start-BPAOrchestrator { ) try { + # Check feature flag + $FeatureFlag = Get-CIPPFeatureFlag -Id 'BestPracticeAnalyser' + if ($FeatureFlag -and $FeatureFlag.Enabled -eq $false) { + Write-LogMessage -API 'BestPracticeAnalyser' -message 'Best Practice Analyser is disabled via feature flag' -sev Info + return $false + } if ($TenantFilter -ne 'AllTenants') { Write-Verbose "TenantFilter: $TenantFilter" if ($TenantFilter -notmatch '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$') { diff --git a/Modules/CIPPCore/Public/Get-CIPPFeatureFlag.ps1 b/Modules/CIPPCore/Public/Get-CIPPFeatureFlag.ps1 new file mode 100644 index 000000000000..a2ce615f1219 --- /dev/null +++ b/Modules/CIPPCore/Public/Get-CIPPFeatureFlag.ps1 @@ -0,0 +1,137 @@ +function Get-CIPPFeatureFlag { + <# + .SYNOPSIS + Get the state of a feature flag or all feature flags + .DESCRIPTION + Retrieves the current state of a feature flag from the FeatureFlags table, falling back to the default state from JSON if not found. + If Id is not specified, returns all feature flags. + .PARAMETER Id + The ID of the feature flag to retrieve. If not specified, returns all feature flags. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $false)] + [string]$Id + ) + + try { + # Get feature flags from JSON + $FeatureFlagsPath = Join-Path -Path $PSScriptRoot -ChildPath '../lib/data/FeatureFlags.json' + $FeatureFlags = Get-Content -Path $FeatureFlagsPath -Raw | ConvertFrom-Json + + # Get all table flags once + $Table = Get-CIPPTable -TableName 'FeatureFlags' + $TableFlags = Get-CIPPAzDataTableEntity @Table -Filter "PartitionKey eq 'FeatureFlag'" + + # If Id is specified, return single flag + if ($Id) { + $FeatureFlag = $FeatureFlags | Where-Object { $_.Id -eq $Id } + + if (-not $FeatureFlag) { + Write-Warning "Feature flag '$Id' not found in FeatureFlags.json" + return $null + } + + $TableFlag = $TableFlags | Where-Object { $_.RowKey -eq $Id } + + if ($TableFlag) { + # Return the table version with metadata from JSON + # Parse JSON arrays from table storage + $Timers = if ($TableFlag.Timers) { $TableFlag.Timers | ConvertFrom-Json } else { $FeatureFlag.Timers } + $Endpoints = if ($TableFlag.Endpoints) { $TableFlag.Endpoints | ConvertFrom-Json } else { $FeatureFlag.Endpoints } + $Pages = if ($TableFlag.Pages) { $TableFlag.Pages | ConvertFrom-Json } else { $FeatureFlag.Pages } + + return [PSCustomObject]@{ + Id = $TableFlag.RowKey + Name = $TableFlag.Name + Description = $TableFlag.Description + AllowUserToggle = $FeatureFlag.AllowUserToggle + Timers = $Timers + Endpoints = $Endpoints + Pages = $Pages + Enabled = $TableFlag.Enabled + } + } else { + # Insert feature flag into table with defaults from JSON + $Entity = @{ + PartitionKey = 'FeatureFlag' + RowKey = $FeatureFlag.Id + Enabled = $FeatureFlag.Enabled + Timers = [string]($FeatureFlag.Timers | ConvertTo-Json -Compress) + Endpoints = [string]($FeatureFlag.Endpoints | ConvertTo-Json -Compress) + Pages = [string]($FeatureFlag.Pages | ConvertTo-Json -Compress) + Name = [string]$FeatureFlag.Name + Description = [string]$FeatureFlag.Description + } + Add-CIPPAzDataTableEntity @Table -Entity $Entity -Force + + # Return the initialized feature flag + return [PSCustomObject]@{ + Id = $FeatureFlag.Id + Name = $FeatureFlag.Name + Description = $FeatureFlag.Description + AllowUserToggle = $FeatureFlag.AllowUserToggle + Timers = $FeatureFlag.Timers + Endpoints = $FeatureFlag.Endpoints + Pages = $FeatureFlag.Pages + Enabled = $FeatureFlag.Enabled + } + } + } else { + # Return all feature flags + $Results = foreach ($FeatureFlag in $FeatureFlags) { + $TableFlag = $TableFlags | Where-Object { $_.RowKey -eq $FeatureFlag.Id } + + if ($TableFlag) { + # Parse JSON arrays from table storage + $Timers = if ($TableFlag.Timers) { $TableFlag.Timers | ConvertFrom-Json } else { $FeatureFlag.Timers } + $Endpoints = if ($TableFlag.Endpoints) { $TableFlag.Endpoints | ConvertFrom-Json } else { $FeatureFlag.Endpoints } + $Pages = if ($TableFlag.Pages) { $TableFlag.Pages | ConvertFrom-Json } else { $FeatureFlag.Pages } + + [PSCustomObject]@{ + Id = $TableFlag.RowKey + Name = $TableFlag.Name + Description = $TableFlag.Description + AllowUserToggle = $FeatureFlag.AllowUserToggle + Timers = $Timers + Endpoints = $Endpoints + Pages = $Pages + Enabled = $TableFlag.Enabled + } + } else { + # Insert feature flag into table with defaults from JSON + $Entity = @{ + PartitionKey = 'FeatureFlag' + RowKey = $FeatureFlag.Id + Enabled = $FeatureFlag.Enabled + Timers = [string]($FeatureFlag.Timers | ConvertTo-Json -Compress) + Endpoints = [string]($FeatureFlag.Endpoints | ConvertTo-Json -Compress) + Pages = [string]($FeatureFlag.Pages | ConvertTo-Json -Compress) + Name = [string]$FeatureFlag.Name + Description = [string]$FeatureFlag.Description + } + Add-CIPPAzDataTableEntity @Table -Entity $Entity -Force + + # Return the initialized feature flag + [PSCustomObject]@{ + Id = $FeatureFlag.Id + Name = $FeatureFlag.Name + Description = $FeatureFlag.Description + AllowUserToggle = $FeatureFlag.AllowUserToggle + Timers = $FeatureFlag.Timers + Endpoints = $FeatureFlag.Endpoints + Pages = $FeatureFlag.Pages + Enabled = $FeatureFlag.Enabled + } + } + } + return $Results + } + } catch { + $ErrorMsg = if ($Id) { "'$Id'" } else { 'flags' } + Write-Error "Error retrieving feature $($ErrorMsg): $($_.Exception.Message)" + return $null + } +} diff --git a/Modules/CIPPCore/Public/Get-CIPPTimerFunctions.ps1 b/Modules/CIPPCore/Public/Get-CIPPTimerFunctions.ps1 index d60ca8ed40cf..51b17666167a 100644 --- a/Modules/CIPPCore/Public/Get-CIPPTimerFunctions.ps1 +++ b/Modules/CIPPCore/Public/Get-CIPPTimerFunctions.ps1 @@ -42,10 +42,17 @@ function Get-CIPPTimerFunctions { $CIPPRoot = (Get-Item $CIPPCoreModuleRoot).Parent.Parent $CippTimers = Get-Content -Path $CIPPRoot\CIPPTimers.json + # Get all feature flags to filter disabled features + $FeatureFlags = Get-CIPPFeatureFlag + $DisabledTimers = $FeatureFlags | Where-Object { $_.Enabled -eq $false } | ForEach-Object { $_.Timers } | Where-Object { $_ } + if ($ListAllTasks) { $Orchestrators = $CippTimers | ConvertFrom-Json | Sort-Object -Property Priority } else { - $Orchestrators = $CippTimers | ConvertFrom-Json | Where-Object { $_.RunOnProcessor -eq $RunOnProcessor } | Sort-Object -Property Priority + # Filter out timers associated with disabled feature flags + $Orchestrators = $CippTimers | ConvertFrom-Json | Where-Object { + $_.RunOnProcessor -eq $RunOnProcessor -and $_.Id -notin $DisabledTimers + } | Sort-Object -Property Priority } $Table = Get-CIPPTable -TableName 'CIPPTimers' $RunOnProcessorTxt = if ($RunOnProcessor) { 'true' } else { 'false' } diff --git a/Modules/CIPPCore/Public/Set-CIPPFeatureFlag.ps1 b/Modules/CIPPCore/Public/Set-CIPPFeatureFlag.ps1 new file mode 100644 index 000000000000..2a36291c7235 --- /dev/null +++ b/Modules/CIPPCore/Public/Set-CIPPFeatureFlag.ps1 @@ -0,0 +1,68 @@ +function Set-CIPPFeatureFlag { + <# + .SYNOPSIS + Set the state of a feature flag + .DESCRIPTION + Updates the state of a feature flag in the FeatureFlags table + .PARAMETER Id + The ID of the feature flag to update + .PARAMETER Enabled + The new enabled state for the feature flag (true/false) + .FUNCTIONALITY + Internal + #> + [CmdletBinding(SupportsShouldProcess = $true)] + param( + [Parameter(Mandatory = $true)] + [string]$Id, + + [Parameter(Mandatory = $true)] + [bool]$Enabled + ) + + try { + # Get feature flags from JSON to validate + $FeatureFlagsPath = Join-Path -Path $PSScriptRoot -ChildPath '../lib/data/FeatureFlags.json' + $FeatureFlags = Get-Content -Path $FeatureFlagsPath -Raw | ConvertFrom-Json + + # Find the requested feature flag in JSON + $FeatureFlag = $FeatureFlags | Where-Object { $_.Id -eq $Id } + + if (-not $FeatureFlag) { + Write-Error "Feature flag '$Id' not found in FeatureFlags.json" + return $false + } + + # Check if user toggle is allowed + if (-not $FeatureFlag.AllowUserToggle) { + Write-Warning "Feature flag '$Id' does not allow user toggling" + return $false + } + + if ($PSCmdlet.ShouldProcess($Id, "Set feature flag enabled to $Enabled")) { + # Update or create the table entry + $Table = Get-CIPPTable -TableName 'FeatureFlags' + + # Convert arrays to JSON strings for table storage + $Entity = @{ + PartitionKey = 'FeatureFlag' + RowKey = $Id + Enabled = $Enabled + Timers = [string]($FeatureFlag.Timers | ConvertTo-Json -Compress) + Endpoints = [string]($FeatureFlag.Endpoints | ConvertTo-Json -Compress) + Pages = [string]($FeatureFlag.Pages | ConvertTo-Json -Compress) + Name = [string]$FeatureFlag.Name + Description = [string]$FeatureFlag.Description + LastModified = (Get-Date).ToUniversalTime().ToString('o') + } + + $Result = Add-CIPPAzDataTableEntity @Table -Entity $Entity -Force + + Write-Information "Feature flag '$Id' set to $Enabled" + return $true + } + } catch { + Write-Error "Error setting feature flag '$Id': $($_.Exception.Message)" + return $false + } +} diff --git a/Modules/CIPPCore/lib/data/FeatureFlags.json b/Modules/CIPPCore/lib/data/FeatureFlags.json new file mode 100644 index 000000000000..5be983f636ed --- /dev/null +++ b/Modules/CIPPCore/lib/data/FeatureFlags.json @@ -0,0 +1,25 @@ +[ + { + "Id": "BestPracticeAnalyser", + "Name": "Best Practice Analyser", + "Type": "Orchestrator", + "Description": "Best Practice Analyser orchestrator (deprecated)", + "Enabled": false, + "AllowUserToggle": true, + "Timers": [ + "80070b4f-95ed-4e5f-be4c-9e339306d4aa" + ], + "Endpoints": [ + "BestPracticeAnalyser_List", + "ExecBPA", + "ListBPA", + "ListBPATemplates", + "RemoveBPATemplate" + ], + "Pages": [ + "/tenant/standards/bpa-report", + "/tenant/standards/bpa-report/builder", + "/tenant/standards/bpa-report/view" + ] + } +] From e6ba6fa132498059906ccacd16a9f7d2f8dd9585 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Wed, 11 Feb 2026 11:40:22 -0500 Subject: [PATCH 050/150] Persist only Enabled in FeatureFlags table Keep feature flag metadata in the static JSON and only store the enabled state in table storage. Get-CIPPFeatureFlag now returns Enabled from the table but sources Id/Name/Description/AllowUserToggle/Timers/Endpoints/Pages from FeatureFlags.json, and it inserts table entities with just RowKey and Enabled if missing. Set-CIPPFeatureFlag updates/creates table entries with only PartitionKey, RowKey, Enabled and LastModified (removed serialization of timers/endpoints/pages/name/description). Also update FeatureFlags.json for BestPracticeAnalyser: removed Type and clarified the deprecation description. --- .../CIPPCore/Public/Get-CIPPFeatureFlag.ps1 | 51 ++++++------------- .../CIPPCore/Public/Set-CIPPFeatureFlag.ps1 | 8 +-- Modules/CIPPCore/lib/data/FeatureFlags.json | 3 +- 3 files changed, 18 insertions(+), 44 deletions(-) diff --git a/Modules/CIPPCore/Public/Get-CIPPFeatureFlag.ps1 b/Modules/CIPPCore/Public/Get-CIPPFeatureFlag.ps1 index a2ce615f1219..b068d3d37cbe 100644 --- a/Modules/CIPPCore/Public/Get-CIPPFeatureFlag.ps1 +++ b/Modules/CIPPCore/Public/Get-CIPPFeatureFlag.ps1 @@ -37,33 +37,23 @@ function Get-CIPPFeatureFlag { $TableFlag = $TableFlags | Where-Object { $_.RowKey -eq $Id } if ($TableFlag) { - # Return the table version with metadata from JSON - # Parse JSON arrays from table storage - $Timers = if ($TableFlag.Timers) { $TableFlag.Timers | ConvertFrom-Json } else { $FeatureFlag.Timers } - $Endpoints = if ($TableFlag.Endpoints) { $TableFlag.Endpoints | ConvertFrom-Json } else { $FeatureFlag.Endpoints } - $Pages = if ($TableFlag.Pages) { $TableFlag.Pages | ConvertFrom-Json } else { $FeatureFlag.Pages } - + # Return feature flag with Enabled from table, everything else from JSON return [PSCustomObject]@{ - Id = $TableFlag.RowKey - Name = $TableFlag.Name - Description = $TableFlag.Description + Id = $FeatureFlag.Id + Name = $FeatureFlag.Name + Description = $FeatureFlag.Description AllowUserToggle = $FeatureFlag.AllowUserToggle - Timers = $Timers - Endpoints = $Endpoints - Pages = $Pages + Timers = $FeatureFlag.Timers + Endpoints = $FeatureFlag.Endpoints + Pages = $FeatureFlag.Pages Enabled = $TableFlag.Enabled } } else { - # Insert feature flag into table with defaults from JSON + # Insert feature flag into table with defaults from JSON (only RowKey and Enabled) $Entity = @{ PartitionKey = 'FeatureFlag' RowKey = $FeatureFlag.Id Enabled = $FeatureFlag.Enabled - Timers = [string]($FeatureFlag.Timers | ConvertTo-Json -Compress) - Endpoints = [string]($FeatureFlag.Endpoints | ConvertTo-Json -Compress) - Pages = [string]($FeatureFlag.Pages | ConvertTo-Json -Compress) - Name = [string]$FeatureFlag.Name - Description = [string]$FeatureFlag.Description } Add-CIPPAzDataTableEntity @Table -Entity $Entity -Force @@ -85,32 +75,23 @@ function Get-CIPPFeatureFlag { $TableFlag = $TableFlags | Where-Object { $_.RowKey -eq $FeatureFlag.Id } if ($TableFlag) { - # Parse JSON arrays from table storage - $Timers = if ($TableFlag.Timers) { $TableFlag.Timers | ConvertFrom-Json } else { $FeatureFlag.Timers } - $Endpoints = if ($TableFlag.Endpoints) { $TableFlag.Endpoints | ConvertFrom-Json } else { $FeatureFlag.Endpoints } - $Pages = if ($TableFlag.Pages) { $TableFlag.Pages | ConvertFrom-Json } else { $FeatureFlag.Pages } - + # Return feature flag with Enabled from table, everything else from JSON [PSCustomObject]@{ - Id = $TableFlag.RowKey - Name = $TableFlag.Name - Description = $TableFlag.Description + Id = $FeatureFlag.Id + Name = $FeatureFlag.Name + Description = $FeatureFlag.Description AllowUserToggle = $FeatureFlag.AllowUserToggle - Timers = $Timers - Endpoints = $Endpoints - Pages = $Pages + Timers = $FeatureFlag.Timers + Endpoints = $FeatureFlag.Endpoints + Pages = $FeatureFlag.Pages Enabled = $TableFlag.Enabled } } else { - # Insert feature flag into table with defaults from JSON + # Insert feature flag into table with defaults from JSON (only RowKey and Enabled) $Entity = @{ PartitionKey = 'FeatureFlag' RowKey = $FeatureFlag.Id Enabled = $FeatureFlag.Enabled - Timers = [string]($FeatureFlag.Timers | ConvertTo-Json -Compress) - Endpoints = [string]($FeatureFlag.Endpoints | ConvertTo-Json -Compress) - Pages = [string]($FeatureFlag.Pages | ConvertTo-Json -Compress) - Name = [string]$FeatureFlag.Name - Description = [string]$FeatureFlag.Description } Add-CIPPAzDataTableEntity @Table -Entity $Entity -Force diff --git a/Modules/CIPPCore/Public/Set-CIPPFeatureFlag.ps1 b/Modules/CIPPCore/Public/Set-CIPPFeatureFlag.ps1 index 2a36291c7235..d8cb088f0fcf 100644 --- a/Modules/CIPPCore/Public/Set-CIPPFeatureFlag.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPFeatureFlag.ps1 @@ -40,19 +40,13 @@ function Set-CIPPFeatureFlag { } if ($PSCmdlet.ShouldProcess($Id, "Set feature flag enabled to $Enabled")) { - # Update or create the table entry + # Update or create the table entry (only store RowKey and Enabled) $Table = Get-CIPPTable -TableName 'FeatureFlags' - # Convert arrays to JSON strings for table storage $Entity = @{ PartitionKey = 'FeatureFlag' RowKey = $Id Enabled = $Enabled - Timers = [string]($FeatureFlag.Timers | ConvertTo-Json -Compress) - Endpoints = [string]($FeatureFlag.Endpoints | ConvertTo-Json -Compress) - Pages = [string]($FeatureFlag.Pages | ConvertTo-Json -Compress) - Name = [string]$FeatureFlag.Name - Description = [string]$FeatureFlag.Description LastModified = (Get-Date).ToUniversalTime().ToString('o') } diff --git a/Modules/CIPPCore/lib/data/FeatureFlags.json b/Modules/CIPPCore/lib/data/FeatureFlags.json index 5be983f636ed..61a49d453458 100644 --- a/Modules/CIPPCore/lib/data/FeatureFlags.json +++ b/Modules/CIPPCore/lib/data/FeatureFlags.json @@ -2,8 +2,7 @@ { "Id": "BestPracticeAnalyser", "Name": "Best Practice Analyser", - "Type": "Orchestrator", - "Description": "Best Practice Analyser orchestrator (deprecated)", + "Description": "The Best Practice Analyser has been deprecated and will be removed in a future release.", "Enabled": false, "AllowUserToggle": true, "Timers": [ From fc23cd660c6196c44cf69fe54d8076fbfc0883b9 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Wed, 11 Feb 2026 17:21:18 -0500 Subject: [PATCH 051/150] Include Selector2 1024-bit DKIM in rotation filter Expand DKIM selection to include configs where either Selector1KeySize or Selector2KeySize equals 1024 and the config is enabled. Previously only Selector1KeySize was checked, which could miss keys needing rotation on Selector2. --- .../CIPPCore/Public/Standards/Invoke-CIPPStandardRotateDKIM.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardRotateDKIM.ps1 b/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardRotateDKIM.ps1 index 11759ab03809..8b64998f0f86 100644 --- a/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardRotateDKIM.ps1 +++ b/Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardRotateDKIM.ps1 @@ -40,7 +40,7 @@ function Invoke-CIPPStandardRotateDKIM { } #we're done. try { - $DKIM = (New-ExoRequest -tenantid $tenant -cmdlet 'Get-DkimSigningConfig') | Where-Object { $_.Selector1KeySize -eq 1024 -and $_.Enabled -eq $true } + $DKIM = (New-ExoRequest -tenantid $tenant -cmdlet 'Get-DkimSigningConfig') | Where-Object { ($_.Selector1KeySize -eq 1024 -or $_.Selector2KeySize -eq 1024) -and $_.Enabled -eq $true } } catch { $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message Write-LogMessage -API 'Standards' -Tenant $Tenant -Message "Could not get the DKIM state for $Tenant. Error: $ErrorMessage" -Sev Error From eda364ed5c4236e11c5a87ff392fe4ef64527711 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Wed, 11 Feb 2026 18:56:59 -0500 Subject: [PATCH 052/150] Allow overwriting app templates; fix scope lookup Add support for an Overwrite flag and logic to find & reuse existing templates/permission sets when creating app approval templates. Improve delegated permission handling by grouping multi-scope grants, preferring publishedPermissionScopes (with fallback to treat IDs as names), and adding diagnostics. Also adjust servicePrincipal fetch to request publishedPermissionScopes, reuse or generate PermissionSetId when updating, add stronger logging, and use -Force on table writes. --- .../Invoke-ExecCreateAppTemplate.ps1 | 127 +++++++++++++++--- 1 file changed, 105 insertions(+), 22 deletions(-) diff --git a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Tenant/Administration/Application Approval/Invoke-ExecCreateAppTemplate.ps1 b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Tenant/Administration/Application Approval/Invoke-ExecCreateAppTemplate.ps1 index 9e02c588bf36..23315d7eb700 100644 --- a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Tenant/Administration/Application Approval/Invoke-ExecCreateAppTemplate.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Tenant/Administration/Application Approval/Invoke-ExecCreateAppTemplate.ps1 @@ -18,6 +18,7 @@ function Invoke-ExecCreateAppTemplate { $AppId = $Request.Body.AppId $DisplayName = $Request.Body.DisplayName $Type = $Request.Body.Type # 'servicePrincipal' or 'application' + $Overwrite = $Request.Body.Overwrite -eq $true if ([string]::IsNullOrWhiteSpace($AppId)) { throw 'AppId is required' @@ -88,14 +89,21 @@ function Invoke-ExecCreateAppTemplate { $AppRoleAssignments = ($GrantsResults | Where-Object { $_.id -eq 'assignments' }).body.value $DelegateResourceAccess = $DelegatePermissionGrants | Group-Object -Property resourceId | ForEach-Object { + $resourceAccessList = [System.Collections.Generic.List[object]]::new() + foreach ($Grant in $_.Group) { + if (-not [string]::IsNullOrWhiteSpace($Grant.scope)) { + $scopeNames = $Grant.scope -split '\s+' | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } + foreach ($scopeName in $scopeNames) { + $resourceAccessList.Add([pscustomobject]@{ + id = $scopeName + type = 'Scope' + }) + } + } + } [pscustomobject]@{ resourceAppId = ($TenantInfo | Where-Object -Property id -EQ $_.Name).appId - resourceAccess = @($_.Group | ForEach-Object { - [pscustomobject]@{ - id = $_.scope - type = 'Scope' - } - }) + resourceAccess = @($resourceAccessList) } } @@ -229,11 +237,11 @@ function Invoke-ExecCreateAppTemplate { $RequestId = "sp-$RequestIndex" $AppIdToRequestId[$ResourceAppId] = $RequestId - # Use object ID to fetch full details with appRoles and oauth2PermissionScopes + # Use object ID to fetch full details with appRoles $BulkRequests.Add([PSCustomObject]@{ id = $RequestId method = 'GET' - url = "/servicePrincipals/$($ResourceSPInfo.id)?`$select=id,appId,displayName,appRoles,oauth2PermissionScopes" + url = "/servicePrincipals/$($ResourceSPInfo.id)?`$select=id,appId,displayName,appRoles,publishedPermissionScopes" }) $RequestIndex++ } else { @@ -270,6 +278,8 @@ function Invoke-ExecCreateAppTemplate { continue } + #Write-Information ($ResourceSP | ConvertTo-Json -Depth 10) + foreach ($Access in $Resource.resourceAccess) { if ($Access.type -eq 'Role') { # Look up application permission name from appRoles @@ -284,16 +294,27 @@ function Invoke-ExecCreateAppTemplate { Write-LogMessage -headers $Request.headers -API $APINAME -message "Application permission $($Access.id) not found in $ResourceAppId appRoles" -Sev 'Warning' } } elseif ($Access.type -eq 'Scope') { - # Look up delegated permission name from oauth2PermissionScopes - $PermissionScope = $ResourceSP.oauth2PermissionScopes | Where-Object { $_.id -eq $Access.id } | Select-Object -First 1 - if ($PermissionScope) { + Write-Information "Processing delegated permission with id $($Access.id) for resource appId $ResourceAppId" + # Try to look up the permission by ID in publishedPermissionScopes + $OAuth2Permission = $ResourceSP.publishedPermissionScopes | Where-Object { $_.id -eq $Access.id } | Select-Object -First 1 + $OAuth2PermissionValue = $ResourceSP.publishedPermissionScopes | Where-Object { $_.value -eq $Access.id } | Select-Object -First 1 + if ($OAuth2Permission) { + Write-Information "Found delegated permission in publishedPermissionScopes with value: $($OAuth2Permission.value)" + # Found the permission - use the value from the lookup $PermObj = [PSCustomObject]@{ id = $Access.id - value = $PermissionScope.value # Use the claim value name, not the GUID + value = $OAuth2Permission.value } [void]$DelegatedPerms.Add($PermObj) } else { - Write-LogMessage -headers $Request.headers -API $APINAME -message "Delegated permission $($Access.id) not found in $ResourceAppId oauth2PermissionScopes" -Sev 'Warning' + # Not found by ID - assume Access.id is already the permission name + Write-Information "Could not find delegated permission by ID - using provided ID as value: $($Access.id)" + Write-Information "OAuth2PermissionValueLookup: $($OAuth2PermissionValue | ConvertTo-Json -Depth 10)" + $PermObj = [PSCustomObject]@{ + id = $OAuth2PermissionValue.id ?? $Access.id + value = $Access.id + } + [void]$DelegatedPerms.Add($PermObj) } } } @@ -304,10 +325,75 @@ function Invoke-ExecCreateAppTemplate { } } - # Create the permission set in AppPermissions table + # Permission set ID will be determined after template lookup + $PermissionSetId = $null + } + + # Get permissions table reference (needed later) + $PermissionsTable = Get-CIPPTable -TableName 'AppPermissions' + + # Create the template + $Table = Get-CIPPTable -TableName 'templates' + + # Check if template already exists + # For servicePrincipal: match by AppId (immutable) + # For application: match by DisplayName (since AppId changes when copied) + $ExistingTemplate = $null + if ($Overwrite) { + try { + $Filter = "PartitionKey eq 'AppApprovalTemplate'" + $AllTemplates = Get-CIPPAzDataTableEntity @Table -Filter $Filter + $TemplateNameToMatch = "$DisplayName (Auto-created)" + + foreach ($Template in $AllTemplates) { + $TemplateData = $Template.JSON | ConvertFrom-Json + $IsMatch = $false + + if ($Type -eq 'servicePrincipal') { + # Match by AppId for service principals + $IsMatch = $TemplateData.AppId -eq $AppId + } else { + # Match by TemplateName for app registrations + $IsMatch = $TemplateData.TemplateName -eq $TemplateNameToMatch + } + + if ($IsMatch) { + $ExistingTemplate = $Template + # Reuse the existing permission set ID if it exists + if ($TemplateData.PermissionSetId) { + $PermissionSetId = $TemplateData.PermissionSetId + Write-LogMessage -headers $Request.headers -API $APINAME -message "Found existing permission set ID: $PermissionSetId in template" -Sev 'Info' + } else { + Write-LogMessage -headers $Request.headers -API $APINAME -message 'Existing template found but has no PermissionSetId' -Sev 'Warning' + } + break + } + } + } catch { + # Ignore lookup errors + Write-LogMessage -headers $Request.headers -API $APINAME -message "Error during template lookup: $($_.Exception.Message)" -Sev 'Warning' + } + } + + if ($ExistingTemplate) { + $TemplateId = $ExistingTemplate.RowKey + $MatchCriteria = if ($Type -eq 'servicePrincipal') { "AppId: $AppId" } else { "DisplayName: $DisplayName" } + Write-LogMessage -headers $Request.headers -API $APINAME -message "Overwriting existing template matched by $MatchCriteria (Template ID: $TemplateId)" -Sev 'Info' + if ($PermissionSetId) { + Write-LogMessage -headers $Request.headers -API $APINAME -message "Reusing permission set ID: $PermissionSetId" -Sev 'Info' + } + } else { + $TemplateId = (New-Guid).Guid + } + + # Create new permission set ID if we don't have one yet + if (-not $PermissionSetId) { $PermissionSetId = (New-Guid).Guid - $PermissionsTable = Get-CIPPTable -TableName 'AppPermissions' + Write-LogMessage -headers $Request.headers -API $APINAME -message "Creating new permission set ID: $PermissionSetId" -Sev 'Info' + } + # Now create/update the permission set entity with the determined ID + if ($Permissions -and $Permissions.Count -gt 0) { $PermissionEntity = @{ 'PartitionKey' = 'Templates' 'RowKey' = [string]$PermissionSetId @@ -317,13 +403,9 @@ function Invoke-ExecCreateAppTemplate { } Add-CIPPAzDataTableEntity @PermissionsTable -Entity $PermissionEntity -Force - Write-LogMessage -headers $Request.headers -API $APINAME -message "Permission set created with ID: $PermissionSetId for $($Permissions.Count) resource(s)" -Sev 'Info' + Write-LogMessage -headers $Request.headers -API $APINAME -message "Permission set saved with ID: $PermissionSetId for $($Permissions.Count) resource(s)" -Sev 'Info' } - # Create the template - $Table = Get-CIPPTable -TableName 'templates' - $TemplateId = (New-Guid).Guid - $TemplateJson = @{ TemplateName = "$DisplayName (Auto-created)" AppId = $AppId @@ -343,7 +425,7 @@ function Invoke-ExecCreateAppTemplate { PartitionKey = 'AppApprovalTemplate' } - Add-CIPPAzDataTableEntity @Table -Entity $Entity + Add-CIPPAzDataTableEntity @Table -Entity $Entity -Force $PermissionCount = 0 if ($CIPPPermissions -and $CIPPPermissions.Count -gt 0) { @@ -358,7 +440,8 @@ function Invoke-ExecCreateAppTemplate { } } - $Message = "Template created: $DisplayName with $PermissionCount permission(s)" + $Action = if ($ExistingTemplate) { 'updated' } else { 'created' } + $Message = "Template $($Action) - $DisplayName with $PermissionCount permission(s)" Write-LogMessage -headers $Request.headers -API $APINAME -message $Message -Sev 'Info' $Body = @{ From 9148b20b143092c7517a40f8fe9d0c627d3492cd Mon Sep 17 00:00:00 2001 From: Integrated Solutions Date: Thu, 12 Feb 2026 11:00:42 +1000 Subject: [PATCH 053/150] added action to "Deploy to Custom Group" for authentication methods --- .../Administration/Invoke-SetAuthMethod.ps1 | 39 ++++++++++++++++++- .../Public/Set-CIPPAuthenticationPolicy.ps1 | 35 +++++++++++++++++ 2 files changed, 73 insertions(+), 1 deletion(-) diff --git a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Tenant/Administration/Invoke-SetAuthMethod.ps1 b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Tenant/Administration/Invoke-SetAuthMethod.ps1 index 8c7ab7fab111..5c696ca8e199 100644 --- a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Tenant/Administration/Invoke-SetAuthMethod.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Tenant/Administration/Invoke-SetAuthMethod.ps1 @@ -15,10 +15,47 @@ function Invoke-SetAuthMethod { $State = if ($Request.Body.state -eq 'enabled') { $true } else { $false } $TenantFilter = $Request.Body.tenantFilter $AuthenticationMethodId = $Request.Body.Id + $GroupIdsRaw = $Request.Body.GroupIds + + function Get-StandardizedList { + param($InputObject) + + if ($null -eq $InputObject) { return @() } + + if ($InputObject -is [string]) { + return @( + $InputObject -split ',' | + ForEach-Object { $_.Trim() } | + Where-Object { -not [string]::IsNullOrWhiteSpace($_) } + ) + } + + if ($InputObject -is [array] -or $InputObject -is [System.Collections.IEnumerable]) { + return @( + $InputObject | + ForEach-Object { "$_".Trim() } | + Where-Object { -not [string]::IsNullOrWhiteSpace($_) } + ) + } + + return @("$InputObject".Trim()) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } + } + + $GroupIds = Get-StandardizedList -InputObject $GroupIdsRaw try { - $Result = Set-CIPPAuthenticationPolicy -Tenant $TenantFilter -APIName $APIName -AuthenticationMethodId $AuthenticationMethodId -Enabled $State -Headers $Headers + $Params = @{ + Tenant = $TenantFilter + APIName = $APIName + AuthenticationMethodId = $AuthenticationMethodId + Enabled = $State + Headers = $Headers + } + if (@($GroupIds).Count -gt 0) { + $Params.GroupIds = @($GroupIds) + } + $Result = Set-CIPPAuthenticationPolicy @Params $StatusCode = [HttpStatusCode]::OK } catch { $Result = $_.Exception.Message diff --git a/Modules/CIPPCore/Public/Set-CIPPAuthenticationPolicy.ps1 b/Modules/CIPPCore/Public/Set-CIPPAuthenticationPolicy.ps1 index d3335cc89f35..f4b1b0f9793b 100644 --- a/Modules/CIPPCore/Public/Set-CIPPAuthenticationPolicy.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPAuthenticationPolicy.ps1 @@ -10,6 +10,7 @@ function Set-CIPPAuthenticationPolicy { $TAPDefaultLifeTime = 60, #minutes $TAPDefaultLength = 8, #TAP password generated length in chars $TAPisUsableOnce = $true, + [Parameter()][string[]]$GroupIds, [Parameter()][ValidateRange(1, 395)]$QRCodeLifetimeInDays = 365, [Parameter()][ValidateRange(8, 20)]$QRCodePinLength = 8, $APIName = 'Set Authentication Policy', @@ -118,6 +119,40 @@ function Set-CIPPAuthenticationPolicy { throw "Somehow you hit the default case with an input of $AuthenticationMethodId . You probably made a typo in the input for AuthenticationMethodId. It`'s case sensitive." } } + + if ($PSBoundParameters.ContainsKey('GroupIds') -and @($GroupIds).Count -gt 0) { + $ResolvedGroupIds = @( + @($GroupIds) | + ForEach-Object { "$_".Trim() } | + Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | + Select-Object -Unique + ) + + if ($ResolvedGroupIds.Count -gt 0) { + $TargetTemplate = $null + if ($CurrentInfo.includeTargets -and @($CurrentInfo.includeTargets).Count -gt 0) { + $TargetTemplate = $CurrentInfo.includeTargets | Select-Object -First 1 + } + + $CurrentInfo.includeTargets = @( + foreach ($GroupId in $ResolvedGroupIds) { + $TargetProperties = [ordered]@{} + if ($TargetTemplate) { + foreach ($Property in $TargetTemplate.PSObject.Properties) { + if ($Property.Name -ne 'id' -and $Property.Name -ne 'targetType') { + $TargetProperties[$Property.Name] = $Property.Value + } + } + } + $TargetProperties.targetType = 'group' + $TargetProperties.id = $GroupId + [pscustomobject]$TargetProperties + } + ) + $OptionalLogMessage = "$OptionalLogMessage and targeted groups set to $($ResolvedGroupIds -join ', ')" + } + } + # Set state of the authentication method try { if ($PSCmdlet.ShouldProcess($AuthenticationMethodId, "Set state to $State $OptionalLogMessage")) { From e3f82840efcf8511b83a1fc619dd674488161734 Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Thu, 12 Feb 2026 19:33:37 +0100 Subject: [PATCH 054/150] add totals from db. --- .../Timer Functions/Start-CIPPStatsTimer.ps1 | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-CIPPStatsTimer.ps1 b/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-CIPPStatsTimer.ps1 index e4060f1c0a37..3218c12a3491 100644 --- a/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-CIPPStatsTimer.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-CIPPStatsTimer.ps1 @@ -6,7 +6,7 @@ function Start-CIPPStatsTimer { [CmdletBinding(SupportsShouldProcess = $true)] param() #These stats are sent to a central server to help us understand how many tenants are using the product, and how many are using the latest version, this information allows the CIPP team to make decisions about what features to support, and what features to deprecate. - #We will never ship any data that is related to your instance, all we care about is the number of tenants, and the version of the API you are running, and if you completed setup. + if ($PSCmdlet.ShouldProcess('Start-CIPPStatsTimer', 'Starting CIPP Stats Timer')) { if ($env:ApplicationID -ne 'LongApplicationID') { @@ -25,13 +25,19 @@ function Start-CIPPStatsTimer { } catch { $RawExt = @{} } - + $counts = Get-CIPPDbItem -TenantFilter AllTenants -CountsOnly + $userCount = ($counts | Where-Object { $_.RowKey -eq 'Users-Count' } | Measure-Object -Property DataCount -Sum).Sum + $deviceCount = ($counts | Where-Object { $_.RowKey -eq 'Devices-Count' } | Measure-Object -Property DataCount -Sum).Sum + $groupsCount = ($counts | Where-Object { $_.RowKey -eq 'Groups-Count' } | Measure-Object -Property DataCount -Sum).Sum $SendingObject = [PSCustomObject]@{ rgid = $env:WEBSITE_SITE_NAME SetupComplete = $SetupComplete RunningVersionAPI = $APIVersion.trim() CountOfTotalTenants = $tenantcount uid = $env:TenantID + UserCount = $userCount + DeviceCount = $deviceCount + GroupsCount = $groupsCount CIPPAPI = $RawExt.CIPPAPI.Enabled Hudu = $RawExt.Hudu.Enabled Sherweb = $RawExt.Sherweb.Enabled From df83265e6ec4c0b0bfb9a9d8c74e102b335b9078 Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Thu, 12 Feb 2026 23:18:03 +0100 Subject: [PATCH 055/150] clean up AI fragments --- .../Get-CIPPAlertInactiveGuestUsers.ps1 | 39 ++++------- .../Alerts/Get-CIPPAlertInactiveUsers.ps1 | 28 ++++---- .../Alerts/Get-CIPPAlertStaleEntraDevices.ps1 | 64 ++++++++----------- 3 files changed, 52 insertions(+), 79 deletions(-) diff --git a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertInactiveGuestUsers.ps1 b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertInactiveGuestUsers.ps1 index 1109cd5e5bc0..839a0af97e37 100644 --- a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertInactiveGuestUsers.ps1 +++ b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertInactiveGuestUsers.ps1 @@ -18,19 +18,15 @@ function Get-CIPPAlertInactiveGuestUsers { $inactiveDays = 90 $excludeDisabled = $false - if ($InputValue -is [hashtable] -or $InputValue -is [pscustomobject]) { - $excludeDisabled = [bool]$InputValue.ExcludeDisabled - if ($null -ne $InputValue.DaysSinceLastLogin -and $InputValue.DaysSinceLastLogin -ne '') { - $parsedDays = 0 - if ([int]::TryParse($InputValue.DaysSinceLastLogin.ToString(), [ref]$parsedDays) -and $parsedDays -gt 0) { - $inactiveDays = $parsedDays - } + $excludeDisabled = [bool]$InputValue.ExcludeDisabled + if ($null -ne $InputValue.DaysSinceLastLogin -and $InputValue.DaysSinceLastLogin -ne '') { + $parsedDays = 0 + if ([int]::TryParse($InputValue.DaysSinceLastLogin.ToString(), [ref]$parsedDays) -and $parsedDays -gt 0) { + $inactiveDays = $parsedDays } } - elseif ($InputValue -eq $true) { - # Backwards compatibility: legacy single-input boolean means exclude disabled users - $excludeDisabled = $true - } + + $Lookup = (Get-Date).AddDays(-$inactiveDays).ToUniversalTime() Write-Host "Checking for guest users inactive since $Lookup (excluding disabled: $excludeDisabled)" @@ -39,13 +35,11 @@ function Get-CIPPAlertInactiveGuestUsers { $Uri = if ($BaseFilter) { "https://graph.microsoft.com/beta/users?`$filter=$BaseFilter&`$select=id,UserPrincipalName,signInActivity,mail,userType,accountEnabled,assignedLicenses" - } - else { + } else { "https://graph.microsoft.com/beta/users?`$select=id,UserPrincipalName,signInActivity,mail,userType,accountEnabled,assignedLicenses" } - $GraphRequest = New-GraphGetRequest -uri $Uri -scope 'https://graph.microsoft.com/.default' -tenantid $TenantFilter | - Where-Object { $_.userType -eq 'Guest' } + $GraphRequest = New-GraphGetRequest -uri $Uri-tenantid $TenantFilter | Where-Object { $_.userType -eq 'Guest' } $AlertData = foreach ($user in $GraphRequest) { $lastInteractive = $user.signInActivity.lastSignInDateTime @@ -55,11 +49,9 @@ function Get-CIPPAlertInactiveGuestUsers { $lastSignIn = $null if ($lastInteractive -and $lastNonInteractive) { $lastSignIn = if ([DateTime]$lastInteractive -gt [DateTime]$lastNonInteractive) { $lastInteractive } else { $lastNonInteractive } - } - elseif ($lastInteractive) { + } elseif ($lastInteractive) { $lastSignIn = $lastInteractive - } - elseif ($lastNonInteractive) { + } elseif ($lastNonInteractive) { $lastSignIn = $lastNonInteractive } @@ -72,8 +64,7 @@ function Get-CIPPAlertInactiveGuestUsers { if (-not $lastSignIn) { $Message = 'Guest user {0} has never signed in.' -f $user.UserPrincipalName - } - else { + } else { $daysSinceSignIn = [Math]::Round(((Get-Date) - [DateTime]$lastSignIn).TotalDays) $Message = 'Guest user {0} has been inactive for {1} days. Last sign-in: {2}' -f $user.UserPrincipalName, $daysSinceSignIn, $lastSignIn } @@ -91,10 +82,8 @@ function Get-CIPPAlertInactiveGuestUsers { } Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData - } - catch {} - } - catch { + } catch {} + } catch { Write-AlertMessage -tenant $($TenantFilter) -message "Failed to check inactive guest users for $($TenantFilter): $(Get-NormalizedError -message $_.Exception.message)" } } diff --git a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertInactiveUsers.ps1 b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertInactiveUsers.ps1 index 0a42e8346cce..037f37e501d5 100644 --- a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertInactiveUsers.ps1 +++ b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertInactiveUsers.ps1 @@ -18,17 +18,12 @@ function Get-CIPPAlertInactiveUsers { $inactiveDays = 90 $excludeDisabled = $false - if ($InputValue -is [hashtable] -or $InputValue -is [pscustomobject]) { - $excludeDisabled = [bool]$InputValue.ExcludeDisabled - if ($null -ne $InputValue.DaysSinceLastLogin -and $InputValue.DaysSinceLastLogin -ne '') { - $parsedDays = 0 - if ([int]::TryParse($InputValue.DaysSinceLastLogin.ToString(), [ref]$parsedDays) -and $parsedDays -gt 0) { - $inactiveDays = $parsedDays - } + $excludeDisabled = [bool]$InputValue.ExcludeDisabled + if ($null -ne $InputValue.DaysSinceLastLogin -and $InputValue.DaysSinceLastLogin -ne '') { + $parsedDays = 0 + if ([int]::TryParse($InputValue.DaysSinceLastLogin.ToString(), [ref]$parsedDays) -and $parsedDays -gt 0) { + $inactiveDays = $parsedDays } - } elseif ($InputValue -eq $true) { - # Backwards compatibility: legacy single-input boolean means exclude disabled users - $excludeDisabled = $true } $Lookup = (Get-Date).AddDays(-$inactiveDays).ToUniversalTime() @@ -42,8 +37,7 @@ function Get-CIPPAlertInactiveUsers { "https://graph.microsoft.com/beta/users?`$select=id,UserPrincipalName,signInActivity,mail,userType,accountEnabled,assignedLicenses" } - $GraphRequest = New-GraphGetRequest -uri $Uri -scope 'https://graph.microsoft.com/.default' -tenantid $TenantFilter | - Where-Object { $_.userType -eq 'Member' } + $GraphRequest = New-GraphGetRequest -uri $Uri -tenantid $TenantFilter | Where-Object { $_.userType -eq 'Member' } $AlertData = foreach ($user in $GraphRequest) { $lastInteractive = $user.signInActivity.lastSignInDateTime @@ -73,12 +67,12 @@ function Get-CIPPAlertInactiveUsers { } [PSCustomObject]@{ - UserPrincipalName = $user.UserPrincipalName - Id = $user.id - lastSignIn = $lastSignIn + UserPrincipalName = $user.UserPrincipalName + Id = $user.id + lastSignIn = $lastSignIn DaysSinceLastSignIn = if ($daysSinceSignIn) { $daysSinceSignIn } else { 'N/A' } - Message = $Message - Tenant = $TenantFilter + Message = $Message + Tenant = $TenantFilter } } } diff --git a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertStaleEntraDevices.ps1 b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertStaleEntraDevices.ps1 index 2c308fb00e05..29043c3288fd 100644 --- a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertStaleEntraDevices.ps1 +++ b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertStaleEntraDevices.ps1 @@ -15,19 +15,13 @@ function Get-CIPPAlertStaleEntraDevices { try { $inactiveDays = 90 - if ($InputValue -is [hashtable] -or $InputValue -is [pscustomobject]) { - $excludeDisabled = [bool]$InputValue.ExcludeDisabled - if ($null -ne $InputValue.DaysSinceLastActivity -and $InputValue.DaysSinceLastActivity -ne '') { - $parsedDays = 0 - if ([int]::TryParse($InputValue.DaysSinceLastActivity.ToString(), [ref]$parsedDays) -and $parsedDays -gt 0) { - $inactiveDays = $parsedDays - } + $excludeDisabled = [bool]$InputValue.ExcludeDisabled + if ($null -ne $InputValue.DaysSinceLastActivity -and $InputValue.DaysSinceLastActivity -ne '') { + $parsedDays = 0 + if ([int]::TryParse($InputValue.DaysSinceLastActivity.ToString(), [ref]$parsedDays) -and $parsedDays -gt 0) { + $inactiveDays = $parsedDays } } - elseif ($InputValue -eq $true) { - # Backwards compatibility: legacy single-input boolean means exclude disabled users - $excludeDisabled = $true - } $Lookup = (Get-Date).AddDays(-$inactiveDays).ToUniversalTime() Write-Host "Checking for inactive Entra devices since $Lookup (excluding disabled: $excludeDisabled)" @@ -36,12 +30,11 @@ function Get-CIPPAlertStaleEntraDevices { $Uri = if ($BaseFilter) { "https://graph.microsoft.com/beta/devices?`$filter=$BaseFilter" - } - else { - "https://graph.microsoft.com/beta/devices" + } else { + 'https://graph.microsoft.com/beta/devices' } - $GraphRequest = New-GraphGetRequest -uri $Uri -scope 'https://graph.microsoft.com/.default' -tenantid $TenantFilter + $GraphRequest = New-GraphGetRequest -uri $Uri -tenantid $TenantFilter $AlertData = foreach ($device in $GraphRequest) { @@ -54,40 +47,37 @@ function Get-CIPPAlertStaleEntraDevices { if (-not $lastActivity) { $Message = 'Device {0} has never been active' -f $device.displayName - } - else { + } else { $daysSinceLastActivity = [Math]::Round(((Get-Date) - [DateTime]$lastActivity).TotalDays) $Message = 'Device {0} has been inactive for {1} days. Last activity: {2}' -f $device.displayName, $daysSinceLastActivity, $lastActivity } - if ($device.TrustType -eq "Workplace") { $TrustType = "Entra registered" } - elseif ($device.TrustType -eq "AzureAd") { $TrustType = "Entra joined" } - elseif ($device.TrustType -eq "ServerAd") { $TrustType = "Entra hybrid joined" } + if ($device.TrustType -eq 'Workplace') { $TrustType = 'Entra registered' } + elseif ($device.TrustType -eq 'AzureAd') { $TrustType = 'Entra joined' } + elseif ($device.TrustType -eq 'ServerAd') { $TrustType = 'Entra hybrid joined' } [PSCustomObject]@{ - DeviceName = if ($device.displayName) { $device.displayName } else { 'N/A' } - Id = if ($device.id) { $device.id } else { 'N/A' } - deviceOwnership = if ($device.deviceOwnership) { $device.deviceOwnership } else { 'N/A' } - operatingSystem = if ($device.operatingSystem) { $device.operatingSystem } else { 'N/A' } - enrollmentType = if ($device.enrollmentType) { $device.enrollmentType } else { 'N/A' } - Enabled = if ($device.accountEnabled) { $device.accountEnabled } else { 'N/A' } - Managed = if ($device.isManaged) { $device.isManaged } else { 'N/A' } - Complaint = if ($device.isCompliant) { $device.isCompliant } else { 'N/A' } - JoinType = $TrustType - lastActivity = if ($lastActivity) { $lastActivity } else { 'N/A' } + DeviceName = if ($device.displayName) { $device.displayName } else { 'N/A' } + Id = if ($device.id) { $device.id } else { 'N/A' } + deviceOwnership = if ($device.deviceOwnership) { $device.deviceOwnership } else { 'N/A' } + operatingSystem = if ($device.operatingSystem) { $device.operatingSystem } else { 'N/A' } + enrollmentType = if ($device.enrollmentType) { $device.enrollmentType } else { 'N/A' } + Enabled = if ($device.accountEnabled) { $device.accountEnabled } else { 'N/A' } + Managed = if ($device.isManaged) { $device.isManaged } else { 'N/A' } + Complaint = if ($device.isCompliant) { $device.isCompliant } else { 'N/A' } + JoinType = $TrustType + lastActivity = if ($lastActivity) { $lastActivity } else { 'N/A' } DaysSinceLastActivity = if ($daysSinceLastActivity) { $daysSinceLastActivity } else { 'N/A' } - RegisteredDateTime = if ($device.createdDateTime) { $device.createdDateTime } else { 'N/A' } - Message = $Message - Tenant = $TenantFilter + RegisteredDateTime = if ($device.createdDateTime) { $device.createdDateTime } else { 'N/A' } + Message = $Message + Tenant = $TenantFilter } } } Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData - } - catch {} - } - catch { + } catch {} + } catch { Write-AlertMessage -tenant $($TenantFilter) -message "Failed to check inactive guest users for $($TenantFilter): $(Get-NormalizedError -message $_.Exception.message)" } } From d5b3ab0bb1337dc59558ea1943b4e66bee41adaf Mon Sep 17 00:00:00 2001 From: Integrated Solutions Date: Fri, 13 Feb 2026 14:46:37 +1000 Subject: [PATCH 056/150] when creating a Security group, mailNickname will populate with random string if empty --- Modules/CIPPCore/Public/New-CIPPGroup.ps1 | 32 ++++++++++++++++------- 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/Modules/CIPPCore/Public/New-CIPPGroup.ps1 b/Modules/CIPPCore/Public/New-CIPPGroup.ps1 index 1d4a199568e4..72dc499d5748 100644 --- a/Modules/CIPPCore/Public/New-CIPPGroup.ps1 +++ b/Modules/CIPPCore/Public/New-CIPPGroup.ps1 @@ -77,20 +77,32 @@ function New-CIPPGroup { $null } + # Determine if we should generate a random mailNickname: + # For Security/Generic groups WITHOUT a username filled in + $ShouldGenerateRandomMailNickname = ($NormalizedGroupType -in @('Generic', 'Security')) -and [string]::IsNullOrWhiteSpace($GroupObject.username) + # Extract local part of username if exists and remove special characters for mailNickname - if ($GroupObject.username -like '*@*') { - $MailNickname = ($GroupObject.username -split '@')[0] + if ($ShouldGenerateRandomMailNickname) { + # Generate a random alphanumeric mailNickname for security groups without a username + # Format: 8 hex characters + hyphen + 1 hex character (e.g., "450662e4-3") + $RandomPart1 = -join ((0..7) | ForEach-Object { (0..15 | ForEach-Object { '0123456789abcdef'[$_] } | Get-Random) }) + $RandomPart2 = (0..15 | ForEach-Object { '0123456789abcdef'[$_] } | Get-Random) + $MailNickname = "$RandomPart1-$RandomPart2" } else { - $MailNickname = $GroupObject.username - } + if ($GroupObject.username) { + $MailNickname = ($GroupObject.username -split '@')[0] + } else { + $MailNickname = $GroupObject.username + } - # Remove forbidden characters per Microsoft 365 mailNickname requirements: - # ASCII 0-127 only, excluding: @ () / [] ' ; : <> , SPACE and any non-ASCII - $MailNickname = $MailNickname -replace "[@()\[\]/'`;:<>,\s]|[^\x00-\x7F]", '' + # Remove forbidden characters per Microsoft 365 mailNickname requirements: + # ASCII 0-127 only, excluding: @ () / [] ' ; : <> , SPACE and any non-ASCII + $MailNickname = $MailNickname -replace "[@()\[\]/'`;:<>,\s]|[^\x00-\x7F]", '' - # Ensure max length of 64 characters - if ($MailNickname.Length -gt 64) { - $MailNickname = $MailNickname.Substring(0, 64) + # Ensure max length of 64 characters + if ($MailNickname.Length -gt 64) { + $MailNickname = $MailNickname.Substring(0, 64) + } } Write-LogMessage -API $APIName -tenant $TenantFilter -message "Creating group $($GroupObject.displayName) of type $NormalizedGroupType$(if ($NeedsEmail) { " with email $Email" })" -Sev Info 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 057/150] 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 058/150] 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 059/150] 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 060/150] 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 061/150] 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 062/150] 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 063/150] 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 064/150] 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 065/150] 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 066/150] 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 067/150] 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 068/150] 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 069/150] 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 070/150] 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 071/150] 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 072/150] 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 073/150] 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 074/150] 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 075/150] 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 076/150] 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 077/150] 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 078/150] 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 079/150] 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 080/150] 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 081/150] 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 082/150] 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 083/150] 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 084/150] 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 085/150] 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 086/150] 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 087/150] 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 088/150] 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 089/150] 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 090/150] 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 091/150] 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 092/150] 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 093/150] 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 094/150] 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 095/150] 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 096/150] 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 097/150] 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 098/150] 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 099/150] 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 100/150] 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 101/150] 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 102/150] 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 103/150] 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 104/150] 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 105/150] 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 106/150] 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 107/150] 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 108/150] 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 109/150] 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 110/150] 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 111/150] 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 112/150] 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 113/150] 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 114/150] 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 115/150] 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 116/150] 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 117/150] 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 118/150] 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 119/150] 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 120/150] 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 bc49356ef6557a8a2decd66f1e5ffecf97e17d8e Mon Sep 17 00:00:00 2001 From: Integrated Solutions <159874617+isgq-github01@users.noreply.github.com> Date: Wed, 18 Feb 2026 15:16:21 +1000 Subject: [PATCH 121/150] Update Invoke-SetAuthMethod.ps1 - added functionality for deploying to groups --- .../Administration/Invoke-SetAuthMethod.ps1 | 31 ++----------------- 1 file changed, 2 insertions(+), 29 deletions(-) diff --git a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Tenant/Administration/Invoke-SetAuthMethod.ps1 b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Tenant/Administration/Invoke-SetAuthMethod.ps1 index 5c696ca8e199..71b5adf5984d 100644 --- a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Tenant/Administration/Invoke-SetAuthMethod.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Tenant/Administration/Invoke-SetAuthMethod.ps1 @@ -15,34 +15,7 @@ function Invoke-SetAuthMethod { $State = if ($Request.Body.state -eq 'enabled') { $true } else { $false } $TenantFilter = $Request.Body.tenantFilter $AuthenticationMethodId = $Request.Body.Id - $GroupIdsRaw = $Request.Body.GroupIds - - function Get-StandardizedList { - param($InputObject) - - if ($null -eq $InputObject) { return @() } - - if ($InputObject -is [string]) { - return @( - $InputObject -split ',' | - ForEach-Object { $_.Trim() } | - Where-Object { -not [string]::IsNullOrWhiteSpace($_) } - ) - } - - if ($InputObject -is [array] -or $InputObject -is [System.Collections.IEnumerable]) { - return @( - $InputObject | - ForEach-Object { "$_".Trim() } | - Where-Object { -not [string]::IsNullOrWhiteSpace($_) } - ) - } - - return @("$InputObject".Trim()) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } - } - - $GroupIds = Get-StandardizedList -InputObject $GroupIdsRaw - + $GroupIds = $Request.Body.GroupIds try { $Params = @{ @@ -52,7 +25,7 @@ function Invoke-SetAuthMethod { Enabled = $State Headers = $Headers } - if (@($GroupIds).Count -gt 0) { + if ($GroupIds) { $Params.GroupIds = @($GroupIds) } $Result = Set-CIPPAuthenticationPolicy @Params From 442a3db1289e97ebe801b7c76eff9f44bc085ebf Mon Sep 17 00:00:00 2001 From: Integrated Solutions <159874617+isgq-github01@users.noreply.github.com> Date: Wed, 18 Feb 2026 15:17:30 +1000 Subject: [PATCH 122/150] Update New-CIPPGroup.ps1 - allow blank usernames, generate GUID --- Modules/CIPPCore/Public/New-CIPPGroup.ps1 | 29 ++++------------------- 1 file changed, 4 insertions(+), 25 deletions(-) diff --git a/Modules/CIPPCore/Public/New-CIPPGroup.ps1 b/Modules/CIPPCore/Public/New-CIPPGroup.ps1 index 72dc499d5748..ea08927acaa8 100644 --- a/Modules/CIPPCore/Public/New-CIPPGroup.ps1 +++ b/Modules/CIPPCore/Public/New-CIPPGroup.ps1 @@ -77,32 +77,11 @@ function New-CIPPGroup { $null } - # Determine if we should generate a random mailNickname: - # For Security/Generic groups WITHOUT a username filled in - $ShouldGenerateRandomMailNickname = ($NormalizedGroupType -in @('Generic', 'Security')) -and [string]::IsNullOrWhiteSpace($GroupObject.username) - - # Extract local part of username if exists and remove special characters for mailNickname - if ($ShouldGenerateRandomMailNickname) { - # Generate a random alphanumeric mailNickname for security groups without a username - # Format: 8 hex characters + hyphen + 1 hex character (e.g., "450662e4-3") - $RandomPart1 = -join ((0..7) | ForEach-Object { (0..15 | ForEach-Object { '0123456789abcdef'[$_] } | Get-Random) }) - $RandomPart2 = (0..15 | ForEach-Object { '0123456789abcdef'[$_] } | Get-Random) - $MailNickname = "$RandomPart1-$RandomPart2" + # Determine if we should generate a mailNickname with a GUID, or use the username field + if (-not $GroupObject.Username) { + $MailNickname = (New-Guid).guid.substring(0, 10) } else { - if ($GroupObject.username) { - $MailNickname = ($GroupObject.username -split '@')[0] - } else { - $MailNickname = $GroupObject.username - } - - # Remove forbidden characters per Microsoft 365 mailNickname requirements: - # ASCII 0-127 only, excluding: @ () / [] ' ; : <> , SPACE and any non-ASCII - $MailNickname = $MailNickname -replace "[@()\[\]/'`;:<>,\s]|[^\x00-\x7F]", '' - - # Ensure max length of 64 characters - if ($MailNickname.Length -gt 64) { - $MailNickname = $MailNickname.Substring(0, 64) - } + $MailNickname = $GroupObject.Username } Write-LogMessage -API $APIName -tenant $TenantFilter -message "Creating group $($GroupObject.displayName) of type $NormalizedGroupType$(if ($NeedsEmail) { " with email $Email" })" -Sev Info From 6a1367ee057c2e12c3a086550d9f6196bd3321f9 Mon Sep 17 00:00:00 2001 From: Integrated Solutions <159874617+isgq-github01@users.noreply.github.com> Date: Wed, 18 Feb 2026 15:18:14 +1000 Subject: [PATCH 123/150] Update Set-CIPPAuthenticationPolicy.ps1 - allow deploying to groups --- .../Public/Set-CIPPAuthenticationPolicy.ps1 | 40 +++++-------------- 1 file changed, 10 insertions(+), 30 deletions(-) diff --git a/Modules/CIPPCore/Public/Set-CIPPAuthenticationPolicy.ps1 b/Modules/CIPPCore/Public/Set-CIPPAuthenticationPolicy.ps1 index f4b1b0f9793b..a7202202bef0 100644 --- a/Modules/CIPPCore/Public/Set-CIPPAuthenticationPolicy.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPAuthenticationPolicy.ps1 @@ -120,39 +120,19 @@ function Set-CIPPAuthenticationPolicy { } } - if ($PSBoundParameters.ContainsKey('GroupIds') -and @($GroupIds).Count -gt 0) { - $ResolvedGroupIds = @( - @($GroupIds) | - ForEach-Object { "$_".Trim() } | - Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | - Select-Object -Unique - ) - - if ($ResolvedGroupIds.Count -gt 0) { - $TargetTemplate = $null - if ($CurrentInfo.includeTargets -and @($CurrentInfo.includeTargets).Count -gt 0) { - $TargetTemplate = $CurrentInfo.includeTargets | Select-Object -First 1 - } - - $CurrentInfo.includeTargets = @( - foreach ($GroupId in $ResolvedGroupIds) { - $TargetProperties = [ordered]@{} - if ($TargetTemplate) { - foreach ($Property in $TargetTemplate.PSObject.Properties) { - if ($Property.Name -ne 'id' -and $Property.Name -ne 'targetType') { - $TargetProperties[$Property.Name] = $Property.Value - } - } - } - $TargetProperties.targetType = 'group' - $TargetProperties.id = $GroupId - [pscustomobject]$TargetProperties + if ($PSBoundParameters.ContainsKey('GroupIds') -and $GroupIds) { + $CurrentInfo.includeTargets = @( + foreach ($id in $GroupIds ) { + [pscustomobject]@{ + targetType = 'group' + id = $id } - ) - $OptionalLogMessage = "$OptionalLogMessage and targeted groups set to $($ResolvedGroupIds -join ', ')" - } + } + ) + $OptionalLogMessage += " and targeted groups set to $($CurrentInfo.includeTargets.id -join ', ')" } + # Set state of the authentication method try { if ($PSCmdlet.ShouldProcess($AuthenticationMethodId, "Set state to $State $OptionalLogMessage")) { From c9afb7ec9cb072d3c6a5255c88c13183bbf3916f Mon Sep 17 00:00:00 2001 From: John Duprey Date: Wed, 18 Feb 2026 12:05:46 -0500 Subject: [PATCH 124/150] 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 125/150] 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 126/150] 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 127/150] 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 128/150] 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 129/150] 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 130/150] 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 131/150] 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 132/150] 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 133/150] 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 134/150] 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 135/150] 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 136/150] 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 137/150] 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 138/150] 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 139/150] 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 140/150] 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 141/150] 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 142/150] 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 143/150] 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 144/150] 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 145/150] 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 146/150] 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 147/150] 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 148/150] 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 149/150] 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 150/150] 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