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/Alerts/Get-CIPPAlertInactiveGuestUsers.ps1 b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertInactiveGuestUsers.ps1 new file mode 100644 index 000000000000..839a0af97e37 --- /dev/null +++ b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertInactiveGuestUsers.ps1 @@ -0,0 +1,89 @@ +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 + + $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 + } + } + + + + $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-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..037f37e501d5 --- /dev/null +++ b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertInactiveUsers.ps1 @@ -0,0 +1,85 @@ +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 + + $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 + } + } + + $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 -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-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]@{ diff --git a/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertStaleEntraDevices.ps1 b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertStaleEntraDevices.ps1 new file mode 100644 index 000000000000..29043c3288fd --- /dev/null +++ b/Modules/CIPPCore/Public/Alerts/Get-CIPPAlertStaleEntraDevices.ps1 @@ -0,0 +1,83 @@ +function Get-CIPPAlertStaleEntraDevices { + <# + .FUNCTIONALITY + Entrypoint + #> + [CmdletBinding()] + param ( + [Parameter(Mandatory = $false)] + [Alias('input')] + $InputValue, + $TenantFilter + ) + + try { + try { + $inactiveDays = 90 + + $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 + } + } + + $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 -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)" + } +} diff --git a/Modules/CIPPCore/Public/Compare-CIPPIntuneObject.ps1 b/Modules/CIPPCore/Public/Compare-CIPPIntuneObject.ps1 index b974f4bafbe7..de8c605da2a0 100644 --- a/Modules/CIPPCore/Public/Compare-CIPPIntuneObject.ps1 +++ b/Modules/CIPPCore/Public/Compare-CIPPIntuneObject.ps1 @@ -295,7 +295,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) } } } } @@ -381,7 +381,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) } } } @@ -399,7 +399,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 @@ -471,7 +471,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/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/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..93dd3b986ce3 --- /dev/null +++ b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ListIntuneReusableSettingTemplates.ps1 @@ -0,0 +1,46 @@ +function Invoke-ListIntuneReusableSettingTemplates { + <# + .FUNCTIONALITY + Entrypoint,AnyTenant + .ROLE + Endpoint.MEM.Read + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $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) { + $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 + + 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..20f2712ae3c0 --- /dev/null +++ b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ListIntuneReusableSettings.ps1 @@ -0,0 +1,71 @@ +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 + $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/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/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 = @{ 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 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 {} 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/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-CIPPDBCacheUsers.ps1 b/Modules/CIPPCore/Public/Set-CIPPDBCacheUsers.ps1 index 8462dbd75e7f..d500fcbe16f2 100644 --- a/Modules/CIPPCore/Public/Set-CIPPDBCacheUsers.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPDBCacheUsers.ps1 @@ -20,7 +20,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 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/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 a47738ecaee2..c08f688dace3 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) + Write-Host 'INTUNETEMPLATERUN' $Table = Get-CippTable -tablename 'templates' $Filter = "PartitionKey eq 'IntuneTemplate'" @@ -46,6 +47,17 @@ function Invoke-CIPPStandardIntuneTemplate { 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 + } + } 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 = $Template.Displayname $description = $Template.Description $RawJSON = $Template.RawJSON 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 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/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 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/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": [ 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) { 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 + } +}