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 01/15] 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 02/15] 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 03/15] 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 04/15] 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 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 05/15] 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 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 06/15] 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 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 07/15] 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 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 08/15] 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 4fd9970c16b56e57f33cdb6eb88227ca1f6ae574 Mon Sep 17 00:00:00 2001 From: Steven van Beek Date: Tue, 10 Feb 2026 14:00:23 +0100 Subject: [PATCH 09/15] 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 19f9d7f8a01ef29504606c9fa4ee7edb2ba4cccd Mon Sep 17 00:00:00 2001 From: mpressley-np Date: Tue, 10 Feb 2026 12:42:13 -0500 Subject: [PATCH 10/15] 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 e6ba6fa132498059906ccacd16a9f7d2f8dd9585 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Wed, 11 Feb 2026 11:40:22 -0500 Subject: [PATCH 11/15] 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 12/15] 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 13/15] 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 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 14/15] 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 15/15] 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)" } }