From 1db684e7d22ca081ebb299bc46a44049233bb791 Mon Sep 17 00:00:00 2001 From: jspern Date: Thu, 30 Jul 2026 13:59:53 -0400 Subject: [PATCH 001/226] feat(standards): add TAP lifetime and length configuration Expose minimum, maximum, and default TAP lifetime along with TAP length as configurable options on the Enable Temporary Access Passes standard. Previously only the single-use/multi-logon toggle was exposed, and the lifetime and length values silently fell back to the Set-CIPPAuthenticationPolicy parameter defaults. Validate the configuration before contacting Graph: the run is skipped with an error when the minimum lifetime exceeds the maximum, or when the default lifetime falls outside that range, rather than issuing a PATCH that Graph would reject. Absolute bounds are surfaced in the form via field validators. Drift detection, remediation, and the standards comparison report now cover all five settings. Co-Authored-By: Claude Opus 5 --- backend/Config/standards.json | 46 ++++++++- .../Standards/Invoke-CIPPStandardTAP.ps1 | 95 ++++++++++++++----- frontend/src/data/standards.json | 46 ++++++++- 3 files changed, 156 insertions(+), 31 deletions(-) diff --git a/backend/Config/standards.json b/backend/Config/standards.json index af70959830..e33dd39f82 100644 --- a/backend/Config/standards.json +++ b/backend/Config/standards.json @@ -1212,15 +1212,55 @@ "cat": "Entra (AAD) Standards", "tag": [], "appliesToTest": ["EIDSCAAT01", "EIDSCAAT02", "ZTNA21845", "ZTNA21846"], - "helpText": "Enables TAP and sets the default TAP lifetime to 1 hour. This configuration also allows you to select if a TAP is single use or multi-logon.", + "helpText": "Enable TAP with the specified configuration settings.", "docsDescription": "Enables Temporary Access Pass generation for the tenant.", - "executiveText": "Enables temporary access passes that IT administrators can generate for employees who are locked out or need emergency access to systems. These time-limited passs provide a secure way to restore access without compromising long-term security policies.", + "executiveText": "Enables temporary access passes that IT administrators can generate for employees who are locked out or need emergency access to systems. These time-limited passes provide a secure way to restore access without compromising long-term security policies.", "addedComponent": [ + { + "type": "number", + "name": "standards.TAP.MinimumLifetime", + "label": "Minimum Lifetime (minutes)", + "defaultValue": 60, + "validators": { + "min": { "value": 10, "message": "Minimum value is 10" }, + "max": { "value": 43200, "message": "Maximum value is 43200" } + } + }, + { + "type": "number", + "name": "standards.TAP.MaximumLifetime", + "label": "Maximum Lifetime (minutes)", + "defaultValue": 480, + "validators": { + "min": { "value": 10, "message": "Minimum value is 10" }, + "max": { "value": 43200, "message": "Maximum value is 43200" } + } + }, + { + "type": "number", + "name": "standards.TAP.DefaultLifetime", + "label": "Default Lifetime (minutes)", + "defaultValue": 60, + "validators": { + "min": { "value": 10, "message": "Minimum value is 10" }, + "max": { "value": 43200, "message": "Maximum value is 43200" } + } + }, + { + "type": "number", + "name": "standards.TAP.TAPLength", + "label": "Length (characters)", + "defaultValue": 8, + "validators": { + "min": { "value": 8, "message": "Minimum value is 8" }, + "max": { "value": 48, "message": "Maximum value is 48" } + } + }, { "type": "autoComplete", "multiple": false, "creatable": false, - "label": "Select TAP Lifetime", + "label": "Number of Times Usable", "name": "standards.TAP.config", "options": [ { "label": "Only Once", "value": "true" }, diff --git a/backend/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTAP.ps1 b/backend/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTAP.ps1 index 21d632dac5..80f06e8c2b 100644 --- a/backend/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTAP.ps1 +++ b/backend/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTAP.ps1 @@ -5,22 +5,27 @@ function Invoke-CIPPStandardTAP { .COMPONENT (APIName) TAP .SYNOPSIS - (Label) Enable Temporary Access Passwords + (Label) Enable Temporary Access Passes (TAP) .DESCRIPTION - (Helptext) Enables TAP and sets the default TAP lifetime to 1 hour. This configuration also allows you to select if a TAP is single use or multi-logon. - (DocsDescription) Enables Temporary Password generation for the tenant. + (Helptext) Enable TAP with the specified configuration settings. + (DocsDescription) Enables Temporary Access Pass generation for the tenant. .NOTES CAT Entra (AAD) Standards TAG - "ZTNA21845" - "ZTNA21846" + APPLIESTOTEST "EIDSCAAT01" "EIDSCAAT02" + "ZTNA21845" + "ZTNA21846" EXECUTIVETEXT - Enables temporary access passwords that IT administrators can generate for employees who are locked out or need emergency access to systems. These time-limited passwords provide a secure way to restore access without compromising long-term security policies. + Enables temporary access passes that IT administrators can generate for employees who are locked out or need emergency access to systems. These time-limited passes provide a secure way to restore access without compromising long-term security policies. ADDEDCOMPONENT - {"type":"autoComplete","multiple":false,"creatable":false,"label":"Select TAP Lifetime","name":"standards.TAP.config","options":[{"label":"Only Once","value":"true"},{"label":"Multiple Logons","value":"false"}]} + {"type":"number","name":"standards.TAP.MinimumLifetime","label":"Minimum Lifetime (minutes)","defaultValue":60} + {"type":"number","name":"standards.TAP.MaximumLifetime","label":"Maximum Lifetime (minutes)","defaultValue":480} + {"type":"number","name":"standards.TAP.DefaultLifetime","label":"Default Lifetime (minutes)","defaultValue":60} + {"type":"number","name":"standards.TAP.TAPLength","label":"Length (characters)","defaultValue":8} + {"type":"autoComplete","multiple":false,"creatable":false,"label":"Number of Times Usable","name":"standards.TAP.config","options":[{"label":"Only Once","value":"true"},{"label":"Multiple Logons","value":"false"}]} IMPACT Low Impact ADDEDDATE @@ -30,12 +35,30 @@ function Invoke-CIPPStandardTAP { RECOMMENDEDBY "CIPP" UPDATECOMMENTBLOCK - Run the Tools\Update-StandardsComments.ps1 script to update this comment block + Run the tools\Update-StandardsComments.ps1 script to update this comment block .LINK https://docs.cipp.app/user-documentation/tenant/standards/alignment/templates/available-standards #> param($Tenant, $Settings) + + # Get config values using null-coalescing operator + $MinimumLifetime = [int]($Settings.MinimumLifetime ?? 60) + $MaximumLifetime = [int]($Settings.MaximumLifetime ?? 480) + $DefaultLifetime = [int]($Settings.DefaultLifetime ?? 60) + $TAPLength = [int]($Settings.TAPLength ?? 8) + $OneTimeUse = $Settings.config.value ?? $Settings.config ?? 'true' + $OneTimeUseBool = [System.Convert]::ToBoolean($OneTimeUse) + + if ($MinimumLifetime -gt $MaximumLifetime) { + Write-LogMessage -API 'Standards' -Tenant $Tenant -Message "TAP: minimum lifetime ($MinimumLifetime) exceeds maximum lifetime ($MaximumLifetime). Skipping run, correct the standard configuration." -Sev Error + return + } + + if ($DefaultLifetime -lt $MinimumLifetime -or $DefaultLifetime -gt $MaximumLifetime) { + Write-LogMessage -API 'Standards' -Tenant $Tenant -Message "TAP: default lifetime ($DefaultLifetime) must fall between the minimum ($MinimumLifetime) and maximum ($MaximumLifetime). Skipping run, correct the standard configuration." -Sev Error + return + } try { $CurrentState = New-GraphGetRequest -Uri 'https://graph.microsoft.com/beta/policies/authenticationmethodspolicy/authenticationMethodConfigurations/TemporaryAccessPass' -tenantid $Tenant @@ -45,33 +68,45 @@ function Invoke-CIPPStandardTAP { return } - # Get config value using null-coalescing operator - $config = $Settings.config.value ?? $Settings.config - if ($null -eq $config) { $config = $True } - - $StateIsCorrect = ($CurrentState.state -eq 'enabled') -and - ([System.Convert]::ToBoolean($CurrentState.isUsableOnce) -eq [System.Convert]::ToBoolean($config)) + $StateIsCorrect = $CurrentState.state -eq 'enabled' -and + [int]$CurrentState.minimumLifetimeInMinutes -eq $MinimumLifetime -and + [int]$CurrentState.maximumLifetimeInMinutes -eq $MaximumLifetime -and + [int]$CurrentState.defaultLifetimeInMinutes -eq $DefaultLifetime -and + [int]$CurrentState.defaultLength -eq $TAPLength -and + [System.Convert]::ToBoolean($CurrentState.isUsableOnce) -eq $OneTimeUseBool if ($Settings.remediate -eq $true) { if ($StateIsCorrect -eq $true) { - Write-LogMessage -API 'Standards' -tenant $Tenant -message 'Temporary Access Passwords is already enabled.' -sev Info + Write-LogMessage -API 'Standards' -tenant $Tenant -message 'Temporary Access Pass policy already matches the desired state.' -sev Info } else { try { - Set-CIPPAuthenticationPolicy -Tenant $Tenant -APIName 'Standards' -AuthenticationMethodId 'TemporaryAccessPass' -Enabled $true -TAPisUsableOnce $config + $PolicyConfig = @{ + Tenant = $Tenant + APIName = 'Standards' + AuthenticationMethodId = 'TemporaryAccessPass' + Enabled = $true + TapMinimumLifetime = $MinimumLifetime + TAPMaximumLifetime = $MaximumLifetime + TAPDefaultLifeTime = $DefaultLifetime + TAPDefaultLength = $TAPLength + TAPisUsableOnce = $OneTimeUseBool + } + + Set-CIPPAuthenticationPolicy @PolicyConfig } catch { $ErrorMessage = Get-CippException -Exception $_ - Write-LogMessage -API 'Standards' -tenant $Tenant -message "Failed to enable Temporary Access Passwords. Error: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage + Write-LogMessage -API 'Standards' -tenant $Tenant -message "Failed to configure Temporary Access Pass policy. Error: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage } } } if ($Settings.alert -eq $true) { if ($StateIsCorrect -eq $true) { - Write-LogMessage -API 'Standards' -tenant $Tenant -message 'Temporary Access Passwords is enabled.' -sev Info + Write-LogMessage -API 'Standards' -tenant $Tenant -message 'Temporary Access Pass policy is enabled and configured.' -sev Info } else { - $Object = $CurrentState | Select-Object -Property state, isUsableOnce, defaultLifetimeInMinutes, defaultLength, maximumLifetimeInMinutes - Write-StandardsAlert -message 'Temporary Access Passwords is not enabled.' -object $Object -tenant $Tenant -standardName 'TAP' -standardId $Settings.standardId - Write-LogMessage -API 'Standards' -tenant $Tenant -message 'Temporary Access Passwords is not enabled.' -sev Info + $Object = $CurrentState | Select-Object -Property state, isUsableOnce, defaultLifetimeInMinutes, defaultLength, maximumLifetimeInMinutes, minimumLifetimeInMinutes + Write-StandardsAlert -message 'Temporary Access Pass policy is not enabled.' -object $Object -tenant $Tenant -standardName 'TAP' -standardId $Settings.standardId + Write-LogMessage -API 'Standards' -tenant $Tenant -message 'Temporary Access Pass policy is not enabled.' -sev Info } } @@ -79,13 +114,23 @@ function Invoke-CIPPStandardTAP { Add-CIPPBPAField -FieldName 'TemporaryAccessPass' -FieldValue $StateIsCorrect -StoreAs bool -Tenant $Tenant $CurrentValue = @{ - state = $CurrentState.state - isUsableOnce = $CurrentState.isUsableOnce + state = $CurrentState.state + minimumLifetimeInMinutes = [int]$CurrentState.minimumLifetimeInMinutes + maximumLifetimeInMinutes = [int]$CurrentState.maximumLifetimeInMinutes + defaultLifetimeInMinutes = [int]$CurrentState.defaultLifetimeInMinutes + defaultLength = [int]$CurrentState.defaultLength + isUsableOnce = [System.Convert]::ToBoolean($CurrentState.isUsableOnce) } + $ExpectedValue = @{ - state = 'enabled' - isUsableOnce = [System.Convert]::ToBoolean($config) + state = 'enabled' + minimumLifetimeInMinutes = $MinimumLifetime + maximumLifetimeInMinutes = $MaximumLifetime + defaultLifetimeInMinutes = $DefaultLifetime + defaultLength = $TAPLength + isUsableOnce = $OneTimeUseBool } + Set-CIPPStandardsCompareField -FieldName 'standards.TAP' -CurrentValue $CurrentValue -ExpectedValue $ExpectedValue -Tenant $Tenant } } diff --git a/frontend/src/data/standards.json b/frontend/src/data/standards.json index af70959830..e33dd39f82 100644 --- a/frontend/src/data/standards.json +++ b/frontend/src/data/standards.json @@ -1212,15 +1212,55 @@ "cat": "Entra (AAD) Standards", "tag": [], "appliesToTest": ["EIDSCAAT01", "EIDSCAAT02", "ZTNA21845", "ZTNA21846"], - "helpText": "Enables TAP and sets the default TAP lifetime to 1 hour. This configuration also allows you to select if a TAP is single use or multi-logon.", + "helpText": "Enable TAP with the specified configuration settings.", "docsDescription": "Enables Temporary Access Pass generation for the tenant.", - "executiveText": "Enables temporary access passes that IT administrators can generate for employees who are locked out or need emergency access to systems. These time-limited passs provide a secure way to restore access without compromising long-term security policies.", + "executiveText": "Enables temporary access passes that IT administrators can generate for employees who are locked out or need emergency access to systems. These time-limited passes provide a secure way to restore access without compromising long-term security policies.", "addedComponent": [ + { + "type": "number", + "name": "standards.TAP.MinimumLifetime", + "label": "Minimum Lifetime (minutes)", + "defaultValue": 60, + "validators": { + "min": { "value": 10, "message": "Minimum value is 10" }, + "max": { "value": 43200, "message": "Maximum value is 43200" } + } + }, + { + "type": "number", + "name": "standards.TAP.MaximumLifetime", + "label": "Maximum Lifetime (minutes)", + "defaultValue": 480, + "validators": { + "min": { "value": 10, "message": "Minimum value is 10" }, + "max": { "value": 43200, "message": "Maximum value is 43200" } + } + }, + { + "type": "number", + "name": "standards.TAP.DefaultLifetime", + "label": "Default Lifetime (minutes)", + "defaultValue": 60, + "validators": { + "min": { "value": 10, "message": "Minimum value is 10" }, + "max": { "value": 43200, "message": "Maximum value is 43200" } + } + }, + { + "type": "number", + "name": "standards.TAP.TAPLength", + "label": "Length (characters)", + "defaultValue": 8, + "validators": { + "min": { "value": 8, "message": "Minimum value is 8" }, + "max": { "value": 48, "message": "Maximum value is 48" } + } + }, { "type": "autoComplete", "multiple": false, "creatable": false, - "label": "Select TAP Lifetime", + "label": "Number of Times Usable", "name": "standards.TAP.config", "options": [ { "label": "Only Once", "value": "true" }, From 0aa537d58a64f848de26c2a35b83aaf1e81f3e12 Mon Sep 17 00:00:00 2001 From: Bobby <31723128+kris6673@users.noreply.github.com> Date: Wed, 5 Aug 2026 01:57:55 +0200 Subject: [PATCH 002/226] feat: add Purview Message Encryption tools page and standard Closes #141 --- backend/Config/standards.json | 22 ++ .../Tools/Invoke-ExecIRMConfiguration.ps1 | 54 +++++ .../Tools/Invoke-ListIRMConfiguration.ps1 | 46 ++++ .../Invoke-CIPPStandardMessageEncryption.ps1 | 110 +++++++++ .../Invoke-ListIRMConfiguration.Tests.ps1 | 168 +++++++++++++ ...ke-CIPPStandardMessageEncryption.Tests.ps1 | 166 +++++++++++++ docs/SUMMARY.md | 1 + .../tools/email-tools/message-encryption.md | 39 +++ frontend/src/data/standards.json | 22 ++ frontend/src/layouts/config.js | 5 + .../email/tools/message-encryption/index.js | 226 ++++++++++++++++++ .../pages/MessageEncryptionPage.test.jsx | 136 +++++++++++ 12 files changed, 995 insertions(+) create mode 100644 backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Tools/Invoke-ExecIRMConfiguration.ps1 create mode 100644 backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Tools/Invoke-ListIRMConfiguration.ps1 create mode 100644 backend/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardMessageEncryption.ps1 create mode 100644 backend/Tests/Endpoint/Invoke-ListIRMConfiguration.Tests.ps1 create mode 100644 backend/Tests/Standards/Invoke-CIPPStandardMessageEncryption.Tests.ps1 create mode 100644 docs/user-documentation/tools/email-tools/message-encryption.md create mode 100644 frontend/src/pages/email/tools/message-encryption/index.js create mode 100644 frontend/tests/pages/MessageEncryptionPage.test.jsx diff --git a/backend/Config/standards.json b/backend/Config/standards.json index 7190e69a0d..b4fad78c92 100644 --- a/backend/Config/standards.json +++ b/backend/Config/standards.json @@ -7588,6 +7588,28 @@ "EXCHANGE_LITE" ] }, + { + "name": "standards.MessageEncryption", + "cat": "Exchange Standards", + "tag": [], + "helpText": "Enables Microsoft Purview Message Encryption by turning on Azure RMS licensing for Exchange Online. Skipped with a warning when the tenant still points at an on-premises AD RMS cluster, because AD RMS has to be migrated to Azure RMS first. This standard only turns the feature on: branding, one-time passcodes, and social ID sign-in for encrypted messages are configured in the [Configure Encrypted Message Branding (OME)](https://standards.cipp.app/standards/omebranding) standard. [Read more](https://learn.microsoft.com/en-us/purview/set-up-new-message-encryption-capabilities)", + "docsDescription": "Sets AzureRMSLicensingEnabled to true, the only prerequisite for Microsoft Purview Message Encryption. Reports the IRM licensing state per tenant, including the licensing location, so you can see at a glance which tenants have message encryption available. Remediation is deliberately skipped for tenants with an on-premises AD RMS licensing location, as Purview Message Encryption is not compatible with AD RMS and those tenants need to be migrated to Azure RMS first.", + "executiveText": "Turns on the built-in encryption that lets staff send protected email to anyone, including recipients outside the organization. Uses licensing the organization already owns, removing the need for a separate secure-email product.", + "addedComponent": [], + "label": "Enable Purview Message Encryption", + "impact": "Low Impact", + "impactColour": "info", + "addedDate": "2026-08-04", + "powershellEquivalent": "Set-IRMConfiguration -AzureRMSLicensingEnabled $true", + "recommendedBy": [], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ] + }, { "name": "standards.OMEBranding", "cat": "Exchange Standards", diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Tools/Invoke-ExecIRMConfiguration.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Tools/Invoke-ExecIRMConfiguration.ps1 new file mode 100644 index 0000000000..bdc787731a --- /dev/null +++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Tools/Invoke-ExecIRMConfiguration.ps1 @@ -0,0 +1,54 @@ +function Invoke-ExecIRMConfiguration { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Exchange.Mailbox.ReadWrite + .DESCRIPTION + Enables or disables Microsoft Purview Message Encryption for a tenant by setting AzureRMSLicensingEnabled, or runs Test-IRMConfiguration to verify that encryption and decryption work end to end. + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers + $TenantFilter = $Request.Body.tenantFilter + $Action = $Request.Body.Action + + try { + switch ($Action) { + 'Test' { + $SenderAddress = $Request.Body.Sender + $RecipientAddress = $Request.Body.Recipient + if (!$SenderAddress -or !$RecipientAddress) { + throw 'A sender and a recipient are required to test the message encryption configuration.' + } + $TestResult = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Test-IRMConfiguration' -cmdParams @{ Sender = $SenderAddress; Recipient = $RecipientAddress } + # Test-IRMConfiguration returns one object per check, the summary lives in the Results property. + $Results = @($TestResult.Results | Where-Object { $_ }) + if (!$Results) { $Results = @($TestResult | Out-String) } + Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter -message "Tested the message encryption configuration for $SenderAddress" -Sev Info + } + 'Set' { + $AzureRMSLicensingEnabled = [System.Convert]::ToBoolean($Request.Body.AzureRMSLicensingEnabled) + $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Set-IRMConfiguration' -cmdParams @{ AzureRMSLicensingEnabled = $AzureRMSLicensingEnabled } + $Results = "Successfully $(if ($AzureRMSLicensingEnabled) { 'enabled' } else { 'disabled' }) Microsoft Purview Message Encryption." + Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter -message $Results -Sev Info + } + default { + throw "Invalid action: $Action" + } + } + $StatusCode = [HttpStatusCode]::OK + } catch { + $ErrorMessage = Get-CippException -Exception $_ + $Results = "Failed to run the '$Action' action on the message encryption configuration. Error: $($ErrorMessage.NormalizedError)" + Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter -message $Results -Sev Error -LogData $ErrorMessage + $StatusCode = [HttpStatusCode]::InternalServerError + } + + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = @{ 'Results' = $Results } + }) +} diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Tools/Invoke-ListIRMConfiguration.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Tools/Invoke-ListIRMConfiguration.ps1 new file mode 100644 index 0000000000..a1ba707615 --- /dev/null +++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Tools/Invoke-ListIRMConfiguration.ps1 @@ -0,0 +1,46 @@ +function Invoke-ListIRMConfiguration { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Exchange.Mailbox.Read + .DESCRIPTION + Lists the Information Rights Management (IRM) configuration for a tenant. Used to check whether Microsoft Purview Message Encryption is active and whether an on-premises AD RMS deployment still has to be migrated to Azure RMS first. + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $TenantFilter = $Request.Query.tenantFilter ?? $Request.Body.tenantFilter + + try { + $IRMConfig = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-IRMConfiguration' | Select-Object -First 1 + + # Purview Message Encryption is not compatible with on-premises AD RMS. A licensing location + # that is not an Azure RMS URL means the tenant still points at an AD RMS cluster and has to + # be migrated before message encryption can be enabled. + # ponytail: URL-shape heuristic, the only signal Get-IRMConfiguration gives us. Get-AipServiceConfiguration would confirm it, but that needs the AIPService module which CIPP does not ship. + $LicensingLocation = @($IRMConfig.LicensingLocation | Where-Object { $_ }) + $AdRmsDetected = @($LicensingLocation | Where-Object { $_ -notmatch 'aadrm\.|azurerms|\.microsoft\.(com|us)' }).Count -gt 0 + + $Results = [PSCustomObject]@{ + AzureRMSLicensingEnabled = $IRMConfig.AzureRMSLicensingEnabled + InternalLicensingEnabled = $IRMConfig.InternalLicensingEnabled + ExternalLicensingEnabled = $IRMConfig.ExternalLicensingEnabled + SimplifiedClientAccessEnabled = $IRMConfig.SimplifiedClientAccessEnabled + TransportDecryptionSetting = $IRMConfig.TransportDecryptionSetting + JournalReportDecryptionEnabled = $IRMConfig.JournalReportDecryptionEnabled + LicensingLocation = $LicensingLocation + MessageEncryptionEnabled = [bool]$IRMConfig.AzureRMSLicensingEnabled + AdRmsDetected = $AdRmsDetected + } + $StatusCode = [HttpStatusCode]::OK + } catch { + $Results = Get-NormalizedError -Message $_.Exception.Message + $StatusCode = [HttpStatusCode]::InternalServerError + } + + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = $Results + }) +} diff --git a/backend/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardMessageEncryption.ps1 b/backend/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardMessageEncryption.ps1 new file mode 100644 index 0000000000..78f894b722 --- /dev/null +++ b/backend/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardMessageEncryption.ps1 @@ -0,0 +1,110 @@ +function Invoke-CIPPStandardMessageEncryption { + <# + .FUNCTIONALITY + Internal + .COMPONENT + (APIName) MessageEncryption + .SYNOPSIS + (Label) Enable Purview Message Encryption + .DESCRIPTION + (Helptext) Enables Microsoft Purview Message Encryption by turning on Azure RMS licensing for Exchange Online. Skipped with a warning when the tenant still points at an on-premises AD RMS cluster, because AD RMS has to be migrated to Azure RMS first. This standard only turns the feature on: branding, one-time passcodes, and social ID sign-in for encrypted messages are configured in the [Configure Encrypted Message Branding (OME)](https://standards.cipp.app/standards/omebranding) standard. [Read more](https://learn.microsoft.com/en-us/purview/set-up-new-message-encryption-capabilities) + (DocsDescription) Sets AzureRMSLicensingEnabled to true, the only prerequisite for Microsoft Purview Message Encryption. Reports the IRM licensing state per tenant, including the licensing location, so you can see at a glance which tenants have message encryption available. Remediation is deliberately skipped for tenants with an on-premises AD RMS licensing location, as Purview Message Encryption is not compatible with AD RMS and those tenants need to be migrated to Azure RMS first. + .NOTES + CAT + Exchange Standards + TAG + EXECUTIVETEXT + Turns on the built-in encryption that lets staff send protected email to anyone, including recipients outside the organization. Uses licensing the organization already owns, removing the need for a separate secure-email product. + ADDEDCOMPONENT + IMPACT + Low Impact + ADDEDDATE + 2026-08-04 + POWERSHELLEQUIVALENT + Set-IRMConfiguration -AzureRMSLicensingEnabled \$true + RECOMMENDEDBY + REQUIREDCAPABILITIES + "EXCHANGE_S_STANDARD" + "EXCHANGE_S_ENTERPRISE" + "EXCHANGE_S_STANDARD_GOV" + "EXCHANGE_S_ENTERPRISE_GOV" + "EXCHANGE_LITE" + UPDATECOMMENTBLOCK + Run the Tools\Update-StandardsComments.ps1 script to update this comment block + .LINK + https://docs.cipp.app/user-documentation/tenant/standards/alignment/templates/available-standards + #> + + param($Tenant, $Settings) + $TestResult = Test-CIPPStandardLicense -StandardName 'MessageEncryption' -TenantFilter $Tenant -Preset Exchange + + if ($TestResult -eq $false) { + return $true + } + + try { + $CurrentState = New-ExoRequest -tenantid $Tenant -cmdlet 'Get-IRMConfiguration' | Select-Object -First 1 + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'Standards' -Tenant $Tenant -Message "Could not get the IRM configuration for $Tenant. Error: $($ErrorMessage.NormalizedError)" -Sev Error -LogData $ErrorMessage + return + } + + # Purview Message Encryption is not compatible with on-premises AD RMS. A licensing location that + # is not an Azure RMS URL means the tenant still points at an AD RMS cluster and has to be + # migrated before message encryption can be enabled. + # ponytail: URL-shape heuristic, the only signal Get-IRMConfiguration gives us. Get-AipServiceConfiguration would confirm it, but that needs the AIPService module which CIPP does not ship. + $LicensingLocation = @($CurrentState.LicensingLocation | Where-Object { $_ }) + $AdRmsDetected = @($LicensingLocation | Where-Object { $_ -notmatch 'aadrm\.|azurerms|\.microsoft\.(com|us)' }).Count -gt 0 + $StateIsCorrect = $CurrentState.AzureRMSLicensingEnabled -eq $true -and $AdRmsDetected -eq $false + + if ($Settings.remediate -eq $true) { + if ($AdRmsDetected) { + Write-LogMessage -API 'Standards' -tenant $Tenant -message "An on-premises AD RMS licensing location was found ($($LicensingLocation -join ', ')). Migrate to Azure RMS before enabling Purview Message Encryption. Skipping remediation." -sev Warning + } elseif ($StateIsCorrect) { + Write-LogMessage -API 'Standards' -tenant $Tenant -message 'Purview Message Encryption is already enabled.' -sev Info + } else { + try { + $null = New-ExoRequest -tenantid $Tenant -cmdlet 'Set-IRMConfiguration' -cmdParams @{ AzureRMSLicensingEnabled = $true } + Write-LogMessage -API 'Standards' -tenant $Tenant -message 'Enabled Purview Message Encryption.' -sev Info + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'Standards' -tenant $Tenant -message "Failed to enable Purview Message Encryption. Error: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage + } + } + } + + if ($Settings.alert -eq $true) { + if ($StateIsCorrect) { + Write-LogMessage -API 'Standards' -tenant $Tenant -message 'Purview Message Encryption is enabled.' -sev Info + } else { + $Message = if ($AdRmsDetected) { + 'Purview Message Encryption cannot be used, the tenant still uses an on-premises AD RMS licensing location.' + } else { + 'Purview Message Encryption is not enabled.' + } + $Object = [PSCustomObject]@{ + AzureRMSLicensingEnabled = $CurrentState.AzureRMSLicensingEnabled + LicensingLocation = $LicensingLocation + AdRmsDetected = $AdRmsDetected + } + Write-StandardsAlert -message $Message -object $Object -tenant $Tenant -standardName 'MessageEncryption' -standardId $Settings.standardId + Write-LogMessage -API 'Standards' -tenant $Tenant -message $Message -sev Info + } + } + + if ($Settings.report -eq $true) { + $ReportCurrent = [PSCustomObject]@{ + AzureRMSLicensingEnabled = $CurrentState.AzureRMSLicensingEnabled + LicensingLocation = $LicensingLocation + AdRmsDetected = $AdRmsDetected + } + $ReportExpected = [PSCustomObject]@{ + AzureRMSLicensingEnabled = $true + LicensingLocation = $LicensingLocation + AdRmsDetected = $false + } + Set-CIPPStandardsCompareField -FieldName 'standards.MessageEncryption' -CurrentValue $ReportCurrent -ExpectedValue $ReportExpected -TenantFilter $Tenant + Add-CIPPBPAField -FieldName 'messageEncryptionEnabled' -FieldValue $StateIsCorrect -StoreAs bool -Tenant $Tenant + } +} diff --git a/backend/Tests/Endpoint/Invoke-ListIRMConfiguration.Tests.ps1 b/backend/Tests/Endpoint/Invoke-ListIRMConfiguration.Tests.ps1 new file mode 100644 index 0000000000..2e1d19120c --- /dev/null +++ b/backend/Tests/Endpoint/Invoke-ListIRMConfiguration.Tests.ps1 @@ -0,0 +1,168 @@ +# Pester tests for Invoke-ListIRMConfiguration +# +# The load-bearing logic here is AdRmsDetected. Purview Message Encryption is not compatible with +# on-premises AD RMS, and Get-IRMConfiguration's only hint is the shape of LicensingLocation: an +# Azure RMS URL means cloud, anything else means the tenant still points at an AD RMS cluster and +# has to be migrated first. A false negative would let CIPP enable message encryption on a tenant +# where it cannot work. + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $FunctionPath = Get-ChildItem -Path (Join-Path $RepoRoot 'Modules') -Recurse -Filter 'Invoke-ListIRMConfiguration.ps1' -File -ErrorAction SilentlyContinue | + Select-Object -First 1 -ExpandProperty FullName + if (-not $FunctionPath) { throw 'Could not locate Invoke-ListIRMConfiguration.ps1 under Modules/' } + + class HttpResponseContext { + [int]$StatusCode + [object]$Body + } + + # The function uses the short [HttpStatusCode] (the Functions host supplies `using namespace + # System.Net`). Register a type accelerator so it resolves when the function is dot-sourced here. + $TypeAccelerators = [PowerShell].Assembly.GetType('System.Management.Automation.TypeAccelerators') + if (-not ([System.Management.Automation.PSTypeName]'HttpStatusCode').Type) { + $TypeAccelerators::Add('HttpStatusCode', [System.Net.HttpStatusCode]) + } + + function New-ExoRequest { param($tenantid, $cmdlet, $cmdParams) } + function Get-NormalizedError { param($Message) $Message } + + . $FunctionPath + + function New-IRMRequest { + [pscustomobject]@{ + Params = @{ CIPPEndpoint = 'ListIRMConfiguration' } + Query = @{ tenantFilter = 'contoso.com' } + Body = $null + } + } + + function New-IRMConfig { + param($LicensingLocation, $AzureRMSLicensingEnabled = $true) + [pscustomobject]@{ + AzureRMSLicensingEnabled = $AzureRMSLicensingEnabled + InternalLicensingEnabled = $true + ExternalLicensingEnabled = $false + SimplifiedClientAccessEnabled = $false + TransportDecryptionSetting = 'Optional' + JournalReportDecryptionEnabled = $true + LicensingLocation = $LicensingLocation + } + } +} + +Describe 'Invoke-ListIRMConfiguration' { + BeforeEach { + Mock -CommandName Get-NormalizedError -MockWith { $Message } + } + + Context 'AD RMS detection' { + It 'does not flag AD RMS for an Azure RMS licensing location' { + Mock -CommandName New-ExoRequest -MockWith { + New-IRMConfig -LicensingLocation @('https://5c6bb73b-1234.rms.na.aadrm.com/_wmcs/licensing') + } + + $Response = Invoke-ListIRMConfiguration -Request (New-IRMRequest) + + $Response.Body.AdRmsDetected | Should -BeFalse + $Response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::OK) + } + + It 'does not flag AD RMS when the licensing location is empty' { + Mock -CommandName New-ExoRequest -MockWith { New-IRMConfig -LicensingLocation @() } + + $Response = Invoke-ListIRMConfiguration -Request (New-IRMRequest) + + $Response.Body.AdRmsDetected | Should -BeFalse + $Response.Body.LicensingLocation | Should -BeNullOrEmpty + } + + It 'does not flag AD RMS when the licensing location is null' { + Mock -CommandName New-ExoRequest -MockWith { New-IRMConfig -LicensingLocation $null } + + $Response = Invoke-ListIRMConfiguration -Request (New-IRMRequest) + + $Response.Body.AdRmsDetected | Should -BeFalse + } + + It 'flags AD RMS for an on-premises licensing location' { + Mock -CommandName New-ExoRequest -MockWith { + New-IRMConfig -LicensingLocation @('https://rms.contoso.local/_wmcs/licensing') + } + + $Response = Invoke-ListIRMConfiguration -Request (New-IRMRequest) + + $Response.Body.AdRmsDetected | Should -BeTrue + } + + It 'flags AD RMS when an on-premises location is mixed in with the Azure RMS one' { + Mock -CommandName New-ExoRequest -MockWith { + New-IRMConfig -LicensingLocation @( + 'https://5c6bb73b-1234.rms.na.aadrm.com/_wmcs/licensing' + 'https://rms.contoso.local/_wmcs/licensing' + ) + } + + $Response = Invoke-ListIRMConfiguration -Request (New-IRMRequest) + + $Response.Body.AdRmsDetected | Should -BeTrue + $Response.Body.LicensingLocation.Count | Should -Be 2 + } + + It 'strips empty entries out of the licensing location' { + Mock -CommandName New-ExoRequest -MockWith { + New-IRMConfig -LicensingLocation @('https://5c6bb73b.rms.na.aadrm.com/_wmcs/licensing', '', $null) + } + + $Response = Invoke-ListIRMConfiguration -Request (New-IRMRequest) + + # An empty string would not match the Azure RMS pattern and would fake an AD RMS hit. + $Response.Body.AdRmsDetected | Should -BeFalse + $Response.Body.LicensingLocation.Count | Should -Be 1 + } + } + + Context 'reported state' { + It 'reports message encryption as enabled when Azure RMS licensing is on' { + Mock -CommandName New-ExoRequest -MockWith { + New-IRMConfig -LicensingLocation @() -AzureRMSLicensingEnabled $true + } + + $Response = Invoke-ListIRMConfiguration -Request (New-IRMRequest) + + $Response.Body.MessageEncryptionEnabled | Should -BeTrue + $Response.Body.TransportDecryptionSetting | Should -Be 'Optional' + } + + It 'reports message encryption as disabled when Azure RMS licensing is off' { + Mock -CommandName New-ExoRequest -MockWith { + New-IRMConfig -LicensingLocation @() -AzureRMSLicensingEnabled $false + } + + $Response = Invoke-ListIRMConfiguration -Request (New-IRMRequest) + + $Response.Body.MessageEncryptionEnabled | Should -BeFalse + } + + It 'queries Get-IRMConfiguration against the requested tenant' { + Mock -CommandName New-ExoRequest -MockWith { New-IRMConfig -LicensingLocation @() } + + $null = Invoke-ListIRMConfiguration -Request (New-IRMRequest) + + Should -Invoke New-ExoRequest -Times 1 -Exactly -ParameterFilter { + $cmdlet -eq 'Get-IRMConfiguration' -and $tenantid -eq 'contoso.com' + } + } + } + + Context 'failures' { + It 'returns an error status when the Exchange request throws' { + Mock -CommandName New-ExoRequest -MockWith { throw 'no exchange for you' } + + $Response = Invoke-ListIRMConfiguration -Request (New-IRMRequest) + + $Response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::InternalServerError) + $Response.Body | Should -Be 'no exchange for you' + } + } +} diff --git a/backend/Tests/Standards/Invoke-CIPPStandardMessageEncryption.Tests.ps1 b/backend/Tests/Standards/Invoke-CIPPStandardMessageEncryption.Tests.ps1 new file mode 100644 index 0000000000..42048c1489 --- /dev/null +++ b/backend/Tests/Standards/Invoke-CIPPStandardMessageEncryption.Tests.ps1 @@ -0,0 +1,166 @@ +# Pester tests for Invoke-CIPPStandardMessageEncryption +# +# The standard enables Purview Message Encryption by turning on AzureRMSLicensingEnabled. The one +# case it must NOT act on is a tenant still pointed at an on-premises AD RMS cluster: Purview +# Message Encryption is incompatible with AD RMS, so remediating there would silently half-configure +# a tenant that first needs a migration. That skip is what these tests pin down. + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + # Resolve by name under Modules/ so the test survives the function moving between modules. + $StandardPath = Get-ChildItem -Path (Join-Path $RepoRoot 'Modules') -Recurse -Filter 'Invoke-CIPPStandardMessageEncryption.ps1' -File -ErrorAction SilentlyContinue | + Select-Object -First 1 -ExpandProperty FullName + if (-not $StandardPath) { throw 'Could not locate Invoke-CIPPStandardMessageEncryption.ps1 under Modules/' } + + # Stubs mirror the real signatures and are advanced functions on purpose: strict parameter + # binding makes signature drift in the standard fail loudly here instead of silently landing in + # $args and leaving the captured value $null. + function Test-CIPPStandardLicense { [CmdletBinding()] param($StandardName, $TenantFilter, $RequiredCapabilities, $Preset, [switch]$SkipLog) } + function New-ExoRequest { [CmdletBinding()] param($tenantid, $cmdlet, $cmdParams) } + function Write-LogMessage { [CmdletBinding()] param($API, $tenant, $message, $sev, $LogData) } + function Write-StandardsAlert { [CmdletBinding()] param($message, $object, $tenant, $standardName, $standardId) } + function Set-CIPPStandardsCompareField { [CmdletBinding()] param($FieldName, $CurrentValue, $ExpectedValue, $TenantFilter) } + function Add-CIPPBPAField { [CmdletBinding()] param($FieldName, $FieldValue, $StoreAs, $Tenant) } + function Get-CippException { [CmdletBinding()] param($Exception) } + + . $StandardPath + + # Pester v5: anything assigned in a Describe body only exists during Discovery, so these live + # here or they are $null by the time an It runs. + $tenant = 'contoso.onmicrosoft.com' + $AzureRmsLocation = 'https://5c6bb73b-1234.rms.na.aadrm.com/_wmcs/licensing' + $AdRmsLocation = 'https://rms.contoso.local/_wmcs/licensing' + + function New-IRMConfig { + param($AzureRMSLicensingEnabled = $false, $LicensingLocation = @()) + [pscustomobject]@{ + AzureRMSLicensingEnabled = $AzureRMSLicensingEnabled + LicensingLocation = $LicensingLocation + } + } +} + +Describe 'Invoke-CIPPStandardMessageEncryption' { + BeforeEach { + Mock -CommandName Test-CIPPStandardLicense -MockWith { $true } + Mock -CommandName Write-LogMessage -MockWith { } + Mock -CommandName Write-StandardsAlert -MockWith { } + Mock -CommandName Set-CIPPStandardsCompareField -MockWith { } + Mock -CommandName Add-CIPPBPAField -MockWith { } + Mock -CommandName Get-CippException -MockWith { @{ NormalizedError = 'boom' } } + } + + Context 'licensing guard' { + It 'bails out when the tenant is not licensed' { + Mock -CommandName Test-CIPPStandardLicense -MockWith { $false } + Mock -CommandName New-ExoRequest -MockWith { New-IRMConfig } + + Invoke-CIPPStandardMessageEncryption -Tenant $tenant -Settings @{ remediate = $true } + + Should -Invoke New-ExoRequest -Times 0 -Exactly + } + } + + Context 'remediation' { + It 'enables Azure RMS licensing when message encryption is off' { + Mock -CommandName New-ExoRequest -MockWith { New-IRMConfig -AzureRMSLicensingEnabled $false } + + Invoke-CIPPStandardMessageEncryption -Tenant $tenant -Settings @{ remediate = $true } + + Should -Invoke New-ExoRequest -Times 1 -Exactly -ParameterFilter { + $cmdlet -eq 'Set-IRMConfiguration' -and $cmdParams.AzureRMSLicensingEnabled -eq $true + } + } + + It 'does nothing when message encryption is already enabled' { + Mock -CommandName New-ExoRequest -MockWith { + New-IRMConfig -AzureRMSLicensingEnabled $true -LicensingLocation @($AzureRmsLocation) + } + + Invoke-CIPPStandardMessageEncryption -Tenant $tenant -Settings @{ remediate = $true } + + Should -Invoke New-ExoRequest -Times 0 -Exactly -ParameterFilter { + $cmdlet -eq 'Set-IRMConfiguration' + } + } + + It 'refuses to remediate a tenant that still uses on-premises AD RMS' { + Mock -CommandName New-ExoRequest -MockWith { + New-IRMConfig -AzureRMSLicensingEnabled $false -LicensingLocation @($AdRmsLocation) + } + + Invoke-CIPPStandardMessageEncryption -Tenant $tenant -Settings @{ remediate = $true } + + Should -Invoke New-ExoRequest -Times 0 -Exactly -ParameterFilter { + $cmdlet -eq 'Set-IRMConfiguration' + } + Should -Invoke Write-LogMessage -Times 1 -Exactly -ParameterFilter { + $sev -eq 'Warning' -and $message -match 'AD RMS' + } + } + + It 'does not remediate when the Exchange read fails' { + Mock -CommandName New-ExoRequest -MockWith { throw 'no exchange for you' } + + Invoke-CIPPStandardMessageEncryption -Tenant $tenant -Settings @{ remediate = $true } + + Should -Invoke New-ExoRequest -Times 1 -Exactly + Should -Invoke Write-LogMessage -Times 1 -Exactly -ParameterFilter { $Sev -eq 'Error' } + } + } + + Context 'alerting' { + It 'alerts when message encryption is disabled' { + Mock -CommandName New-ExoRequest -MockWith { New-IRMConfig -AzureRMSLicensingEnabled $false } + + Invoke-CIPPStandardMessageEncryption -Tenant $tenant -Settings @{ alert = $true } + + Should -Invoke Write-StandardsAlert -Times 1 -Exactly -ParameterFilter { + $message -match 'not enabled' + } + } + + It 'alerts about the AD RMS blocker even when Azure RMS licensing is on' { + Mock -CommandName New-ExoRequest -MockWith { + New-IRMConfig -AzureRMSLicensingEnabled $true -LicensingLocation @($AdRmsLocation) + } + + Invoke-CIPPStandardMessageEncryption -Tenant $tenant -Settings @{ alert = $true } + + Should -Invoke Write-StandardsAlert -Times 1 -Exactly -ParameterFilter { + $message -match 'AD RMS' + } + } + + It 'stays quiet when the tenant is correctly configured' { + Mock -CommandName New-ExoRequest -MockWith { + New-IRMConfig -AzureRMSLicensingEnabled $true -LicensingLocation @($AzureRmsLocation) + } + + Invoke-CIPPStandardMessageEncryption -Tenant $tenant -Settings @{ alert = $true } + + Should -Invoke Write-StandardsAlert -Times 0 -Exactly + } + } + + Context 'reporting' { + It 'reports the current and expected IRM state' { + Mock -CommandName New-ExoRequest -MockWith { + New-IRMConfig -AzureRMSLicensingEnabled $false -LicensingLocation @($AdRmsLocation) + } + + Invoke-CIPPStandardMessageEncryption -Tenant $tenant -Settings @{ report = $true } + + Should -Invoke Set-CIPPStandardsCompareField -Times 1 -Exactly -ParameterFilter { + $FieldName -eq 'standards.MessageEncryption' -and + $CurrentValue.AzureRMSLicensingEnabled -eq $false -and + $CurrentValue.AdRmsDetected -eq $true -and + $ExpectedValue.AzureRMSLicensingEnabled -eq $true -and + $ExpectedValue.AdRmsDetected -eq $false + } + Should -Invoke Add-CIPPBPAField -Times 1 -Exactly -ParameterFilter { + $FieldName -eq 'messageEncryptionEnabled' -and $FieldValue -eq $false + } + } + } +} diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index aef770b52b..3577d71837 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -373,6 +373,7 @@ * [Message Trace](user-documentation/tools/email-tools/message-trace.md) * [Message Viewer](user-documentation/tools/email-tools/message-viewer.md) * [Mailbox Restores](user-documentation/tools/email-tools/mailbox-restores.md) + * [Message Encryption](user-documentation/tools/email-tools/message-encryption.md) * [Intune Tools](user-documentation/tools/intune-tools/README.md) * [Compare Policies](user-documentation/tools/intune-tools/compare-policies.md) * [Dark Web Tools](user-documentation/tools/dark-web-tools/README.md) diff --git a/docs/user-documentation/tools/email-tools/message-encryption.md b/docs/user-documentation/tools/email-tools/message-encryption.md new file mode 100644 index 0000000000..c9fe2198da --- /dev/null +++ b/docs/user-documentation/tools/email-tools/message-encryption.md @@ -0,0 +1,39 @@ +# Message Encryption + +Microsoft Purview Message Encryption lets users send protected email to any recipient, including Gmail and Outlook.com. This page shows the Information Rights Management (IRM) configuration for the selected tenant, lets you turn message encryption on, and verifies that it actually works. + +{% hint style="info" %} +The only prerequisite for Purview Message Encryption is that Azure Rights Management is active for the tenant. For most eligible plans it is activated automatically. See [Set up Message Encryption](https://learn.microsoft.com/en-us/purview/set-up-new-message-encryption-capabilities). +{% endhint %} + +## Current Configuration + +| Field | Description | +| ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------ | +| Purview Message Encryption | Whether Azure RMS licensing is enabled. This is the switch that makes message encryption available to the tenant. | +| Internal Licensing Enabled | Whether IRM features are enabled for messages sent to internal recipients. | +| External Licensing Enabled | Whether Exchange tries to acquire licenses from clusters other than the one it is configured to use. | +| Protect Button in Outlook on the Web | Whether the Protect button is shown in Outlook on the web. Defaults to disabled. | +| Transport Decryption | Whether transport decryption is Disabled, Optional, or Mandatory. | +| Journal Report Decryption | Whether a decrypted copy of a protected message is attached to the journal report. | +| Licensing Location | The RMS licensing URLs for the tenant. Used to work out whether the tenant is on Azure RMS or still on on-premises AD RMS. | + +## AD RMS migration warning + +Purview Message Encryption is **not compatible with Active Directory Rights Management Services (AD RMS)**. When the licensing location points at something other than an Azure RMS URL, the page shows a warning: that tenant is still using an on-premises AD RMS cluster and has to be [migrated to Azure RMS](https://learn.microsoft.com/en-us/azure/information-protection/migrate-from-ad-rms-to-azure-rms) before message encryption can be used. + +The warning does not block the toggle, so you can still act on a tenant you know has already been migrated. The `Enable Purview Message Encryption` standard is stricter: it skips remediation entirely for these tenants and logs a warning instead, because it runs unattended. + +## Actions + +
ActionDetails
Enable Purview Message EncryptionToggles Azure RMS licensing for the tenant, then Submit applies it. This is the only setting this page writes.
Run TestRuns a test against the tenant that acquires the RMS templates and verifies that encryption and decryption both work. Enter any mailbox in the tenant as both the sender and the recipient. The button stays disabled until both addresses are filled in.
+ +## Rolling this out across tenants + +This page configures one tenant at a time. To deploy message encryption to many tenants and keep it that way, use the **Enable Purview Message Encryption** standard under Exchange Standards. In report mode it records the licensing state per tenant, including whether AD RMS was detected, which gives you the same pre-check across the whole estate without changing anything. + +Encrypted message branding, one-time passcodes, and social ID sign-in are configured separately, through the **Configure Encrypted Message Branding (OME)** standard. + +*** + +{% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/frontend/src/data/standards.json b/frontend/src/data/standards.json index 7190e69a0d..b4fad78c92 100644 --- a/frontend/src/data/standards.json +++ b/frontend/src/data/standards.json @@ -7588,6 +7588,28 @@ "EXCHANGE_LITE" ] }, + { + "name": "standards.MessageEncryption", + "cat": "Exchange Standards", + "tag": [], + "helpText": "Enables Microsoft Purview Message Encryption by turning on Azure RMS licensing for Exchange Online. Skipped with a warning when the tenant still points at an on-premises AD RMS cluster, because AD RMS has to be migrated to Azure RMS first. This standard only turns the feature on: branding, one-time passcodes, and social ID sign-in for encrypted messages are configured in the [Configure Encrypted Message Branding (OME)](https://standards.cipp.app/standards/omebranding) standard. [Read more](https://learn.microsoft.com/en-us/purview/set-up-new-message-encryption-capabilities)", + "docsDescription": "Sets AzureRMSLicensingEnabled to true, the only prerequisite for Microsoft Purview Message Encryption. Reports the IRM licensing state per tenant, including the licensing location, so you can see at a glance which tenants have message encryption available. Remediation is deliberately skipped for tenants with an on-premises AD RMS licensing location, as Purview Message Encryption is not compatible with AD RMS and those tenants need to be migrated to Azure RMS first.", + "executiveText": "Turns on the built-in encryption that lets staff send protected email to anyone, including recipients outside the organization. Uses licensing the organization already owns, removing the need for a separate secure-email product.", + "addedComponent": [], + "label": "Enable Purview Message Encryption", + "impact": "Low Impact", + "impactColour": "info", + "addedDate": "2026-08-04", + "powershellEquivalent": "Set-IRMConfiguration -AzureRMSLicensingEnabled $true", + "recommendedBy": [], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ] + }, { "name": "standards.OMEBranding", "cat": "Exchange Standards", diff --git a/frontend/src/layouts/config.js b/frontend/src/layouts/config.js index 534d4732d6..b0f5617c8b 100644 --- a/frontend/src/layouts/config.js +++ b/frontend/src/layouts/config.js @@ -1076,6 +1076,11 @@ export const nativeMenuItems = [ path: '/email/tools/mailbox-restores', permissions: ['Exchange.Mailbox.*'], }, + { + title: 'Message Encryption', + path: '/email/tools/message-encryption', + permissions: ['Exchange.Mailbox.*'], + }, ], }, { diff --git a/frontend/src/pages/email/tools/message-encryption/index.js b/frontend/src/pages/email/tools/message-encryption/index.js new file mode 100644 index 0000000000..b32058bf0c --- /dev/null +++ b/frontend/src/pages/email/tools/message-encryption/index.js @@ -0,0 +1,226 @@ +import { useEffect } from 'react' +import { useForm } from 'react-hook-form' +import { Alert, Button, Link, Typography } from '@mui/material' +import { Grid } from '@mui/system' +import { Layout as DashboardLayout } from '../../../../layouts/index.js' +import CippFormPage from '../../../../components/CippFormPages/CippFormPage' +import CippFormComponent from '../../../../components/CippComponents/CippFormComponent' +import { CippPropertyListCard } from '../../../../components/CippCards/CippPropertyListCard' +import { CippApiResults } from '../../../../components/CippComponents/CippApiResults' +import { ApiGetCall, ApiPostCall } from '../../../../api/ApiCall' +import { useSettings } from '../../../../hooks/use-settings.js' + +const yesNo = (value) => (value ? 'Yes' : 'No') + +const Page = () => { + const tenant = useSettings().currentTenant + const queryKey = `IRMConfiguration-${tenant}` + + const formControl = useForm({ mode: 'onChange' }) + + const irmRequest = ApiGetCall({ + url: '/api/ListIRMConfiguration', + data: { tenantFilter: tenant }, + queryKey: queryKey, + }) + + const irm = irmRequest.data + + const testCall = ApiPostCall({ datafromUrl: true }) + + useEffect(() => { + // Blank everything the moment the tenant changes, before the new GET lands. Otherwise a + // switch toggled for tenant A stays dirty and Submit stays enabled, which would write A's + // pending value against B's tenantFilter, and A's test output would read as B's. + formControl.reset({ + AzureRMSLicensingEnabled: false, + Sender: '', + Recipient: '', + }) + testCall.reset() + }, [tenant]) + + useEffect(() => { + if (irmRequest.isSuccess) { + // Spread the current values so a refetch does not wipe a half-typed test address. + formControl.reset({ + ...formControl.getValues(), + AzureRMSLicensingEnabled: !!irm?.AzureRMSLicensingEnabled, + }) + } + }, [irmRequest.isSuccess, irm]) + + const [testSender, testRecipient] = formControl.watch(['Sender', 'Recipient']) + + const runTest = () => { + testCall.mutate({ + url: '/api/ExecIRMConfiguration', + data: { + tenantFilter: tenant, + Action: 'Test', + Sender: testSender?.value ?? testSender, + Recipient: testRecipient?.value ?? testRecipient, + }, + }) + } + + const propertyItems = [ + { + label: 'Purview Message Encryption', + value: irm?.AzureRMSLicensingEnabled ? 'Enabled' : 'Disabled', + }, + { + label: 'Internal Licensing Enabled', + value: yesNo(irm?.InternalLicensingEnabled), + }, + { + label: 'External Licensing Enabled', + value: yesNo(irm?.ExternalLicensingEnabled), + }, + { + label: 'Protect Button in Outlook on the Web', + value: yesNo(irm?.SimplifiedClientAccessEnabled), + }, + { + label: 'Transport Decryption', + value: irm?.TransportDecryptionSetting ?? 'Unknown', + }, + { + label: 'Journal Report Decryption', + value: yesNo(irm?.JournalReportDecryptionEnabled), + }, + { + label: 'Licensing Location', + value: irm?.LicensingLocation?.length + ? irm.LicensingLocation.join(', ') + : 'None', + }, + ] + + return ( + ({ + tenantFilter: tenant, + Action: 'Set', + AzureRMSLicensingEnabled: !!values?.AzureRMSLicensingEnabled, + })} + addedButtons={ + + } + > + + + + Microsoft Purview Message Encryption lets users send protected email + to any recipient, including Gmail and Outlook.com. The only + prerequisite is that Azure Rights Management is active for the + tenant. + + + {irmRequest.isError && ( + + + Failed to load the IRM configuration for this tenant. + + + )} + {irm?.AdRmsDetected && ( + + + This tenant has an on-premises AD RMS licensing location ({' '} + {irm.LicensingLocation.join(', ')} ). Purview Message Encryption + is not compatible with AD RMS, so the tenant has to be{' '} + + migrated to Azure RMS + {' '} + before enabling it. + + + )} + {(irm || irmRequest.isFetching) && ( + + + + )} + + + + + Test the configuration + + Runs Test-IRMConfiguration, which verifies that RMS templates can be + acquired and that encryption and decryption both work. Use any + mailbox in the tenant for both addresses. + + + + `${option.displayName} (${option.UPN})`, + valueField: 'UPN', + }} + /> + + + `${option.displayName} (${option.UPN})`, + valueField: 'UPN', + }} + /> + + + + + + + ) +} + +Page.getLayout = (page) => {page} + +export default Page diff --git a/frontend/tests/pages/MessageEncryptionPage.test.jsx b/frontend/tests/pages/MessageEncryptionPage.test.jsx new file mode 100644 index 0000000000..c378ae2bb9 --- /dev/null +++ b/frontend/tests/pages/MessageEncryptionPage.test.jsx @@ -0,0 +1,136 @@ +import React from 'react' +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { renderWithProviders } from '../test-utils' +import Page from '../../src/pages/email/tools/message-encryption/index.js' + +vi.mock('../../src/api/ApiCall', async () => + (await import('../mocks/api-call')).apiCallMock() +) +import { api, getResult, paginatedResult, postResult } from '../mocks/api-call' + +const AZURE_RMS = 'https://5c6bb73b-1234.rms.na.aadrm.com/_wmcs/licensing' +const AD_RMS = 'https://rms.contoso.local/_wmcs/licensing' + +// stable identity per the mock's own warning: a fresh literal per call spins the +// effects that key off the data object +const irmConfig = (overrides = {}) => ({ + AzureRMSLicensingEnabled: true, + InternalLicensingEnabled: true, + ExternalLicensingEnabled: false, + SimplifiedClientAccessEnabled: false, + TransportDecryptionSetting: 'Optional', + JournalReportDecryptionEnabled: true, + LicensingLocation: [AZURE_RMS], + MessageEncryptionEnabled: true, + AdRmsDetected: false, + ...overrides, +}) + +describe('Message Encryption page', () => { + beforeEach(() => { + vi.clearAllMocks() + api.post = postResult() + api.paginated = paginatedResult([ + { displayName: 'Admin', UPN: 'admin@contoso.com' }, + { displayName: 'Helpdesk', UPN: 'helpdesk@contoso.com' }, + ]) + }) + + it('renders the current IRM state for the tenant', async () => { + api.get = getResult({ data: irmConfig() }) + renderWithProviders() + + expect(await screen.findByText('Current Configuration')).toBeInTheDocument() + expect(screen.getByText('Enabled')).toBeInTheDocument() + expect(screen.getByText(AZURE_RMS)).toBeInTheDocument() + }) + + it('hides the migration warning for a cloud-only tenant', async () => { + api.get = getResult({ data: irmConfig() }) + renderWithProviders() + + await screen.findByText('Current Configuration') + expect(screen.queryByText(/not compatible with/i)).not.toBeInTheDocument() + }) + + it('warns that AD RMS has to be migrated before message encryption can be used', async () => { + api.get = getResult({ + data: irmConfig({ + AzureRMSLicensingEnabled: false, + MessageEncryptionEnabled: false, + LicensingLocation: [AD_RMS], + AdRmsDetected: true, + }), + }) + renderWithProviders() + + expect(await screen.findByText(/not compatible with/i)).toBeInTheDocument() + expect( + screen.getByRole('link', { name: 'migrated to Azure RMS' }) + ).toBeInTheDocument() + }) + + it('keeps Run Test disabled until both mailboxes are selected', async () => { + const user = userEvent.setup() + api.get = getResult({ data: irmConfig() }) + renderWithProviders() + + const runTest = await screen.findByRole('button', { name: 'Run Test' }) + expect(runTest).toBeDisabled() + + await user.click(screen.getByRole('combobox', { name: 'Sender' })) + await user.click( + await screen.findByRole('option', { name: 'Admin (admin@contoso.com)' }) + ) + expect(runTest).toBeDisabled() + + await user.click(screen.getByRole('combobox', { name: 'Recipient' })) + await user.click( + await screen.findByRole('option', { + name: 'Helpdesk (helpdesk@contoso.com)', + }) + ) + expect(runTest).toBeEnabled() + }) + + it('posts the Test action with the entered addresses', async () => { + const user = userEvent.setup() + api.get = getResult({ data: irmConfig() }) + renderWithProviders() + + await user.click(await screen.findByRole('combobox', { name: 'Sender' })) + await user.click( + await screen.findByRole('option', { name: 'Admin (admin@contoso.com)' }) + ) + await user.click(screen.getByRole('combobox', { name: 'Recipient' })) + await user.click( + await screen.findByRole('option', { + name: 'Helpdesk (helpdesk@contoso.com)', + }) + ) + await user.click(screen.getByRole('button', { name: 'Run Test' })) + + expect(api.post.mutate).toHaveBeenCalledWith({ + url: '/api/ExecIRMConfiguration', + data: { + tenantFilter: 'testdomain.com', + Action: 'Test', + Sender: 'admin@contoso.com', + Recipient: 'helpdesk@contoso.com', + }, + }) + }) + + it('surfaces a load failure', async () => { + api.get = getResult({ isSuccess: false, isError: true, data: undefined }) + renderWithProviders() + + expect( + await screen.findByText(/Failed to load the IRM configuration/i) + ).toBeInTheDocument() + // no card, otherwise every undefined field renders as a confident "Disabled"/"No" + expect(screen.queryByText('Current Configuration')).not.toBeInTheDocument() + }) +}) From b261c1e9e8bd0fc18c9a6b7484b2acb1dcbd05de Mon Sep 17 00:00:00 2001 From: John Duprey Date: Sun, 9 Aug 2026 01:57:11 -0400 Subject: [PATCH 003/226] feat(mobile): add mobile-first responsive UI Introduces a comprehensive mobile-responsive overhaul across the CIPP frontend: - Card list view for tables on mobile (CippMobileCardList) with load-more, select mode, and row action sheets - Mobile table controls (sort/filter/bulk sheets) replacing the desktop toolbar - CippBottomSheet primitive replacing desktop Menus below md - CippMobileTenantPicker fullscreen dialog replacing the 400px Autocomplete in the top bar - CippPageActionsFab replaces CippSpeedDial on mobile; hosts cardButton actions and tabbed-layout views in one sheet - Tab navigation context so a page FAB adopts the tab bar instead of a second FAB appearing - Mobile nav: search, SwipeableDrawer, nav-search filter, logo relocated to drawer - Universal search: joined control row, anchor fix, icon-only search button on mobile, fullscreen dialog - Breadcrumb collapse, maintenance banner clamp, and numerous grid/layout fixes for narrow viewports - Settings preference to force card/table mode; `useTableViewMode` and `useBreakpoint` hooks - Extracted `getFilteredPortals`, `help-links`, `render-url-value` utilities - Fixed filter restore loop, filter/search-box sync, and nested-menu anchor positioning - New unit and Storybook tests covering all new components and hooks --- .../CippCards/CippBannerListCard.jsx | 22 +- .../CippCards/CippUniversalSearchV2.jsx | 57 ++- .../components/CippCards/CippUserInfoCard.jsx | 10 +- .../CippAddTestReportDrawer.jsx | 66 +-- .../CippComponents/CippBottomSheet.jsx | 73 +++ .../CippComponents/CippBreadcrumbNav.jsx | 32 +- .../CippComponents/CippMaintenanceBanner.jsx | 67 ++- .../CippComponents/CippMobileTenantPicker.jsx | 271 +++++++++++ .../CippComponents/CippOffCanvas.jsx | 114 +++-- .../CippComponents/CippPageActionsFab.jsx | 156 +++++++ .../CippComponents/CippReportToolbar.jsx | 337 ++++++++++---- .../CippComponents/CippSpeedDial.jsx | 8 + .../CippTabNavigationSection.jsx | 57 +++ .../CippComponents/CippTableDialog.jsx | 12 +- .../CippComponents/CippTablePage.jsx | 77 ++-- .../CippComponents/CippTenantSelector.jsx | 4 +- .../CippExchangeSettingsForm.jsx | 4 +- .../CippTable/CIPPTableToptoolbar.js | 283 ++++++++---- .../src/components/CippTable/CippDataTable.js | 267 ++++++++--- .../CippTable/CippDataTableButton.jsx | 23 +- .../CippTable/CippGraphExplorerFilter.js | 15 +- .../CippTable/CippMobileCardList.jsx | 428 ++++++++++++++++++ .../CippTable/CippMobileTableControls.jsx | 427 +++++++++++++++++ .../CippTable/util-mobile-card-slots.js | 141 ++++++ .../components/CippTable/util-tablemode.js | 32 +- frontend/src/components/actions-menu.js | 77 +--- frontend/src/contexts/settings-context.js | 3 + frontend/src/hooks/use-actions-dispatch.jsx | 47 ++ frontend/src/hooks/use-breakpoint.js | 39 ++ frontend/src/layouts/HeaderedTabbedLayout.jsx | 238 ++++++---- frontend/src/layouts/TabbedLayout.jsx | 122 +++-- frontend/src/layouts/account-popover.js | 66 ++- frontend/src/layouts/constants.js | 3 +- frontend/src/layouts/index.js | 25 +- frontend/src/layouts/mobile-nav.js | 122 +++-- .../src/layouts/tab-navigation-context.js | 76 ++++ frontend/src/layouts/top-nav.js | 94 ++-- frontend/src/pages/_app.js | 67 +-- frontend/src/pages/cipp/preferences.js | 19 + frontend/src/pages/dashboardv2/index.js | 119 +++-- .../endpoint/MEM/devices/device/index.jsx | 4 +- .../administration/groups/group/index.jsx | 4 +- .../administration/users/user/bec.jsx | 8 +- .../administration/users/user/exchange.jsx | 6 +- .../administration/users/user/index.jsx | 12 +- .../applications/app-registration/index.jsx | 4 +- .../applications/enterprise-app/index.jsx | 4 +- frontend/src/theme/base/create-components.js | 8 + frontend/src/utils/get-cipp-formatting.js | 30 +- frontend/src/utils/get-filtered-portals.js | 37 ++ frontend/src/utils/help-links.js | 41 ++ frontend/src/utils/render-url-value.jsx | 58 +++ .../CippBottomSheet.stories.jsx | 132 ++++++ .../CippMobileTenantPicker.stories.jsx | 93 ++++ .../CippPageActionsFab.stories.jsx | 220 +++++++++ .../CippPageActionsFab.test.jsx | 108 +++++ .../CippReportToolbar.stories.jsx | 99 ++++ .../CippComponents/CippReportToolbar.test.jsx | 207 +++++++++ .../CippTable/CIPPTableToptoolbar.test.jsx | 90 ++++ .../CippTable/CippDataTable.test.jsx | 116 +++++ .../CippGraphExplorerFilter.test.jsx | 44 ++ .../CippTable/CippMobileCardList.stories.jsx | 244 ++++++++++ .../CippTable/util-mobile-card-slots.test.js | 142 ++++++ frontend/tests/hooks/use-breakpoint.test.jsx | 43 ++ frontend/tests/layouts/TabbedLayout.test.jsx | 165 +++++++ .../tests/utils/get-filtered-portals.test.js | 49 ++ 66 files changed, 5310 insertions(+), 758 deletions(-) create mode 100644 frontend/src/components/CippComponents/CippBottomSheet.jsx create mode 100644 frontend/src/components/CippComponents/CippMobileTenantPicker.jsx create mode 100644 frontend/src/components/CippComponents/CippPageActionsFab.jsx create mode 100644 frontend/src/components/CippComponents/CippTabNavigationSection.jsx create mode 100644 frontend/src/components/CippTable/CippMobileCardList.jsx create mode 100644 frontend/src/components/CippTable/CippMobileTableControls.jsx create mode 100644 frontend/src/components/CippTable/util-mobile-card-slots.js create mode 100644 frontend/src/hooks/use-actions-dispatch.jsx create mode 100644 frontend/src/hooks/use-breakpoint.js create mode 100644 frontend/src/layouts/tab-navigation-context.js create mode 100644 frontend/src/utils/get-filtered-portals.js create mode 100644 frontend/src/utils/help-links.js create mode 100644 frontend/src/utils/render-url-value.jsx create mode 100644 frontend/tests/components/CippComponents/CippBottomSheet.stories.jsx create mode 100644 frontend/tests/components/CippComponents/CippMobileTenantPicker.stories.jsx create mode 100644 frontend/tests/components/CippComponents/CippPageActionsFab.stories.jsx create mode 100644 frontend/tests/components/CippComponents/CippPageActionsFab.test.jsx create mode 100644 frontend/tests/components/CippComponents/CippReportToolbar.stories.jsx create mode 100644 frontend/tests/components/CippComponents/CippReportToolbar.test.jsx create mode 100644 frontend/tests/components/CippTable/CippMobileCardList.stories.jsx create mode 100644 frontend/tests/components/CippTable/util-mobile-card-slots.test.js create mode 100644 frontend/tests/hooks/use-breakpoint.test.jsx create mode 100644 frontend/tests/layouts/TabbedLayout.test.jsx create mode 100644 frontend/tests/utils/get-filtered-portals.test.js diff --git a/frontend/src/components/CippCards/CippBannerListCard.jsx b/frontend/src/components/CippCards/CippBannerListCard.jsx index 55f5c2ecba..6f2519c719 100644 --- a/frontend/src/components/CippCards/CippBannerListCard.jsx +++ b/frontend/src/components/CippCards/CippBannerListCard.jsx @@ -95,8 +95,14 @@ export const CippBannerListCard = (props) => { { direction="row" spacing={2} alignItems="center" - sx={{ flex: 1, minWidth: 0 }} + sx={{ flex: { xs: "1 1 100%", md: "1 1 auto" }, minWidth: 0 }} > {onSelectionChange && ( { {/* Main Text and Subtext */} - + { {/* Right Side: Status and Expand Icon */} - + {item?.statusText && ( { - if (textFieldRef.current) { - const rect = textFieldRef.current.getBoundingClientRect(); + // Anchored to the whole joined control, not the field inset within it: results then + // span the full search width instead of starting past the scope button, which is + // what was clipping every email, UPN and route path. + const anchor = containerRef.current ?? textFieldRef.current; + if (anchor) { + const rect = anchor.getBoundingClientRect(); const availableHeight = Math.max(220, window.innerHeight - rect.bottom - 16); setDropdownPosition({ top: rect.bottom + window.scrollY + 4, @@ -494,7 +500,8 @@ export const CippUniversalSearchV2 = React.forwardRef( window.removeEventListener("resize", handleResize); }; } - }, [showDropdown]); + // isMobile changes the search button's width, so the anchor's box changes with it + }, [showDropdown, isMobile]); useEffect(() => { setHighlightedIndex(-1); @@ -572,7 +579,34 @@ export const CippUniversalSearchV2 = React.forwardRef( return ( <> - + {/* One joined control: the scope button, the field and the search button share a + single bordered row, so they line up by construction and the results panel can + anchor to the whole row rather than to the field inset within it. */} + *": { flexShrink: 0 }, + // Collapse the doubled borders where the controls meet + "& > * + *": { ml: "-1px" }, + "& .MuiButton-root": { borderRadius: 0, whiteSpace: "nowrap" }, + "& > :first-of-type .MuiButton-root, & > :first-of-type": { + borderTopLeftRadius: (theme) => theme.shape.borderRadius, + borderBottomLeftRadius: (theme) => theme.shape.borderRadius, + }, + "& > :last-child .MuiButton-root, & > :last-child": { + borderTopRightRadius: (theme) => theme.shape.borderRadius, + borderBottomRightRadius: (theme) => theme.shape.borderRadius, + }, + "& .MuiOutlinedInput-root": { borderRadius: 0, height: "100%" }, + // The field is the only part that should absorb the leftover width + "& > .MuiFormControl-root": { flex: "1 1 auto", minWidth: 0 }, + // Keep the focused field's outline on top of the adjacent borders + "& .MuiOutlinedInput-root.Mui-focused": { zIndex: 1 }, + }} + > } - sx={{ flexShrink: 0 }} + startIcon={isMobile ? undefined : } + aria-label="Search" + // Icon-only on phones: the label costs the field width it needs more + sx={{ flexShrink: 0, minWidth: isMobile ? 48 : undefined, px: isMobile ? 0 : undefined }} > - Search + {isMobile ? : "Search"} )} @@ -646,7 +682,12 @@ export const CippUniversalSearchV2 = React.forwardRef( left: `${dropdownPosition.left}px`, width: `${dropdownPosition.width}px`, maxHeight: `${dropdownMaxHeight}px`, - overflow: "auto", + overflowY: "auto", + // Emails, UPNs and route paths are single unbreakable tokens: whiteSpace + // normal can't wrap them, so without this they widen the panel and the + // result text runs off the right edge. + overflowX: "hidden", + overflowWrap: "anywhere", zIndex: 9999, boxShadow: 3, border: "1px solid", diff --git a/frontend/src/components/CippCards/CippUserInfoCard.jsx b/frontend/src/components/CippCards/CippUserInfoCard.jsx index 6b18617f12..54cf5f42aa 100644 --- a/frontend/src/components/CippCards/CippUserInfoCard.jsx +++ b/frontend/src/components/CippCards/CippUserInfoCard.jsx @@ -137,8 +137,10 @@ export const CippUserInfoCard = (props) => { ) : ( - {/* Avatar section */} - + {/* Avatar section — "auto" rather than a full row on xs: the picture is a + fixed 64px, so collapsing it to its own line only pushes the identity + fields down without making the picture any bigger. */} + { - {/* Status information section */} - + {/* Status information section — grows into whatever the avatar leaves */} + diff --git a/frontend/src/components/CippComponents/CippAddTestReportDrawer.jsx b/frontend/src/components/CippComponents/CippAddTestReportDrawer.jsx index fed306b2b1..4de49cf6ee 100644 --- a/frontend/src/components/CippComponents/CippAddTestReportDrawer.jsx +++ b/frontend/src/components/CippComponents/CippAddTestReportDrawer.jsx @@ -25,11 +25,18 @@ export const CippAddTestReportDrawer = ({ mode = 'create', reportToEdit = null, disabled = false, + open, + onClose, + hideTrigger = false, }) => { const [drawerVisible, setDrawerVisible] = useState(false) const [activeTab, setActiveTab] = useState(0) const [searchTerm, setSearchTerm] = useState('') const isEditMode = mode === 'edit' + // Controlled mode: the parent owns open/close (mobile sheet rows trigger the drawer + // without rendering its button). Uncontrolled keeps the original self-contained shape. + const isControlled = open !== undefined + const visible = isControlled ? open : drawerVisible const formControl = useForm({ mode: 'onChange', @@ -81,7 +88,7 @@ export const CippAddTestReportDrawer = ({ }, [createReport.isSuccess, formControl, isEditMode]) useEffect(() => { - if (drawerVisible && isEditMode && reportToEdit) { + if (visible && isEditMode && reportToEdit) { formControl.reset({ name: reportToEdit.name || '', description: reportToEdit.description || '', @@ -90,7 +97,7 @@ export const CippAddTestReportDrawer = ({ CustomTests: reportToEdit.CustomTests || [], }) } - }, [drawerVisible, isEditMode, reportToEdit, formControl]) + }, [visible, isEditMode, reportToEdit, formControl]) const handleSubmit = () => { formControl.trigger() @@ -117,7 +124,10 @@ export const CippAddTestReportDrawer = ({ const handleCloseDrawer = () => { createReport.reset() - setDrawerVisible(false) + if (!isControlled) { + setDrawerVisible(false) + } + onClose?.() setSearchTerm('') setActiveTab(0) formControl.reset({ @@ -179,39 +189,41 @@ export const CippAddTestReportDrawer = ({ return ( <> - + + {buttonText} + + + )} { + const { open, onClose, title, children, footer, ...other } = props; + return ( + theme.zIndex.modal + 1 }} + PaperProps={{ + sx: { + borderTopLeftRadius: 14, + borderTopRightRadius: 14, + maxHeight: "85dvh", + display: "flex", + flexDirection: "column", + }, + }} + {...other} + > + + {title && ( + + {title} + + )} + + {children} + + {footer && ( + + {footer} + + )} + + ); +}; diff --git a/frontend/src/components/CippComponents/CippBreadcrumbNav.jsx b/frontend/src/components/CippComponents/CippBreadcrumbNav.jsx index 2e42c4a904..18c23806a9 100644 --- a/frontend/src/components/CippComponents/CippBreadcrumbNav.jsx +++ b/frontend/src/components/CippComponents/CippBreadcrumbNav.jsx @@ -1,6 +1,6 @@ import { useEffect, useState, useRef } from 'react' import { useRouter } from 'next/router' -import { Breadcrumbs, Link, Typography, Box, IconButton, Tooltip } from '@mui/material' +import { Breadcrumbs, Link, Typography, Box, IconButton, Tooltip, useMediaQuery } from '@mui/material' import { History, AccountTree } from '@mui/icons-material' import { nativeMenuItems } from '../../layouts/config' import { useSettings } from '../../hooks/use-settings' @@ -39,6 +39,9 @@ const loadTabOptions = () => { export const CippBreadcrumbNav = () => { const router = useRouter() const settings = useSettings() + // Phones get one line: leading crumbs collapse behind MUI's ellipsis button instead of + // the trail wrapping to two rows of chrome above every table. + const mdDown = useMediaQuery((theme) => theme.breakpoints.down('md')) const [history, setHistory] = useState([]) const [mode, setMode] = useState(settings.breadcrumbMode || 'hierarchical') const [tabOptions] = useState(loadTabOptions) @@ -627,6 +630,9 @@ export const CippBreadcrumbNav = () => { { minWidth: 0, userSelect: 'text', '& .MuiBreadcrumbs-separator': { userSelect: 'text' }, + ...(mdDown && { + '& .MuiBreadcrumbs-ol': { flexWrap: 'nowrap' }, + '& .MuiBreadcrumbs-li': { minWidth: 0 }, + '& .MuiBreadcrumbs-li > *': { + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', + display: 'block', + }, + }), }} > {breadcrumbs.map((crumb, index) => { @@ -703,7 +719,9 @@ export const CippBreadcrumbNav = () => { } })} - {bookmarkStar} + {/* Mobile: star pinned to the right edge — a stable tap target instead of trailing + the crumb text. Desktop keeps it directly after the last crumb. */} + {bookmarkStar} ) } @@ -728,7 +746,9 @@ export const CippBreadcrumbNav = () => { { minWidth: 0, userSelect: 'text', '& .MuiBreadcrumbs-separator': { userSelect: 'text' }, + ...(mdDown && { + '& .MuiBreadcrumbs-ol': { flexWrap: 'nowrap' }, + '& .MuiBreadcrumbs-li': { minWidth: 0 }, + }), }} > {visibleHistory.map((page, index) => { @@ -786,7 +810,7 @@ export const CippBreadcrumbNav = () => { ) })} - {bookmarkStar} + {bookmarkStar} ) } diff --git a/frontend/src/components/CippComponents/CippMaintenanceBanner.jsx b/frontend/src/components/CippComponents/CippMaintenanceBanner.jsx index a5f91d20c9..9e540bfd6b 100644 --- a/frontend/src/components/CippComponents/CippMaintenanceBanner.jsx +++ b/frontend/src/components/CippComponents/CippMaintenanceBanner.jsx @@ -1,6 +1,6 @@ -import { useCallback, useEffect, useMemo, useRef, useSyncExternalStore } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react' import NextLink from 'next/link' -import { Box, Button, Chip, IconButton, Stack, Typography } from '@mui/material' +import { Box, Button, Chip, IconButton, Link, Stack, Typography, useMediaQuery } from '@mui/material' import { alpha, useTheme } from '@mui/material/styles' import { Close, ErrorOutline, InfoOutlined, WarningAmber } from '@mui/icons-material' import { formatDistanceStrict } from 'date-fns' @@ -85,6 +85,41 @@ const buildWindowText = (start, end, active, now) => { export const CippMaintenanceBanner = ({ alert }) => { const theme = useTheme() const rootRef = useRef(null) + const messageRef = useRef(null) + // On phones a long notice pushes the whole chrome down by its height — clamp the message + // to two lines with a Read more toggle there. Desktop keeps the full inline message. + const mdDown = useMediaQuery(theme.breakpoints.down('md')) + const [messageExpanded, setMessageExpanded] = useState(false) + const [messageClamped, setMessageClamped] = useState(false) + const clampActive = mdDown && !messageExpanded + + // Measured off a frame rather than synchronously in the effect: the clamped height isn't + // final until the browser has laid the text out, and a synchronous setState here would + // cascade a second render on every pass. + useEffect(() => { + const element = messageRef.current + if (!mdDown || !element) { + const frame = requestAnimationFrame(() => setMessageClamped(false)) + return () => cancelAnimationFrame(frame) + } + + const measure = () => + setMessageClamped( + // Expanded text no longer overflows — keep the toggle so it can collapse again. + messageExpanded || element.scrollHeight > element.clientHeight + 1 + ) + + const frame = requestAnimationFrame(measure) + if (typeof ResizeObserver === 'undefined') { + return () => cancelAnimationFrame(frame) + } + const observer = new ResizeObserver(measure) + observer.observe(element) + return () => { + cancelAnimationFrame(frame) + observer.disconnect() + } + }, [mdDown, messageExpanded, alert?.Alert]) const noticeId = alert?.noticeId const dismissible = alert?.dismissible !== false @@ -219,9 +254,33 @@ export const CippMaintenanceBanner = ({ alert }) => { )} - + {alert.Alert} + {messageClamped && ( + setMessageExpanded((prev) => !prev)} + sx={{ color: 'inherit', fontWeight: 600, textDecorationColor: 'currentColor' }} + > + {messageExpanded ? 'Show less' : 'Read more'} + + )} {windowText && ( { color: 'text.primary', opacity: solid ? 0.85 : 0.62, fontVariantNumeric: 'tabular-nums', - whiteSpace: 'nowrap', + whiteSpace: { xs: 'normal', md: 'nowrap' }, }} > {windowText} diff --git a/frontend/src/components/CippComponents/CippMobileTenantPicker.jsx b/frontend/src/components/CippComponents/CippMobileTenantPicker.jsx new file mode 100644 index 0000000000..2679413034 --- /dev/null +++ b/frontend/src/components/CippComponents/CippMobileTenantPicker.jsx @@ -0,0 +1,271 @@ +import { useMemo, useState } from "react"; +import { + Avatar, + Box, + ButtonBase, + Chip, + Dialog, + IconButton, + InputAdornment, + List, + ListItemButton, + ListItemText, + ListSubheader, + OutlinedInput, + Typography, +} from "@mui/material"; +import { Close, KeyboardArrowDown, Public, Search, Star, StarBorder } from "@mui/icons-material"; +import { useRouter } from "next/router"; +import { useQueryClient } from "@tanstack/react-query"; +import { ApiGetCall } from "../../api/ApiCall"; +import { useSettings } from "../../hooks/use-settings"; +import { useTenantPreferences } from "../../hooks/use-tenant-preferences"; + +// Mobile replacement for the 400px CippTenantSelector Autocomplete: a top-bar chip opening +// a fullscreen picker (the CippApiDialog fullscreen-on-mobile precedent). Shares the +// "TenantSelector" query cache and the same favourites/recent preference store. Selection +// writes settings + the tenantFilter URL param directly — the desktop selector (which +// normally owns that sync) is not mounted on mobile. +export const CippMobileTenantPicker = () => { + const [open, setOpen] = useState(false); + const [search, setSearch] = useState(""); + const router = useRouter(); + const settings = useSettings(); + const queryClient = useQueryClient(); + const { recent, favorites, trackRecent, toggleFavorite, isFavorite } = useTenantPreferences(); + + const tenantList = ApiGetCall({ + url: "/api/listTenants", + data: { AllTenantSelector: true }, + queryKey: "TenantSelector", + refetchOnMount: false, + refetchOnReconnect: false, + keepPreviousData: true, + }); + + const currentTenant = router.query.tenantFilter ?? settings.currentTenant; + + const tenants = useMemo( + () => (tenantList.isSuccess && Array.isArray(tenantList.data) ? tenantList.data : []), + [tenantList.isSuccess, tenantList.data] + ); + + const currentDisplayName = useMemo(() => { + if (currentTenant === "AllTenants") return "All Tenants"; + const match = tenants.find((t) => t.defaultDomainName === currentTenant); + return match?.displayName ?? currentTenant ?? "Select tenant"; + }, [tenants, currentTenant]); + + const groups = useMemo(() => { + const selectable = tenants.filter((t) => t.defaultDomainName !== "AllTenants"); + const query = search.trim().toLowerCase(); + const matches = query + ? selectable.filter( + (t) => + t.displayName?.toLowerCase().includes(query) || + t.defaultDomainName?.toLowerCase().includes(query) + ) + : selectable; + + const favoriteValues = new Set(favorites.map((f) => f.value)); + const recentValues = recent.map((r) => r.value).filter((v) => !favoriteValues.has(v)); + const recentSet = new Set(recentValues); + const byValue = new Map(matches.map((t) => [t.defaultDomainName, t])); + + return { + favorites: favorites.map((f) => byValue.get(f.value)).filter(Boolean), + recent: recentValues.map((v) => byValue.get(v)).filter(Boolean), + all: matches + .filter((t) => !favoriteValues.has(t.defaultDomainName) && !recentSet.has(t.defaultDomainName)) + .slice() + .sort((a, b) => (a.displayName ?? "").localeCompare(b.displayName ?? "")), + }; + }, [tenants, favorites, recent, search]); + + const selectTenant = (value, tenant) => { + // Same contract as the desktop selector's URL watcher: cancel in-flight queries, + // update settings, and normalize the tenantFilter URL param. + queryClient.cancelQueries(); + if (tenant) { + trackRecent({ + value: tenant.defaultDomainName, + label: `${tenant.displayName} (${tenant.defaultDomainName})`, + addedFields: { + defaultDomainName: tenant.defaultDomainName, + displayName: tenant.displayName, + customerId: tenant.customerId, + initialDomainName: tenant.initialDomainName, + }, + }); + } + settings.handleUpdate({ currentTenant: value }); + router.replace( + { + pathname: router.pathname, + query: { ...router.query, tenantFilter: value }, + }, + undefined, + { shallow: true } + ); + setOpen(false); + setSearch(""); + }; + + const renderTenantRow = (tenant) => { + const value = tenant.defaultDomainName; + const favorited = isFavorite(value); + const isCurrent = value === currentTenant; + return ( + selectTenant(value, tenant)} + sx={{ minHeight: 52, gap: 1.5 }} + > + + {(tenant.displayName ?? "?").charAt(0).toUpperCase()} + + + {isCurrent && ( + + )} + { + event.stopPropagation(); + toggleFavorite({ + value, + label: `${tenant.displayName} (${value})`, + }); + }} + sx={{ + color: favorited ? "warning.main" : "action.active", + flexShrink: 0, + minWidth: 44, + minHeight: 44, + }} + > + {favorited ? : } + + + ); + }; + + return ( + <> + setOpen(true)} + aria-label="Select tenant" + sx={{ + flex: 1, + minWidth: 0, + height: 40, + px: 1.25, + borderRadius: 1, + display: "flex", + alignItems: "center", + gap: 0.75, + justifyContent: "flex-start", + bgcolor: "rgba(255,255,255,.08)", + color: "common.white", + }} + > + {currentTenant === "AllTenants" && } + + {currentDisplayName} + + {/* Pinned to the chip's right edge so it reads as the control's affordance rather + than punctuation trailing whatever the tenant happens to be called */} + + + + setOpen(false)}> + + setOpen(false)} aria-label="Close" sx={{ minWidth: 44, minHeight: 44 }}> + + + Select tenant + + + setSearch(event.target.value)} + inputProps={{ enterKeyHint: "search", "aria-label": "Search tenants" }} + startAdornment={ + + + + } + sx={{ minHeight: 44 }} + /> + + + + {!search && ( + selectTenant("AllTenants")} + sx={{ minHeight: 52, gap: 1.5 }} + > + + + + + {currentTenant === "AllTenants" && ( + + )} + + )} + {groups.favorites.length > 0 && ( + <> + Favorites + {groups.favorites.map(renderTenantRow)} + + )} + {groups.recent.length > 0 && ( + <> + Recent + {groups.recent.map(renderTenantRow)} + + )} + All tenants + {tenantList.isFetching && groups.all.length === 0 && ( + + Loading tenants… + + )} + {groups.all.map(renderTenantRow)} + {!tenantList.isFetching && + search && + groups.all.length + groups.favorites.length + groups.recent.length === 0 && ( + + No tenants match “{search}”. + + )} + + + + + ); +}; diff --git a/frontend/src/components/CippComponents/CippOffCanvas.jsx b/frontend/src/components/CippComponents/CippOffCanvas.jsx index abbe5aa682..e15568e087 100644 --- a/frontend/src/components/CippComponents/CippOffCanvas.jsx +++ b/frontend/src/components/CippComponents/CippOffCanvas.jsx @@ -1,11 +1,13 @@ -import { Drawer, Box, IconButton, Typography, Divider } from "@mui/material"; +import { Drawer, Box, Button, IconButton, Typography, Divider } from "@mui/material"; import { CippPropertyListCard } from "../CippCards/CippPropertyListCard"; import { getCippTranslation } from "../../utils/get-cipp-translation"; import { getCippFormatting } from "../../utils/get-cipp-formatting"; import { useMediaQuery, Grid } from "@mui/system"; import CloseIcon from "@mui/icons-material/Close"; +import ArrowBackIosNewIcon from "@mui/icons-material/ArrowBackIosNew"; import KeyboardArrowUpIcon from "@mui/icons-material/KeyboardArrowUp"; import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown"; +import { renderUrlValue } from "../../utils/render-url-value"; export const CippOffCanvas = (props) => { const { @@ -23,18 +25,30 @@ export const CippOffCanvas = (props) => { onNavigateDown, canNavigateUp = false, canNavigateDown = false, + navigationPosition, contentPadding = 2, keepMounted = false, + richFormatting = false, } = props; const mdDown = useMediaQuery((theme) => theme.breakpoints.down("md")); + // Pages that hand-pick extendedInfoFields expect the flat text rendering. richFormatting + // asks for the same nodes the table cells use — copy chips, links, status icons — which + // is what the card view's generated fallback needs, since its fields ARE table columns. + const formatField = (value, field, isArray) => { + if (!richFormatting) { + return getCippFormatting(value, field, isArray ? "array" : "text", "both"); + } + return renderUrlValue(value, field) ?? getCippFormatting(value, field, undefined, "both"); + }; + const extendedInfo = extendedInfoFields.map((field) => { const value = field.split(".").reduce((acc, part) => acc && acc[part], extendedData); if (value === undefined || value === null) { if (extendedData?.[field] !== undefined && extendedData?.[field] !== null) { return { label: getCippTranslation(field), - value: getCippFormatting(extendedData[field], field, "text", "both"), + value: formatField(extendedData[field], field, false), }; } else { return { @@ -45,35 +59,22 @@ export const CippOffCanvas = (props) => { } else if (Array.isArray(value)) { return { label: getCippTranslation(field), - value: getCippFormatting(value, field, "array", "both"), + value: formatField(value, field, true), }; } else { return { label: getCippTranslation(field), - value: getCippFormatting(value, field, "text", "both"), + value: formatField(value, field, false), }; } }); - if (mdDown) { - drawerWidth = "100%"; - } else { - var drawerWidth = 400; - switch (size) { - case "sm": - drawerWidth = 400; - break; - case "md": - drawerWidth = 600; - break; - case "lg": - drawerWidth = 800; - break; - case "xl": - drawerWidth = 1000; - break; - } - } + const SIZE_WIDTHS = { sm: 400, md: 600, lg: 800, xl: 1000 }; + const drawerWidth = mdDown ? "100%" : (SIZE_WIDTHS[size] ?? 400); + // Prev/next navigation exists on this drawer (row detail view); on phones the 24px + // header arrows move to a 44px bottom bar in thumb reach. + const hasRowNavigation = canNavigateUp || canNavigateDown; + const showBottomNav = mdDown && hasRowNavigation; return ( <> @@ -91,9 +92,21 @@ export const CippOffCanvas = (props) => { - {title} + {/* Phone convention: back chevron on the left — the drawer reads as a detail page */} + {mdDown ? ( + + + + + + {title} + + + ) : ( + {title} + )} - {(canNavigateUp || canNavigateDown) && ( + {hasRowNavigation && !mdDown && ( <> { )} - - - + {!mdDown && ( + + + + )} @@ -183,6 +198,49 @@ export const CippOffCanvas = (props) => { {footer} )} + + {/* Mobile prev/next bar — 44px targets in thumb reach */} + {showBottomNav && ( + + + {navigationPosition?.total > 0 && ( + + {navigationPosition.index} of {navigationPosition.total} + + )} + + + )} diff --git a/frontend/src/components/CippComponents/CippPageActionsFab.jsx b/frontend/src/components/CippComponents/CippPageActionsFab.jsx new file mode 100644 index 0000000000..b04453ef02 --- /dev/null +++ b/frontend/src/components/CippComponents/CippPageActionsFab.jsx @@ -0,0 +1,156 @@ +import { useState } from 'react' +import { + Divider, + Fab, + List, + ListItemButton, + ListItemIcon, + ListItemText, + ListSubheader, + Stack, +} from '@mui/material' +import { MoreHoriz } from '@mui/icons-material' +import { CippBottomSheet } from './CippBottomSheet' +import { CippTabNavigationSection } from './CippTabNavigationSection' +import { + useTabFabClaim, + useTabNavigation, +} from '../../layouts/tab-navigation-context' + +// The mobile page-actions pattern: one FAB in the bottom-right corner opening a bottom +// sheet of actions. CippSpeedDial cedes this corner below md, so the FAB is the only +// fixed control there. With restackButtons (default), children laid out for a desktop +// CardHeader are restacked vertically at full width; purpose-built sheet content (list +// rows) should pass restackButtons={false}. +// +// Under a tabbed layout the sheet also carries that layout's tabs, and claims the corner +// so the layout doesn't add a second FAB of its own. +export const CippPageActionsFab = (props) => { + const { + title, + // One glyph for every page-actions FAB. A "+" only ever told the truth on pages whose + // sheet creates things — on a report page the single action is a sync, and under a + // tabbed layout the sheet also holds views. MoreVert is the row kebab, so the FAB + // takes the horizontal variant. + icon = , + ariaLabel = 'Page actions', + restackButtons = true, + sheetProps, + // The tabbed layout's own fallback FAB must not claim the corner it is filling — + // claiming would flip isClaimed, unmount it, release, and loop. + claimTabCorner = true, + children, + } = props + + const [open, setOpen] = useState(false) + const tabNav = useTabNavigation() + const showTabs = Boolean(tabNav?.enabled && tabNav.tabs?.length) + // A tabbed layout may own page-level actions too (HeaderedTabbedLayout's ActionsMenu); + // they belong in this sheet rather than in a cramped header menu. + const layoutActions = (tabNav?.enabled && tabNav.actions) || [] + useTabFabClaim(claimTabCorner) + + const hasOwnActions = Boolean(children) || layoutActions.length > 0 + + // With both kinds of content the sections label themselves, so a sheet title would only + // repeat one of them; a single-purpose sheet takes the heading instead of a subheader. + const sectioned = hasOwnActions && showTabs + const resolvedTitle = title ?? (sectioned ? undefined : showTabs ? 'Views' : 'Actions') + + return ( + <> + setOpen(true)} + sx={{ + position: 'fixed', + right: 16, + bottom: 'calc(env(safe-area-inset-bottom) + 20px)', + zIndex: (theme) => theme.zIndex.speedDial, + }} + > + {icon} + + setOpen(false)} + title={resolvedTitle} + {...sheetProps} + > + * ': { width: '100%' }, + '& .MuiBox-root': { + display: 'flex', + flexDirection: 'column', + alignItems: 'stretch', + gap: 1, + }, + '& .MuiButton-root': { + width: '100%', + justifyContent: 'flex-start', + minHeight: 44, + }, + }), + }} + onClick={(event) => { + // A tap on any action has done its job — close the sheet so the drawer/dialog + // it opened isn't stacked under it. menuitem covers MenuItem-rendered children. + if (event.target?.closest?.("button, a, [role='menuitem']")) { + setOpen(false) + } + }} + > + {children} + + {showTabs && ( + <> + {children ? : null} + setOpen(false)} + /> + + )} + {layoutActions.length > 0 && ( + <> + {sectioned ? : null} + + Actions + + ) : null + } + > + {layoutActions.map((action, index) => ( + { + setOpen(false) + action.onClick?.() + }} + > + {action.icon && ( + + {action.icon} + + )} + + + ))} + + + )} + + + ) +} diff --git a/frontend/src/components/CippComponents/CippReportToolbar.jsx b/frontend/src/components/CippComponents/CippReportToolbar.jsx index 30d18a6d3a..4e97dbe9ee 100644 --- a/frontend/src/components/CippComponents/CippReportToolbar.jsx +++ b/frontend/src/components/CippComponents/CippReportToolbar.jsx @@ -1,22 +1,44 @@ -import { Box, Button, Tooltip } from '@mui/material' +import { + Box, + Button, + IconButton, + List, + ListItemButton, + ListItemIcon, + ListItemText, + Tooltip, +} from '@mui/material' import { useState, useEffect } from 'react' import { useRouter } from 'next/router' import { useForm, useWatch } from 'react-hook-form' import { useSettings } from '../../hooks/use-settings' +import { useIsMobileLayout } from '../../hooks/use-breakpoint' import { ApiGetCall } from '../../api/ApiCall.jsx' import { useQueryClient } from '@tanstack/react-query' -import { Refresh as RefreshIcon, Delete as DeleteIcon } from '@mui/icons-material' +import { + Add, + Delete as DeleteIcon, + Edit, + MoreVert, + Refresh as RefreshIcon, + Sync, +} from '@mui/icons-material' import CippFormComponent from './CippFormComponent' import { CippAddTestReportDrawer } from './CippAddTestReportDrawer' import { CippApiDialog } from './CippApiDialog' +import { CippBottomSheet } from './CippBottomSheet' export const CippReportToolbar = () => { const settings = useSettings() const router = useRouter() const { currentTenant } = settings const queryClient = useQueryClient() + const isMobile = useIsMobileLayout() const [deleteDialog, setDeleteDialog] = useState({ open: false }) const [refreshDialog, setRefreshDialog] = useState({ open: false }) + const [actionSheetOpen, setActionSheetOpen] = useState(false) + const [createDrawerOpen, setCreateDrawerOpen] = useState(false) + const [editDrawerOpen, setEditDrawerOpen] = useState(false) const defaultReportId = settings.UserSpecificSettings?.defaultTestSuite?.value || @@ -73,105 +95,226 @@ export const CippReportToolbar = () => { const isBuiltIn = selectedReportObject?.source === 'file' const selectedCustomReport = selectedReportObject?.type === 'custom' ? selectedReportObject : null + const openRefreshDialog = () => { + setRefreshDialog({ + open: true, + handleClose: () => setRefreshDialog({ open: false }), + }) + } + + const openDeleteDialog = () => { + const report = reports.find((r) => r.id === selectedReport) + if (report) { + setDeleteDialog({ + open: true, + handleClose: () => setDeleteDialog({ open: false }), + row: { ReportId: selectedReport, name: report.name }, + }) + } + } + + const suiteSelector = (withRefreshAction) => ( + ({ + label: r.name, + value: r.id, + description: r.description, + }))} + placeholder="Choose a test suite" + {...(withRefreshAction && { + customAction: { + position: 'outside', + icon: , + tooltip: 'Refresh test suites', + onClick: handleRefresh, + }, + })} + isFetching={reportsApi.isFetching} + /> + ) + return ( <> - - - ({ - label: r.name, - value: r.id, - description: r.description, - }))} - placeholder="Choose a test suite" - customAction={{ - position: 'outside', - icon: , - tooltip: 'Refresh test suites', - onClick: handleRefresh, + {isMobile ? ( + // Selector + kebab only; suite actions live in the bottom sheet. The overlays they + // open are mounted below, outside the sheet, so closing it doesn't unmount them. + + {suiteSelector(false)} + setActionSheetOpen(true)} + sx={{ minWidth: 44, minHeight: 44 }} + > + + + + ) : ( + + {/* minWidth: 0 lets the selector shrink when the row is tight instead of pushing + the trailing buttons off-screen. Layout is unchanged at widths where it fit. */} + {suiteSelector(true)} + + + + + + + + + + + + - - - - - - - - - - - - - + )} + + {isMobile && ( + <> + setActionSheetOpen(false)} + title="Test suite actions" + > + + { + setActionSheetOpen(false) + setCreateDrawerOpen(true) + }} + > + + + + + + { + setActionSheetOpen(false) + openRefreshDialog() + }} + > + + + + + + { + setActionSheetOpen(false) + setEditDrawerOpen(true) + }} + > + + + + + + { + setActionSheetOpen(false) + openDeleteDialog() + }} + > + + + + + + { + setActionSheetOpen(false) + handleRefresh() + }} + > + + + + + + + + setCreateDrawerOpen(false)} + /> + setEditDrawerOpen(false)} + /> + + )} theme.breakpoints.down('md')) const formControls = actions.reduce((acc, action) => { if (action.form) { @@ -109,6 +113,10 @@ const CippSpeedDial = ({ } }, [speedDialOpen]) + if (mdDown) { + return null + } + return ( <> { + const tabNav = useTabNavigation() + + if (!tabNav?.enabled || !tabNav.tabs?.length) return null + + return ( + + {title} + + ) : null + } + > + {tabNav.tabs.map((tab) => { + const selected = tab.path === tabNav.currentPath + return ( + { + onNavigate?.() + // Already here — the sheet closing is the whole interaction. + if (!selected) tabNav.onNavigate?.(tab.path) + }} + > + + {getIconByName(tab.icon, { fontSize: 'small' })} + + + {selected && } + + ) + })} + + ) +} diff --git a/frontend/src/components/CippComponents/CippTableDialog.jsx b/frontend/src/components/CippComponents/CippTableDialog.jsx index aae182d870..d1c2e80766 100644 --- a/frontend/src/components/CippComponents/CippTableDialog.jsx +++ b/frontend/src/components/CippComponents/CippTableDialog.jsx @@ -1,12 +1,20 @@ -import { Button, Dialog, DialogActions, DialogContent, DialogTitle } from "@mui/material"; +import { Button, Dialog, DialogActions, DialogContent, DialogTitle, useMediaQuery } from "@mui/material"; import { Stack } from "@mui/system"; import { CippDataTable } from "../CippTable/CippDataTable"; export const CippTableDialog = (props) => { const { createDialog, title, fields, api, simpleColumns, ...other } = props; + // Fullscreen on phones so the nested card list gets the viewport (CippApiDialog precedent) + const mdDown = useMediaQuery((theme) => theme.breakpoints.down("md")); return ( - + {title} diff --git a/frontend/src/components/CippComponents/CippTablePage.jsx b/frontend/src/components/CippComponents/CippTablePage.jsx index b95db81b5a..af4881a97b 100644 --- a/frontend/src/components/CippComponents/CippTablePage.jsx +++ b/frontend/src/components/CippComponents/CippTablePage.jsx @@ -2,6 +2,7 @@ import { Alert, Card, Divider } from "@mui/material"; import { Box, Container, Stack } from "@mui/system"; import { CippDataTable } from "../CippTable/CippDataTable"; import { useSettings } from "../../hooks/use-settings"; +import { useTableViewMode } from "../../hooks/use-breakpoint"; import { CippHead } from "./CippHead"; import { useState, useEffect } from "react"; @@ -29,47 +30,69 @@ export const CippTablePage = (props) => { ...other } = props; const tenant = useSettings().currentTenant; + const viewMode = useTableViewMode({ viewMode: other.viewMode }); + const isCardView = viewMode === "cards"; // Use initialFilters if provided, otherwise use regular filters const activeFilters = initialFilters || filters; + + // Pages without an explicit queryKey have always keyed their query on the title — + // which embeds the tenant. Card view drops the tenant from the DISPLAY title, so the + // cache key must keep carrying it explicitly or tenant switches serve stale data. + const effectiveQueryKey = + queryKey ?? (tenantInTitle && tenant !== null ? `${title} - ${tenant}` : title); + + const table = ( + + ); + return ( <> - - + + {tableFilter} {tenantInTitle && (!tenant || tenant === null) && ( No tenant selected. Please select a tenant from the dropdown above. )} - - - - - + > + + + {table} + + )} diff --git a/frontend/src/components/CippComponents/CippTenantSelector.jsx b/frontend/src/components/CippComponents/CippTenantSelector.jsx index d98a00797f..d48def5dee 100644 --- a/frontend/src/components/CippComponents/CippTenantSelector.jsx +++ b/frontend/src/components/CippComponents/CippTenantSelector.jsx @@ -405,7 +405,9 @@ export const CippTenantSelector = React.forwardRef((props, ref) => { disableClearable={true} creatable={false} multiple={multiple} - sx={{ width: width ? width : "400px" }} + // Full width below md by default: the hard 400px overflowed any narrow container + // this selector was dropped into (the old 80%-wide mobile drawer most visibly). + sx={{ width: width ? width : { xs: "100%", md: "400px" } }} placeholder={ tenantList.isFetching ? "Loading Tenants..." diff --git a/frontend/src/components/CippFormPages/CippExchangeSettingsForm.jsx b/frontend/src/components/CippFormPages/CippExchangeSettingsForm.jsx index 0427d3d27d..812e28c849 100644 --- a/frontend/src/components/CippFormPages/CippExchangeSettingsForm.jsx +++ b/frontend/src/components/CippFormPages/CippExchangeSettingsForm.jsx @@ -221,7 +221,7 @@ const CippExchangeSettingsForm = (props) => { ]} /> - + { - + ({ @@ -165,6 +166,13 @@ export const CIPPTableToptoolbar = React.memo( queueMetadata, isInDialog = false, showBulkExportAction = true, + // Mobile card mode: same state, same handlers, different presentation (sheets + // instead of menus). Select-mode state lives in CippDataTable so the card list + // and this toolbar stay in sync. + viewMode = 'table', + selectMode = false, + onSelectModeChange, + selectModeLocked = false, }) => { const popover = usePopover() const [filtersAnchor, setFiltersAnchor] = useState(null) @@ -256,6 +264,58 @@ export const CIPPTableToptoolbar = React.memo( }) } + // Shared refresh dispatch — desktop refresh button and the mobile filter sheet. + const handleRefresh = () => { + if (typeof refreshFunction === 'object') { + refreshFunction.refetch() + } else if (typeof refreshFunction === 'function') { + refreshFunction() + } else if (data && !getRequestData.isFetched) { + // do nothing because data was sent native. + } else if (getRequestData) { + getRequestData.refetch() + } + } + + // Shared bulk-action dispatch — desktop bulk menu and the mobile bulk sheet must not + // drift, so both route through here. + const handleBulkAction = (action, closeMenu = () => {}) => { + if (action.disabled) { + return + } + + const allSelectedRows = table.getSelectedRowModel().rows + const eligibleRows = + action.bulkFilterEligible && action.condition + ? allSelectedRows.filter((row) => action.condition(row.original)) + : allSelectedRows + const selectedData = eligibleRows.map((row) => row.original) + + if (typeof action.customBulkHandler === 'function') { + action.customBulkHandler({ + rows: eligibleRows, + data: selectedData, + closeMenu, + clearSelection: () => table.toggleAllRowsSelected(false), + }) + closeMenu() + return + } + + setActionData({ + data: selectedData, + action: action, + ready: true, + }) + + if (action?.noConfirm && action.customFunction) { + eligibleRows.map((row) => action.customFunction(row.original.original, action, {})) + } else { + createDialog.handleOpen() + closeMenu() + } + } + // Track if we've restored filters for this page to prevent infinite loops const restoredFiltersRef = useRef(new Set()) @@ -416,16 +476,20 @@ export const CIPPTableToptoolbar = React.memo( usedColumns?.length, ]) - // Restore last used filter on mount if persistFilters is enabled (non-graph filters) + // Restore last used filter on mount if persistFilters is enabled (non-graph filters). + // Once-per-page like the graph slot above: keying this on isFetching used to re-arm the + // 100ms timer on every fetch settle (once per page of an auto-paginated load), clobbering + // whatever filter the user had just applied with the persisted one. useEffect(() => { - // Wait for table to be initialized and data to be available + const restorationKey = `${pageName}-table` + // Wait for table to be initialized and columns to exist (column filters need them) if ( settings.persistFilters && settings.lastUsedFilters && settings.lastUsedFilters[pageName] && table && usedColumns.length > 0 && - !getRequestData?.isFetching + !restoredFiltersRef.current.has(restorationKey) ) { // Use setTimeout to ensure the table is fully rendered const timeoutId = setTimeout(() => { @@ -437,13 +501,17 @@ export const CIPPTableToptoolbar = React.memo( } if (last.type === 'global') { + restoredFiltersRef.current.add(restorationKey) table.setGlobalFilter(last.value) + // Keep the visible search box in sync with the filter it now represents + setSearchValue(typeof last.value === 'string' ? last.value : '') setActiveFilters((prev) => ({ ...prev, table: { id: last.id, name: last.name, type: last.type }, })) } else if (last.type === 'column') { - // Only apply if all filter columns exist in the current table + // Only apply if all filter columns exist in the current table; if they don't + // yet (columns still streaming in), leave unmarked so a later run retries. const allColumns = table.getAllColumns().map((col) => col.id) const filterColumns = Array.isArray(last.value) ? last.value.map((f) => f.id) @@ -452,7 +520,10 @@ export const CIPPTableToptoolbar = React.memo( allColumns.includes(colId) ) if (allExist) { - table.setShowColumnFilters(true) + restoredFiltersRef.current.add(restorationKey) + if (viewMode !== 'cards') { + table.setShowColumnFilters(true) + } table.setColumnFilters(last.value) setActiveFilters((prev) => ({ ...prev, @@ -471,7 +542,7 @@ export const CIPPTableToptoolbar = React.memo( pageName, table, usedColumns, - getRequestData?.isFetching, + viewMode, ]) const presetList = ApiGetCall({ @@ -672,6 +743,12 @@ export const CIPPTableToptoolbar = React.memo( if (activeFilters.table?.type === 'column') { table.resetColumnFilters() } + // The search box IS the global filter's visible form — a pending debounced + // keystroke or stale text would silently overwrite this preset otherwise. + if (searchDebounceRef.current) { + clearTimeout(searchDebounceRef.current) + } + setSearchValue(typeof filter === 'string' ? filter : '') table.setGlobalFilter(filter) setActiveFilters((prev) => ({ ...prev, @@ -694,8 +771,15 @@ export const CIPPTableToptoolbar = React.memo( if (filterType === 'column') { if (activeFilters.table?.type === 'global') { table.resetGlobalFilter() + if (searchDebounceRef.current) { + clearTimeout(searchDebounceRef.current) + } + setSearchValue('') + } + if (viewMode !== 'cards') { + // Card view renders no header row for the filter inputs to appear in + table.setShowColumnFilters(true) } - table.setShowColumnFilters(true) table.setColumnFilters(filter) setActiveFilters((prev) => ({ ...prev, @@ -799,6 +883,10 @@ export const CIPPTableToptoolbar = React.memo( if (layer === 'table') { if (activeFilters.table?.type === 'global') { table.resetGlobalFilter() + if (searchDebounceRef.current) { + clearTimeout(searchDebounceRef.current) + } + setSearchValue('') } else { table.resetColumnFilters() } @@ -816,6 +904,23 @@ export const CIPPTableToptoolbar = React.memo( } } + // Pages that compute `filters` asynchronously (or swap them per tenant) need the preset + // list to follow the prop — state-only init froze it at first render. Deep-equal via + // JSON: the prop is usually a fresh array literal every render. + const filtersJson = JSON.stringify(filters ?? []) + useEffect(() => { + const propFilters = JSON.parse(filtersJson) + setFilterList((prev) => { + const fetchedGraphPresets = (prev ?? []).filter( + (f) => + f.type === 'graph' && + !propFilters.some((p) => presetKey(p) === presetKey(f)) + ) + return [...propFilters, ...fetchedGraphPresets] + }) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [filtersJson]) + useEffect(() => { if (api?.url === '/api/ListGraphRequest' && presetList.isSuccess) { var endpoint = api?.data?.Endpoint?.replace(/^\//, '') @@ -882,6 +987,65 @@ export const CIPPTableToptoolbar = React.memo( return ( <> + {viewMode === 'cards' ? ( + setTableFilter('', 'reset', '')} + onEditGraphFilters={ + api?.url === '/api/ListGraphRequest' + ? () => setFilterCanvasVisible(true) + : undefined + } + columnItems={table + .getAllColumns() + .filter((column) => !column.id.startsWith('mrt-')) + .map((column) => ({ + id: column.id, + visible: Boolean(column.getIsVisible()), + }))} + onToggleColumn={(columnId, visible) => + setColumnVisibility({ ...columnVisibility, [columnId]: !visible }) + } + exportEnabled={exportEnabled} + onExportCsv={() => + document.querySelector(`[data-csv-export="${title}"]`)?.click() + } + onExportPdf={() => + document.querySelector(`[data-pdf-export="${title}"]`)?.click() + } + onViewApiResponse={() => + isInDialog ? setJsonDialogOpen(true) : setOffcanvasVisible(true) + } + fixedChrome={!isInDialog} + queueTracker={ + queueMetadata?.QueueId ? ( + + ) : undefined + } + /> + ) : ( { - if (typeof refreshFunction === 'object') { - refreshFunction.refetch() - } else if (typeof refreshFunction === 'function') { - refreshFunction() - } else if (data && !getRequestData.isFetched) { - // do nothing because data was sent native. - } else if (getRequestData) { - getRequestData.refetch() - } - }} + onClick={handleRefresh} disabled={ getRequestData?.isLoading || getRequestData?.isFetching || @@ -1092,9 +1246,12 @@ export const CIPPTableToptoolbar = React.memo( }, }} > + {/* Anchor the nested menus to the stable overflow IconButton — anchoring to + event.currentTarget here targets a MenuItem inside a menu that closes in + the same tick, which positions the next popover unpredictably. */} { - setFiltersAnchor(event.currentTarget) + onClick={() => { + setFiltersAnchor(actionMenuAnchor) setActionMenuAnchor(null) }} > @@ -1104,8 +1261,8 @@ export const CIPPTableToptoolbar = React.memo( Filters { - setColumnsAnchor(event.currentTarget) + onClick={() => { + setColumnsAnchor(actionMenuAnchor) setActionMenuAnchor(null) }} > @@ -1116,8 +1273,8 @@ export const CIPPTableToptoolbar = React.memo( {exportEnabled && ( { - setExportAnchor(event.currentTarget) + onClick={() => { + setExportAnchor(actionMenuAnchor) setActionMenuAnchor(null) }} > @@ -1402,23 +1559,26 @@ export const CIPPTableToptoolbar = React.memo( /> - {/* Hidden export buttons for triggering */} - - - - + + )} + + {/* Hidden export buttons for triggering — outside the mode branch so the + mobile filter sheet's export items can click them too */} + + + {/* Bulk Actions Menu - now inline with toolbar */} @@ -1444,46 +1604,7 @@ export const CIPPTableToptoolbar = React.memo( { - if (action.disabled) { - return - } - - const allSelectedRows = table.getSelectedRowModel().rows - const selectedRows = - action.bulkFilterEligible && action.condition - ? allSelectedRows.filter((row) => - action.condition(row.original) - ) - : allSelectedRows - const selectedData = selectedRows.map((row) => row.original) - - if (typeof action.customBulkHandler === 'function') { - action.customBulkHandler({ - rows: selectedRows, - data: selectedData, - closeMenu: popover.handleClose, - clearSelection: () => table.toggleAllRowsSelected(false), - }) - popover.handleClose() - return - } - - setActionData({ - data: selectedData, - action: action, - ready: true, - }) - - if (action?.noConfirm && action.customFunction) { - selectedRows.map((row) => - action.customFunction(row.original.original, action, {}) - ) - } else { - createDialog.handleOpen() - popover.handleClose() - } - }} + onClick={() => handleBulkAction(action, popover.handleClose)} > {action.icon} diff --git a/frontend/src/components/CippTable/CippDataTable.js b/frontend/src/components/CippTable/CippDataTable.js index f166e1744b..e63ba73904 100644 --- a/frontend/src/components/CippTable/CippDataTable.js +++ b/frontend/src/components/CippTable/CippDataTable.js @@ -8,6 +8,7 @@ import { ListItemText, MenuItem, SvgIcon, + Typography, } from '@mui/material' import { ResourceUnavailable } from '../resource-unavailable' import { ResourceError } from '../resource-error' @@ -30,6 +31,8 @@ import { useSettings } from '../../hooks/use-settings' import { parseCippDate } from '../../utils/parse-cipp-date' import { isEqual } from 'lodash' // Import lodash for deep comparison import { useLicenseBackfill } from '../../hooks/use-license-backfill' +import { useTableViewMode } from '../../hooks/use-breakpoint' +import { CippMobileCardList } from './CippMobileCardList' // Resolve dot-delimited property paths against arbitrary data objects. const getNestedValue = (source, path) => { @@ -388,6 +391,8 @@ export const CippDataTable = (props) => { defaultSorting = [], isInDialog = false, showBulkExportAction = true, + viewMode: viewModeProp, + mobileCard, } = props // Create a map of column IDs to their filterType for quick lookup @@ -434,6 +439,15 @@ export const CippDataTable = (props) => { const settings = useSettings() + // 'cards' below the md breakpoint (or when forced via settings/prop), 'table' otherwise. + // simple tables always resolve to 'table'. + const resolvedViewMode = useTableViewMode({ viewMode: viewModeProp, simple }) + const isCardView = resolvedViewMode === 'cards' + // Mobile select mode: checkboxes on cards + the bottom bulk bar. Lives here so the + // toolbar (which renders the Select toggle) and the card list stay in sync. Picker + // tables (onChange) force it on — selection is their entire purpose. + const [mobileSelectMode, setMobileSelectMode] = useState(false) + // Hook to trigger re-render when license backfill completes const { updateTrigger } = useLicenseBackfill() @@ -648,7 +662,8 @@ export const CippDataTable = (props) => { offCanvas, onChange, maxHeightOffset, - settings + settings, + resolvedViewMode ), [ simple, @@ -657,6 +672,7 @@ export const CippDataTable = (props) => { hasOnChange, maxHeightOffset, settings?.tablePageSize?.value, + resolvedViewMode, ] ) @@ -785,6 +801,62 @@ export const CippDataTable = (props) => { [sanitizedColumnVisibility, sorting, columnFilters, showSkeletons] ) + // Single row-action dispatch used by BOTH the desktop row menu and the mobile action + // sheet — the two presentations must not drift. + // `table` is referenced via closure: it is declared below but initialized before any + // handler can run (the same pattern the row menu has always relied on). + const dispatchRowAction = useCallback( + (action, rowOriginal, closeMenu = () => {}) => { + const scopeToRowTenant = () => { + if (settings.currentTenant === 'AllTenants' && rowOriginal?.Tenant) { + settings.handleUpdate({ + currentTenant: rowOriginal.Tenant, + }) + } + } + + if (action.noConfirm && action.customFunction) { + scopeToRowTenant() + action.customFunction(rowOriginal, action, {}) + closeMenu() + return + } + + // Handle custom component differently + if (typeof action.customComponent === 'function') { + scopeToRowTenant() + setCustomComponentData({ data: rowOriginal, action: action }) + setCustomComponentVisible(true) + closeMenu() + return + } + + // Standard dialog flow + setActionData({ + data: rowOriginal, + action: action, + ready: true, + }) + createDialog.handleOpen() + closeMenu() + }, + [settings, createDialog] + ) + + // Open the extended-info offcanvas for a row, recording its position in the filtered + // row model so prev/next navigation works. Shared by the row menu, the mobile action + // sheet, and card taps. + const openRowOffCanvas = useCallback((rowOriginal) => { + setOffCanvasData(rowOriginal) + const filteredRowsArray = table.getFilteredRowModel().rows + const indexInFiltered = filteredRowsArray.findIndex( + (r) => r.original === rowOriginal + ) + setOffCanvasRowIndex(indexInFiltered >= 0 ? indexInFiltered : 0) + setOffcanvasVisible(true) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + // Memoize renderRowActionMenuItems to avoid re-creating on each render. const renderRowActionMenuItems = useMemo(() => { if (actions) { @@ -801,43 +873,7 @@ export const CippDataTable = (props) => { { - const scopeToRowTenant = () => { - if ( - settings.currentTenant === 'AllTenants' && - row.original?.Tenant - ) { - settings.handleUpdate({ - currentTenant: row.original.Tenant, - }) - } - } - - if (action.noConfirm && action.customFunction) { - scopeToRowTenant() - action.customFunction(row.original, action, {}) - closeMenu() - return - } - - // Handle custom component differently - if (typeof action.customComponent === 'function') { - scopeToRowTenant() - setCustomComponentData({ data: row.original, action: action }) - setCustomComponentVisible(true) - closeMenu() - return - } - - // Standard dialog flow - setActionData({ - data: row.original, - action: action, - ready: true, - }) - createDialog.handleOpen() - closeMenu() - }} + onClick={() => dispatchRowAction(action, row.original, closeMenu)} disabled={handleActionDisabled(row.original, action)} > @@ -851,14 +887,7 @@ export const CippDataTable = (props) => { key={`actions-list-row-more`} onClick={() => { closeMenu() - setOffCanvasData(row.original) - // Find the index of this row in the filtered rows - const filteredRowsArray = table.getFilteredRowModel().rows - const indexInFiltered = filteredRowsArray.findIndex( - (r) => r.original === row.original - ) - setOffCanvasRowIndex(indexInFiltered >= 0 ? indexInFiltered : 0) - setOffcanvasVisible(true) + openRowOffCanvas(row.original) }} > @@ -875,13 +904,7 @@ export const CippDataTable = (props) => { { closeMenu() - setOffCanvasData(row.original) - const filteredRowsArray = table.getFilteredRowModel().rows - const indexInFiltered = filteredRowsArray.findIndex( - (r) => r.original === row.original - ) - setOffCanvasRowIndex(indexInFiltered >= 0 ? indexInFiltered : 0) - setOffcanvasVisible(true) + openRowOffCanvas(row.original) }} > @@ -896,9 +919,9 @@ export const CippDataTable = (props) => { }, [ actions, offCanvas, - settings.currentTenant, + dispatchRowAction, + openRowOffCanvas, handleActionDisabled, - createDialog, ]) // Stable renderTopToolbar — memoized so MaterialReactTable doesn't re-create the toolbar @@ -917,7 +940,7 @@ export const CippDataTable = (props) => { columnVisibility={columnVisibility} getRequestData={getRequestData} usedColumns={usedColumns} - usedData={memoizedData ?? []} + usedData={memoizedData ?? EMPTY_ARRAY} title={title} actions={actions} exportEnabled={exportEnabled} @@ -977,7 +1000,7 @@ export const CippDataTable = (props) => { columnVisibility: sanitizedColumnVisibility, }, columns: usedColumns, - data: memoizedData ?? [], + data: memoizedData ?? EMPTY_ARRAY, state: tableState, onSortingChange: handleSortingChange, onColumnFiltersChange: setColumnFilters, @@ -994,10 +1017,41 @@ export const CippDataTable = (props) => { renderColumnFilterModeMenuItems: renderColumnFilterModeMenuItemsFn, }) + // A card shows at most a title, subtitle, three chips and three detail rows, so on pages + // that never configured an offCanvas the rest of the row would be unreachable. Fall back + // to the columns the user has chosen to show, which is what the card was summarising. + const cardFallbackInfoFields = useMemo(() => { + if (offCanvas || !isCardView) return undefined + return table + .getVisibleLeafColumns() + .map((column) => column.id) + .filter((id) => !id.startsWith('mrt-')) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [offCanvas, isCardView, table, columnVisibility, usedColumns]) + // Remove the useEffect that was resetting filters on table changes // The initial filter application is now handled by the columnFilters state // and the useEffect above that only triggers on actual filter prop changes + // Exiting mobile select mode clears the selection — "Done" means done. + const handleMobileSelectModeChange = useCallback( + (on) => { + setMobileSelectMode(on) + if (!on) { + table.toggleAllRowsSelected(false) + } + }, + [table] + ) + + // Empty-state "Clear filters" in the card list. The full reset (graph filters, + // persisted slots) lives in the toolbar's filter sheet; this only clears what makes + // the current list empty. + const handleClearAllFilters = useCallback(() => { + table.resetGlobalFilter() + table.resetColumnFilters() + }, [table]) + useEffect(() => { if (onChange && table.getSelectedRowModel().rows) { onChange(table.getSelectedRowModel().rows.map((row) => row.original)) @@ -1024,9 +1078,97 @@ export const CippDataTable = (props) => { } }, [simpleColumns]) + const selectModeActive = hasOnChange ? true : mobileSelectMode + return ( <> - {noCard ? ( + {isCardView ? ( + + {!hideTitle && ( + + + {title} + + {Array.isArray(usedData) && !showSkeletons && ( + + {table.getFilteredRowModel().rows.length} results + + )} + + )} + {!Array.isArray(usedData) && usedData ? ( + + ) : ( + <> + + + + )} + {getRequestData.isError && !getRequestData.isFetchNextPageError && ( + getRequestData.refetch()} + message={`Error Loading data: ${getCippError(getRequestData.error)}`} + /> + )} + + ) : noCard ? ( {!Array.isArray(usedData) && usedData ? ( @@ -1086,7 +1228,10 @@ export const CippDataTable = (props) => { visible={offcanvasVisible} onClose={() => setOffcanvasVisible(false)} extendedData={offCanvasData} - extendedInfoFields={offCanvas?.extendedInfoFields} + extendedInfoFields={offCanvas?.extendedInfoFields ?? cardFallbackInfoFields} + // The fallback's fields are table columns, so render them the way their cells + // do — links, copy chips and status icons rather than flattened text. + richFormatting={!offCanvas && Boolean(cardFallbackInfoFields?.length)} actions={actions} title={offCanvasData?.Name || offCanvas?.title || 'Extended Info'} children={ @@ -1113,6 +1258,10 @@ export const CippDataTable = (props) => { canNavigateDown={ filteredRows && offCanvasRowIndex < filteredRows.length - 1 } + navigationPosition={{ + index: offCanvasRowIndex + 1, + total: filteredRows?.length ?? 0, + }} {...offCanvas} /> {/* Render custom component */} diff --git a/frontend/src/components/CippTable/CippDataTableButton.jsx b/frontend/src/components/CippTable/CippDataTableButton.jsx index 86c3c887e1..f0b3d6c742 100644 --- a/frontend/src/components/CippTable/CippDataTableButton.jsx +++ b/frontend/src/components/CippTable/CippDataTableButton.jsx @@ -1,9 +1,11 @@ import { useState } from "react"; -import { Dialog, DialogContent, Button } from "@mui/material"; +import { Dialog, DialogContent, DialogTitle, IconButton, Button, useMediaQuery } from "@mui/material"; +import CloseIcon from "@mui/icons-material/Close"; import { CippDataTable } from "./CippDataTable"; import { getCippTranslation } from "../../utils/get-cipp-translation"; const CippDataTableButton = ({ data, title, tableTitle = "Data" }) => { const [openDialogs, setOpenDialogs] = useState([]); + const mdDown = useMediaQuery((theme) => theme.breakpoints.down("md")); const handleOpenDialog = (event) => { event?.stopPropagation(); @@ -55,15 +57,32 @@ const CippDataTableButton = ({ data, title, tableTitle = "Data" }) => { onMouseDown={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()} fullWidth + // Fullscreen on phones (CippApiDialog precedent): the nested card list needs the + // viewport, not a cramped modal window — and fullscreen has no backdrop, so give + // it an explicit close header. + fullScreen={mdDown} maxWidth="lg" > - + {mdDown && ( + + handleCloseDialog(index, event)} + aria-label="Close" + sx={{ minWidth: 44, minHeight: 44 }} + > + + + {tableTitle} + + )} + diff --git a/frontend/src/components/CippTable/CippGraphExplorerFilter.js b/frontend/src/components/CippTable/CippGraphExplorerFilter.js index eda6a1e50f..ee954ac948 100644 --- a/frontend/src/components/CippTable/CippGraphExplorerFilter.js +++ b/frontend/src/components/CippTable/CippGraphExplorerFilter.js @@ -104,13 +104,16 @@ const CippGraphExplorerFilter = ({ waiting: false, }) - var presetFilter = {} - if (endpointFilter) { - if (formControl.getValues('endpoint') !== endpointFilter) { + // Seeding the form from the endpointFilter prop is a side effect: doing it during render + // updates the subscribed Controller mid-render ("Cannot update a component while rendering + // a different component"). It fires twice on mobile, where the table remounts as cards. + useEffect(() => { + if (endpointFilter && formControl.getValues('endpoint') !== endpointFilter) { formControl.setValue('endpoint', endpointFilter) } - presetFilter = { Endpoint: endpointFilter } - } + }, [endpointFilter, formControl]) + + const presetFilter = endpointFilter ? { Endpoint: endpointFilter } : {} // API call for available presets const presetList = ApiGetCall({ @@ -737,7 +740,7 @@ const CippGraphExplorerFilter = ({ compareValue={true} > {/* Reverse Tenant Lookup Property Field */} - + { + const columnDef = column?.columnDef ?? column; + try { + const cell = row.getAllCells().find((c) => c.column.id === column.id); + // A portal cell is a bare icon — legible under its column header, not on a card row + // that only carries a label. Spell the link out from the raw value instead. + const linked = renderUrlValue(row.original?.[column.id], column.id); + if (linked) return linked; + if (typeof columnDef?.Cell === "function") { + return flexRender(columnDef.Cell, { + row, + cell, + column: cell?.column ?? column, + table, + renderedCellValue: cell ? cell.getValue() : row.getValue(column.id), + }); + } + return cell ? cell.getValue() : row.getValue(column.id); + } catch { + return null; + } +}; + +// String form for the card title/subtitle: the accessorFn output (getCippFormatting text +// mode for generated columns) — never a React node inside noWrap Typography. +const textValue = (row, column) => { + if (!column) return null; + try { + const value = row.getValue(column.id); + return typeof value === "string" || typeof value === "number" ? String(value) : null; + } catch { + return null; + } +}; + +const SkeletonCard = () => ( + + + + + + + + + + + + + +); + +export const CippMobileCardList = (props) => { + const { + table, + actions, + hasOffCanvas = false, + onRowAction, + onMoreInfo, + isActionDisabled, + selectMode = false, + cardButton, + mobileCard, + fixedChrome = true, + onClearFilters, + isStreaming = false, + queueMessage, + } = props; + + const [actionSheetRow, setActionSheetRow] = useState(null); + + // Select mode's bulk bar owns the bottom of the screen, so the page FAB steps aside. Hold + // the claim through it anyway: a tabbed layout would otherwise drop its own FAB in behind + // the bulk bar. Tabs come back with the FAB when selection ends. + useTabFabClaim(fixedChrome && selectMode); + + // A desktop tablePageSize above the cap would render that many unvirtualized cards. + useEffect(() => { + if (table.getState().pagination.pageSize > MOBILE_PAGE_SIZE_CAP) { + table.setPageSize(MOBILE_PAGE_SIZE_CAP); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const rows = table.getRowModel().rows; + const totalFiltered = table.getFilteredRowModel().rows.length; + const showSkeletons = table.getState().showSkeletons; + const { globalFilter, columnFilters } = table.getState(); + const hasActiveFilter = Boolean(globalFilter) || (columnFilters?.length ?? 0) > 0; + + const visibleColumns = table.getVisibleLeafColumns(); + const slots = useMemo( + () => getMobileCardSlots(visibleColumns, mobileCard), + // visibleColumns is a fresh array each call — key on the ids it contains + // eslint-disable-next-line react-hooks/exhaustive-deps + [visibleColumns.map((c) => c.id).join(","), mobileCard] + ); + + const rowActionItems = (row) => + (actions ?? []).filter( + (action) => typeof action.hideCondition !== "function" || !action.hideCondition(row.original) + ); + + // Detail rows that would waste space: empty values, or values already shown as the + // card's title/subtitle (e.g. mail duplicating the UPN on most user rows). + const visibleDetailColumns = (row) => { + const shown = [textValue(row, slots.primary), slots.secondary && textValue(row, slots.secondary)] + .filter(Boolean) + .map((v) => v.toLowerCase()); + return slots.details.filter((col) => { + let raw; + try { + raw = row.getValue(col.id); + } catch { + return true; + } + if (raw === null || raw === undefined || raw === "") return false; + if (Array.isArray(raw) && raw.length === 0) return false; + if (typeof raw === "string" && shown.includes(raw.toLowerCase())) return false; + return true; + }); + }; + + const handleCardTap = (event, row) => { + if ( + event.target?.closest?.( + 'button, a, input, textarea, select, [role="button"], [role="menuitem"], [data-no-row-click="true"]' + ) + ) { + return; + } + if (selectMode) { + row.toggleSelected(); + return; + } + if (hasOffCanvas) { + onMoreInfo?.(row.original); + } + }; + + const handleLoadMore = () => { + table.setPageSize(table.getState().pagination.pageSize + LOAD_STEP); + }; + + const loadedCount = Math.min(rows.length, totalFiltered); + + return ( + + {isStreaming && !showSkeletons && } + + {showSkeletons ? ( + Array.from({ length: 5 }, (_, i) => ) + ) : totalFiltered === 0 ? ( + + + {queueMessage ? : } + + + {queueMessage ?? "No results"} + + {hasActiveFilter && ( + <> + + Nothing matches the current search and filters. + + + + )} + + ) : ( + <> + {rows.map((row) => { + const selected = row.getIsSelected(); + const detailColumns = visibleDetailColumns(row); + return ( + handleCardTap(event, row)} + sx={{ + p: 1.25, + display: "flex", + gap: 1.25, + position: "relative", + cursor: selectMode || hasOffCanvas ? "pointer" : "default", + ...(selected && { + borderColor: "primary.main", + bgcolor: (theme) => + theme.palette.mode === "dark" + ? "rgba(247,127,0,.08)" + : "primary.alpha8", + }), + }} + > + {selectMode && ( + row.toggleSelected()} + sx={{ alignSelf: "flex-start", p: 1, m: -0.5 }} + inputProps={{ "aria-label": `Select ${textValue(row, slots.primary) ?? row.id}` }} + /> + )} + + + {textValue(row, slots.primary) ?? "—"} + + {slots.secondary && ( + + {textValue(row, slots.secondary)} + + )} + {slots.chips.length > 0 && ( + + {slots.chips.map((col) => { + // Booleans format as a bare ✓/✕ icon — meaningful under a column + // header, meaningless floating on a card. Give those chips their + // field name in a labeled pill ("Primary ✓", "Account Enabled ✕"). + const text = textValue(row, col); + const isBareBoolean = text === "Yes" || text === "No"; + return ( + + {isBareBoolean && ( + + {getCippTranslation(col.id)} + + )} + {renderCellValue(row, col, table)} + + ); + })} + + )} + {detailColumns.length > 0 && ( + // Grid so every label shares the width of the longest one — no fixed + // label column truncating "Business Phones" while values sit half-empty. + + {detailColumns.map((col) => ( + + + {getCippTranslation(col.id)} + + *": { verticalAlign: "middle" }, + }} + > + {renderCellValue(row, col, table)} + + + ))} + + )} + {slots.restCount > 0 && hasOffCanvas && ( + { + event.stopPropagation(); + onMoreInfo?.(row.original); + }} + role="button" + > + +{slots.restCount} more field{slots.restCount === 1 ? "" : "s"} + + )} + + {(actions?.length > 0 || hasOffCanvas) && !selectMode && ( + { + event.stopPropagation(); + setActionSheetRow(row); + }} + sx={{ position: "absolute", top: 4, right: 4, minWidth: 44, minHeight: 44 }} + > + + + )} + + ); + })} + + + Showing {loadedCount} of {totalFiltered} + {isStreaming ? " (loading…)" : ""} + + {loadedCount < totalFiltered && ( + + )} + + + )} + + + {/* Page-level add actions: the cardButton children, stacked in a sheet behind one FAB */} + {cardButton && fixedChrome && !selectMode && ( + {cardButton} + )} + + {/* Row actions sheet — same actions array, same dispatch as the desktop row menu */} + setActionSheetRow(null)} + title={actionSheetRow ? (textValue(actionSheetRow, slots.primary) ?? "Row actions") : ""} + > + {actionSheetRow && + rowActionItems(actionSheetRow).map((action, index) => { + const disabled = isActionDisabled?.(actionSheetRow.original, action) ?? false; + return ( + { + setActionSheetRow(null); + onRowAction?.(action, actionSheetRow.original); + }} + sx={{ minHeight: 48, color: action.color }} + > + + {action.icon} + + + + ); + })} + {actionSheetRow && hasOffCanvas && ( + { + setActionSheetRow(null); + onMoreInfo?.(actionSheetRow.original); + }} + sx={{ minHeight: 48 }} + > + + + + + + + + )} + + + ); +}; diff --git a/frontend/src/components/CippTable/CippMobileTableControls.jsx b/frontend/src/components/CippTable/CippMobileTableControls.jsx new file mode 100644 index 0000000000..1ee5ef0122 --- /dev/null +++ b/frontend/src/components/CippTable/CippMobileTableControls.jsx @@ -0,0 +1,427 @@ +import { useState } from "react"; +import { + Badge, + Box, + Button, + Checkbox, + Chip, + Divider, + IconButton, + InputAdornment, + ListItemButton, + ListItemIcon, + ListItemText, + ListSubheader, + OutlinedInput, + Stack, + SvgIcon, + Typography, +} from "@mui/material"; +import { + ArrowDownward, + ArrowUpward, + Check, + FilterList, + RestartAlt, + Search, + SwapVert, + Sync, + DataObject, + FileDownload, + PictureAsPdf, +} from "@mui/icons-material"; +import { getCippTranslation } from "../../utils/get-cipp-translation"; +import { CippBottomSheet } from "../CippComponents/CippBottomSheet"; + +// Presentational mobile controls for the card list. All filter/sort/visibility state and +// handlers are owned by CIPPTableToptoolbar (the same instance the desktop toolbar uses), +// so persistence, presets, and graph filters flow through exactly one code path. +export const CippMobileTableControls = (props) => { + const { + table, + searchValue, + onSearchChange, + onRefresh, + isRefreshing = false, + selectionEnabled = false, + selectMode = false, + onSelectModeChange, + selectModeLocked = false, + customBulkActions = [], + onBulkAction, + graphPresetItems = [], + tablePresetItems = [], + activeFilters = { graph: null, table: null }, + activeSlotCount = 0, + presetKey, + onPresetClick, + onResetFilters, + onEditGraphFilters, + columnItems = [], + onToggleColumn, + exportEnabled = false, + onExportCsv, + onExportPdf, + onViewApiResponse, + fixedChrome = true, + queueTracker, + } = props; + + const [sortOpen, setSortOpen] = useState(false); + const [filterOpen, setFilterOpen] = useState(false); + const [bulkOpen, setBulkOpen] = useState(false); + + const sorting = table.getState().sorting ?? []; + const sortableColumns = table + .getAllColumns() + .filter((column) => !column.id.startsWith("mrt-") && column.getCanSort()); + + // Tap cycles: none -> asc -> desc -> none. Single-column sort — replaces, not appends. + const cycleSort = (columnId) => { + const current = sorting.find((s) => s.id === columnId); + if (!current) { + table.setSorting([{ id: columnId, desc: false }]); + } else if (!current.desc) { + table.setSorting([{ id: columnId, desc: true }]); + } else { + table.setSorting([]); + } + }; + + const selectedCount = table.getSelectedRowModel().rows.length; + const totalCount = table.getFilteredRowModel().rows.length; + const enabledBulkActions = customBulkActions.filter((action) => !action.disabled); + + const renderPresetChips = (items, layer) => ( + + {items.map((filter) => { + const key = presetKey(filter); + const active = activeFilters[layer]?.id === key; + return ( + : undefined} + onClick={() => onPresetClick(filter)} + sx={{ height: 36, borderRadius: 999 }} + /> + ); + })} + + ); + + return ( + <> + + + + + } + sx={{ minHeight: 44, flex: 1, minWidth: 0 }} + /> + {selectionEnabled && !selectModeLocked && ( + + )} + setSortOpen(true)} + sx={{ + minWidth: 44, + minHeight: 44, + border: 1, + borderColor: sorting.length ? "primary.main" : "divider", + borderRadius: 1, + color: sorting.length ? "primary.main" : "inherit", + flexShrink: 0, + }} + > + + + setFilterOpen(true)} + sx={{ + minWidth: 44, + minHeight: 44, + border: 1, + borderColor: activeSlotCount > 0 ? "primary.main" : "divider", + borderRadius: 1, + color: activeSlotCount > 0 ? "primary.main" : "inherit", + flexShrink: 0, + }} + > + + + + + + {queueTracker && {queueTracker}} + + {/* Sort sheet — net-new on mobile: cards have no column headers to click */} + setSortOpen(false)} + title="Sort by" + footer={ + + } + > + {sortableColumns.map((column) => { + const current = sorting.find((s) => s.id === column.id); + return ( + cycleSort(column.id)} + sx={{ minHeight: 48, color: current ? "primary.main" : "inherit" }} + > + + {current && ( + + {current.desc ? : } + + )} + + ); + })} + {sorting.length > 0 && ( + <> + + table.setSorting([])} sx={{ minHeight: 48 }}> + + + + + + + )} + + + {/* Filter sheet — presets first, then card fields, then table utilities */} + setFilterOpen(false)} + title="Filters" + footer={ + + } + > + {tablePresetItems.length > 0 && ( + <> + + Presets + + {renderPresetChips(tablePresetItems, "table")} + + )} + {graphPresetItems.length > 0 && ( + <> + + Graph filters + + {renderPresetChips(graphPresetItems, "graph")} + + )} + {columnItems.length > 0 && ( + <> + + Fields shown + + {columnItems.map((column) => ( + onToggleColumn(column.id, column.visible)} + sx={{ minHeight: 44, py: 0 }} + > + + + + ))} + + )} + + { + onResetFilters(); + setFilterOpen(false); + }} + sx={{ minHeight: 48 }} + > + + + + + + {onEditGraphFilters && ( + { + setFilterOpen(false); + onEditGraphFilters(); + }} + sx={{ minHeight: 48 }} + > + + + + + + )} + {exportEnabled && ( + <> + + + + + + + + + + + + + + )} + { + setFilterOpen(false); + onViewApiResponse(); + }} + sx={{ minHeight: 48 }} + > + + + + + + { + onRefresh(); + setFilterOpen(false); + }} + sx={{ minHeight: 48 }} + > + + + + + + + + {/* Bulk action bar — bottom, in thumb reach, instead of the desktop top-toolbar strip */} + {selectMode && selectionEnabled && ( + theme.zIndex.speedDial, + display: "flex", + alignItems: "center", + gap: 1, + px: 1.5, + pt: 1.25, + pb: "calc(env(safe-area-inset-bottom) + 12px)", + bgcolor: "background.paper", + borderTop: 1, + borderColor: "divider", + }} + > + + {selectedCount} selected + + + {customBulkActions.length > 0 && ( + + )} + {!selectModeLocked && ( + + )} + + )} + + {/* Bulk actions sheet — the same customBulkActions + dispatch as the desktop menu */} + setBulkOpen(false)} + title={`${selectedCount} selected · Bulk actions`} + > + {customBulkActions.map((action, index) => ( + { + setBulkOpen(false); + onBulkAction(action); + }} + sx={{ minHeight: 48 }} + > + + {action.icon} + + + + ))} + + + ); +}; diff --git a/frontend/src/components/CippTable/util-mobile-card-slots.js b/frontend/src/components/CippTable/util-mobile-card-slots.js new file mode 100644 index 0000000000..2e1c374699 --- /dev/null +++ b/frontend/src/components/CippTable/util-mobile-card-slots.js @@ -0,0 +1,141 @@ +// Pure slotting function for the mobile card list: decides which visible columns become +// the card title, subtitle, status chips, and detail rows. Runs unattended across every +// table page, so the rules are deliberate: +// +// primary — first NAME_FIELDS match, else first non-status textual column, else the +// first column. Never naively "first column": the users page's first +// simpleColumn is accountEnabled, which would title every card "Yes". +// secondary — first IDENTIFIER_FIELDS match that isn't the primary. +// chips — up to 3 status-like columns (boolean sortingFn, known status ids, or +// small select filters). +// details — up to 3 of whatever remains, in simpleColumns order. +// rest — everything else, surfaced as "+N more fields" -> detail drawer. +// +// Pages that know better pass mobileCard={{primary, secondary, chips, details}} to +// override any slot; ids not present in the visible columns are ignored. + +const NAME_FIELDS = [ + "displayName", + "DisplayName", + "Name", + "name", + "Title", + "title", + "deviceName", + "hostname", + "TenantName", + "Tenant", + "subject", + "RowKey", +]; + +const IDENTIFIER_FIELDS = [ + "userPrincipalName", + "UPN", + "mail", + "primarySmtpAddress", + "defaultDomainName", + "serialNumber", + "id", + "RowKey", +]; + +// Known enum-ish ids that read as status even when their filter variant doesn't say so. +// accountEnabled is here because get-cipp-filter-variant gives it an explicit select case +// with alphanumeric sorting and no options — none of the generic signals fire for it. +const STATUS_FIELDS = new Set( + [ + "severity", + "status", + "state", + "compliancestate", + "risklevel", + "riskstate", + "usertype", + "outcome", + "healthstate", + "isenabled", + "enabled", + "accountenabled", + ].map((f) => f.toLowerCase()) +); + +const columnId = (col) => col?.id ?? col?.columnDef?.id ?? col?.accessorKey; +const columnDef = (col) => col?.columnDef ?? col; + +export const isStatusLike = (col) => { + const def = columnDef(col); + if (def?.sortingFn === "boolean") return true; + const id = String(columnId(col) ?? "").toLowerCase(); + if (STATUS_FIELDS.has(id)) return true; + if ( + def?.filterVariant === "select" && + Array.isArray(def?.filterSelectOptions) && + def.filterSelectOptions.length > 0 && + def.filterSelectOptions.length <= 6 + ) { + return true; + } + return false; +}; + +const firstMatch = (columns, priorityList, exclude = new Set()) => { + for (const fieldName of priorityList) { + const match = columns.find((col) => columnId(col) === fieldName && !exclude.has(col)); + if (match) return match; + } + return null; +}; + +/** + * @param {Array} visibleColumns columns from table.getVisibleLeafColumns() (or any array of + * objects carrying id + columnDef); mrt-* utility columns are filtered out here. + * @param {Object} [override] optional mobileCard prop: {primary, secondary, chips, details} as ids. + * @returns {{primary, secondary, chips: [], details: [], rest: [], restCount: number}} + * primary/secondary are columns (or null); chips/details/rest are column arrays. + */ +export const getMobileCardSlots = (visibleColumns, override = {}) => { + const columns = (visibleColumns ?? []).filter( + (col) => !String(columnId(col) ?? "").startsWith("mrt-") + ); + + if (columns.length === 0) { + return { primary: null, secondary: null, chips: [], details: [], rest: [], restCount: 0 }; + } + + const byId = (id) => columns.find((col) => columnId(col) === id); + const used = new Set(); + + const primary = + (override.primary && byId(override.primary)) || + firstMatch(columns, NAME_FIELDS) || + columns.find((col) => !isStatusLike(col)) || + columns[0]; + used.add(primary); + + const secondary = + (override.secondary && override.secondary !== columnId(primary) && byId(override.secondary)) || + firstMatch(columns, IDENTIFIER_FIELDS, used) || + null; + if (secondary) used.add(secondary); + + let chips; + if (Array.isArray(override.chips)) { + chips = override.chips.map(byId).filter((col) => col && !used.has(col)); + } else { + chips = columns.filter((col) => !used.has(col) && isStatusLike(col)).slice(0, 3); + } + chips.forEach((col) => used.add(col)); + + let details; + if (Array.isArray(override.details)) { + details = override.details.map(byId).filter((col) => col && !used.has(col)); + } else { + details = columns.filter((col) => !used.has(col)).slice(0, 3); + } + details.forEach((col) => used.add(col)); + + const rest = columns.filter((col) => !used.has(col)); + + return { primary, secondary, chips, details, rest, restCount: rest.length }; +}; diff --git a/frontend/src/components/CippTable/util-tablemode.js b/frontend/src/components/CippTable/util-tablemode.js index 8e5120ebb1..bbc66b913d 100644 --- a/frontend/src/components/CippTable/util-tablemode.js +++ b/frontend/src/components/CippTable/util-tablemode.js @@ -1,3 +1,7 @@ +// Card mode renders its own list, so a huge desktop tablePageSize preference must not +// become that many unvirtualized cards. CippMobileCardList grows pageSize from here. +const MOBILE_PAGE_SIZE_CAP = 50 + export const utilTableMode = ( columnVisibility, mode, @@ -6,7 +10,8 @@ export const utilTableMode = ( offCanvas, onChange, maxHeightOffset = '380px', - settings = {} + settings = {}, + viewMode = 'table' ) => { if (mode === true) { return { @@ -42,15 +47,20 @@ export const utilTableMode = ( }, } } else { + const configuredPageSize = settings?.tablePageSize?.value + ? parseInt(settings?.tablePageSize?.value, 10) + : 25 + const isCards = viewMode === 'cards' + return { enableRowSelection: actions || onChange ? true : false, enableRowActions: actions ? true : false, enableSelectAll: true, enableFacetedValues: true, enableColumnFilterModes: true, - enableStickyHeader: true, + enableStickyHeader: !isCards, selectAllMode: 'all', - enableColumnPinning: true, + enableColumnPinning: !isCards, muiPaginationProps: { rowsPerPageOptions: [25, 50, 100, 250, 500], }, @@ -71,15 +81,17 @@ export const utilTableMode = ( showGlobalFilter: true, density: 'compact', pagination: { - pageSize: settings?.tablePageSize?.value - ? parseInt(settings?.tablePageSize?.value, 10) - : 25, + pageSize: isCards + ? Math.min(configuredPageSize, MOBILE_PAGE_SIZE_CAP) + : configuredPageSize, pageIndex: 0, }, - columnPinning: { - left: ['mrt-row-select'], - right: ['mrt-row-actions'], - }, + ...(!isCards && { + columnPinning: { + left: ['mrt-row-select'], + right: ['mrt-row-actions'], + }, + }), }, } } diff --git a/frontend/src/components/actions-menu.js b/frontend/src/components/actions-menu.js index 77a4c1c6a6..f2ae56e897 100644 --- a/frontend/src/components/actions-menu.js +++ b/frontend/src/components/actions-menu.js @@ -2,25 +2,17 @@ import ChevronDownIcon from "@heroicons/react/24/outline/ChevronDownIcon"; import PropTypes from "prop-types"; import { Button, ListItemText, Menu, MenuItem, SvgIcon } from "@mui/material"; import { usePopover } from "../hooks/use-popover"; -import { useState } from "react"; -import { useDialog } from "../hooks/use-dialog"; -import { CippApiDialog } from "./CippComponents/CippApiDialog"; +import { useActionsDispatch } from "../hooks/use-actions-dispatch"; export const ActionsMenu = (props) => { const { actions = [], label = "Actions", data, queryKeys, ...other } = props; const popover = usePopover(); - const [actionData, setActionData] = useState({ data: {}, action: {}, ready: false }); - const createDialog = useDialog(); - const handleActionDisabled = (row, action) => { - //add nullsaftey for row. It can sometimes be undefined(still loading) or null(no data) - if (!row) { - return true; - } - if (action?.condition) { - return !action?.condition(row); - } - return false; - }; + const { visibleActions, isDisabled, dispatch, dialog } = useActionsDispatch({ + actions, + data, + queryKeys, + }); + return ( <> + setOpen(false)} {...sheetProps}> + {children} + + + ) +} + +const actionRows = ['Edit user', 'Reset password', 'Block sign-in'].map((label) => ( + + + +)) + +export default { + title: 'Components/CippComponents/CippBottomSheet', + component: CippBottomSheet, + tags: ['autodocs'], +} + +export const WithTitle = { + render: () => ( + + {actionRows} + + ), + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement) + const body = within(document.body) + + await step('opens on tap and shows its rows', async () => { + await userEvent.click(canvas.getByRole('button', { name: 'Open sheet' })) + await waitFor(() => expect(body.getByText('Row actions')).toBeInTheDocument()) + expect(body.getByText('Reset password')).toBeInTheDocument() + }) + + await step('closes on backdrop tap', async () => { + await userEvent.click(document.querySelector('.MuiBackdrop-root')) + await waitFor(() => expect(body.queryByText('Row actions')).not.toBeInTheDocument()) + }) + }, +} + +export const WithFooter = { + render: () => ( + + Apply to 12 selected + + } + > + {actionRows} + + ), +} + +export const LongContentScrolls = { + render: () => ( + + + {Array.from({ length: 30 }, (_, i) => ( + + + + ))} + + + ), +} + +// Regression guard for the live bug: popout table dialogs sit at zIndex.modal (1300), so a +// plain Drawer (1200) opened from inside one is invisible. The sheet claims modal + 1. +export const OverADialog = { + render: () => { + const [dialogOpen, setDialogOpen] = React.useState(true) + return ( + <> + + setDialogOpen(false)} fullWidth> + + + A popout table lives here. Its filter sheet must layer above this dialog. + + + {actionRows} + + + + + ) + }, + play: async ({ step }) => { + const body = within(document.body) + + await step('sheet renders above the dialog', async () => { + await userEvent.click(body.getByRole('button', { name: 'Open filters' })) + const sheetRoot = await waitFor(() => { + const title = body.getByText('Filters') + return title.closest('.MuiDrawer-root') + }) + const dialogRoot = document.querySelector('.MuiDialog-root') + const sheetZ = Number(window.getComputedStyle(sheetRoot).zIndex) + const dialogZ = Number(window.getComputedStyle(dialogRoot).zIndex) + expect(sheetZ).toBeGreaterThan(dialogZ) + }) + }, +} diff --git a/frontend/tests/components/CippComponents/CippMobileTenantPicker.stories.jsx b/frontend/tests/components/CippComponents/CippMobileTenantPicker.stories.jsx new file mode 100644 index 0000000000..42e8473276 --- /dev/null +++ b/frontend/tests/components/CippComponents/CippMobileTenantPicker.stories.jsx @@ -0,0 +1,93 @@ +import React from 'react' +import { http, HttpResponse } from 'msw' +import { within, expect, userEvent, waitFor } from 'storybook/test' +import { Box, Paper, Stack } from '@mui/material' +import { CippMobileTenantPicker } from '../../../src/components/CippComponents/CippMobileTenantPicker' + +const tenants = [ + { customerId: 'all', displayName: 'All Tenants', defaultDomainName: 'AllTenants' }, + { customerId: 't-1', displayName: 'Contoso Ltd', defaultDomainName: 'contoso.com' }, + { customerId: 't-2', displayName: 'Fabrikam Inc', defaultDomainName: 'fabrikam.com' }, + { customerId: 't-3', displayName: 'Northwind Traders', defaultDomainName: 'northwind.com' }, + { customerId: 't-4', displayName: 'Adventure Works', defaultDomainName: 'adventure-works.com' }, +] + +export default { + title: 'Components/CippComponents/CippMobileTenantPicker', + component: CippMobileTenantPicker, + tags: ['autodocs'], + parameters: { + msw: { + handlers: [http.get('*/api/listTenants', () => HttpResponse.json(tenants))], + }, + }, + decorators: [ + (Story) => ( + // Stands in for the mobile top bar, where the chip takes the width a search icon + // used to occupy (universal search moved into the account menu). + + + + + + + + ), + ], +} + +export const Chip = { + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement) + + await step('chip shows the current tenant name', async () => { + await waitFor(() => expect(canvasElement.textContent).toContain('testdomain.com')) + }) + }, +} + +export const PickerOpen = { + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement) + const body = within(document.body) + + await step('the chip opens a fullscreen picker listing every tenant', async () => { + await userEvent.click(canvas.getByRole('button')) + await waitFor(() => expect(body.getByText('Contoso Ltd')).toBeInTheDocument()) + expect(body.getByText('Fabrikam Inc')).toBeInTheDocument() + expect(body.getByText('All Tenants')).toBeInTheDocument() + }) + }, +} + +export const SearchFiltersTheList = { + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement) + const body = within(document.body) + + await userEvent.click(canvas.getByRole('button')) + await waitFor(() => expect(body.getByText('Contoso Ltd')).toBeInTheDocument()) + + await step('search narrows by display name', async () => { + await userEvent.type(body.getByPlaceholderText(/search/i), 'north') + await waitFor(() => expect(body.queryByText('Contoso Ltd')).toBeNull()) + expect(body.getByText('Northwind Traders')).toBeInTheDocument() + }) + }, +} + +export const FavoritingATenant = { + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement) + const body = within(document.body) + + await userEvent.click(canvas.getByRole('button')) + await waitFor(() => expect(body.getByText('Fabrikam Inc')).toBeInTheDocument()) + + await step('favoriting promotes the tenant into a Favorites section', async () => { + const favoriteButtons = body.getAllByRole('button', { name: /favorite/i }) + await userEvent.click(favoriteButtons[1]) + await waitFor(() => expect(body.getByText('Favorites')).toBeInTheDocument()) + }) + }, +} diff --git a/frontend/tests/components/CippComponents/CippPageActionsFab.stories.jsx b/frontend/tests/components/CippComponents/CippPageActionsFab.stories.jsx new file mode 100644 index 0000000000..bf39c33ceb --- /dev/null +++ b/frontend/tests/components/CippComponents/CippPageActionsFab.stories.jsx @@ -0,0 +1,220 @@ +import React from 'react' +import { within, expect, userEvent, waitFor, fn } from 'storybook/test' +import { + Box, + Button, + Divider, + List, + ListItemButton, + ListItemIcon, + ListItemText, + ListSubheader, + MenuItem, + Typography, +} from '@mui/material' +import { Add, Assessment, Public, Summarize } from '@mui/icons-material' +import { CippPageActionsFab } from '../../../src/components/CippComponents/CippPageActionsFab' +import { TabNavigationContext } from '../../../src/layouts/tab-navigation-context' + +const TABS = [ + { label: 'Edit Tenant', path: '/tenant/manage/edit', icon: 'Settings' }, + { label: 'Manage Drift', path: '/tenant/manage/drift', icon: 'Sync' }, + { label: 'Configuration Backup', path: '/tenant/manage/backup', icon: 'Backup' }, +] + +// Stands in for a tabbed layout: below md those layouts hand their tabs to whichever FAB +// owns the corner rather than rendering a scrollable tab bar or a second FAB. +const withTabs = (Story) => ( + {}, + claim: () => {}, + release: () => {}, + isClaimed: false, + }} + > + + +) + +export default { + title: 'Components/CippComponents/CippPageActionsFab', + component: CippPageActionsFab, + tags: ['autodocs'], + decorators: [ + (Story) => ( + + + Page content. The FAB is fixed to the viewport's bottom-right corner — below md + that corner belongs to page actions (CippSpeedDial hides itself there). + + + + ), + ], +} + +// How table pages use it: cardButton is an arbitrary Box of drawer triggers laid out for a +// desktop CardHeader, restacked vertically by the primitive's descendant CSS. +export const RestackedCardButton = { + render: () => ( + + + + + + + + ), + play: async ({ step }) => { + const body = within(document.body) + + await step('opens the sheet from the FAB', async () => { + await userEvent.click(body.getByRole('button', { name: 'Page actions' })) + await waitFor(() => expect(body.getByText('Actions')).toBeInTheDocument()) + }) + + await step('children are restacked to full width', async () => { + const addButton = body.getByRole('button', { name: 'Add User' }) + expect(window.getComputedStyle(addButton).justifyContent).toBe('flex-start') + }) + + await step('tapping an action closes the sheet', async () => { + await userEvent.click(body.getByRole('button', { name: 'Bulk Add' })) + await waitFor(() => expect(body.queryByText('Actions')).not.toBeInTheDocument()) + }) + }, +} + +// How the dashboard uses it: purpose-built list rows, so restacking is off. +export const DashboardSections = { + render: (args) => ( + + + Portals + + } + > + {['M365', 'Exchange', 'Entra'].map((label) => ( + + + + + + + ))} + + + + Reports + + } + > + {/* ExecutiveReportButton renders exactly this: a MenuItem, not a Button */} + + + + + + + + + + + + + + + ), + args: { + onExecutiveSummary: fn(), + }, + play: async ({ args, step }) => { + const body = within(document.body) + + await step('sections render under their subheaders', async () => { + await userEvent.click(body.getByRole('button', { name: 'Page actions' })) + await waitFor(() => expect(body.getByText('Dashboard actions')).toBeInTheDocument()) + expect(body.getByText('Portals')).toBeInTheDocument() + expect(body.getByText('Reports')).toBeInTheDocument() + }) + + await step('a MenuItem child fires its handler and closes the sheet', async () => { + await userEvent.click(body.getByRole('menuitem', { name: 'Executive Summary' })) + expect(args.onExecutiveSummary).toHaveBeenCalled() + // keepMounted leaves the sheet in the DOM (so ExecutiveReportButton's own preview + // Dialog survives) — closed means hidden here, not unmounted. + await waitFor(() => expect(body.getByText('Dashboard actions')).not.toBeVisible()) + }) + }, +} + +// Under a tabbed layout the sheet carries both the page's own action and the layout's +// views. Every page-actions FAB uses the same neutral glyph — a "+" only ever told the +// truth on pages whose sheet creates things. +export const MixedActionsAndViews = { + decorators: [withTabs], + render: () => ( + + + + ), + play: async ({ step }) => { + const body = within(document.body) + + await step('the FAB carries the one shared glyph', async () => { + const fab = body.getByRole('button', { name: 'Page actions' }) + expect(within(fab).queryByTestId('AddIcon')).toBeNull() + expect(within(fab).getByTestId('MoreHorizIcon')).toBeInTheDocument() + }) + + await step('one sheet holds the page action and the views', async () => { + await userEvent.click(body.getByRole('button', { name: 'Page actions' })) + await waitFor(() => expect(body.getByText('Views')).toBeInTheDocument()) + expect(body.getByRole('button', { name: 'Add Variable' })).toBeInTheDocument() + expect(body.getByText('Manage Drift')).toBeInTheDocument() + }) + + await step('the current view is checked', async () => { + const current = body.getByText('Edit Tenant').closest('[role="button"]') + expect(current).toHaveClass('Mui-selected') + }) + }, +} + +// Nothing else claimed the corner, so the layout supplies the FAB itself: views only. +export const ViewsOnly = { + decorators: [withTabs], + render: () => , + play: async ({ step }) => { + const body = within(document.body) + await step('sheet lists just the views', async () => { + await userEvent.click(body.getByRole('button', { name: 'Views' })) + await waitFor(() => expect(body.getByText('Configuration Backup')).toBeInTheDocument()) + }) + }, +} diff --git a/frontend/tests/components/CippComponents/CippPageActionsFab.test.jsx b/frontend/tests/components/CippComponents/CippPageActionsFab.test.jsx new file mode 100644 index 0000000000..6409f53684 --- /dev/null +++ b/frontend/tests/components/CippComponents/CippPageActionsFab.test.jsx @@ -0,0 +1,108 @@ +import { describe, it, expect, vi } from "vitest"; +import { screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { Button, ListItemButton, MenuItem, Typography } from "@mui/material"; +import { CippPageActionsFab } from "../../../src/components/CippComponents/CippPageActionsFab"; +import { renderWithProviders } from "../../test-utils"; + +const openSheet = async (user, label = "Page actions") => { + await user.click(screen.getByRole("button", { name: label })); + await screen.findByText("Sheet content"); +}; + +describe("CippPageActionsFab", () => { + it("renders the FAB and opens the sheet with its children", async () => { + const user = userEvent.setup(); + renderWithProviders( + + Sheet content + + + ); + + expect(screen.queryByText("Sheet content")).not.toBeInTheDocument(); + await openSheet(user); + + expect(screen.getByText("Sheet content")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Do a thing" })).toBeInTheDocument(); + expect(screen.getByText("Actions")).toBeInTheDocument(); + }); + + it("uses custom title and aria-label", async () => { + const user = userEvent.setup(); + renderWithProviders( + + Sheet content + + ); + + await openSheet(user, "Dashboard shortcuts"); + expect(screen.getByText("Dashboard actions")).toBeInTheDocument(); + }); + + it("closes the sheet when a child button is tapped", async () => { + const user = userEvent.setup(); + const onClick = vi.fn(); + renderWithProviders( + + Sheet content + + + ); + + await openSheet(user); + await user.click(screen.getByRole("button", { name: "Do a thing" })); + + expect(onClick).toHaveBeenCalledTimes(1); + await waitFor(() => expect(screen.queryByText("Sheet content")).not.toBeInTheDocument()); + }); + + it("closes the sheet when a child link is tapped", async () => { + const user = userEvent.setup(); + renderWithProviders( + + Sheet content + + External portal + + + ); + + await openSheet(user); + await user.click(screen.getByRole("link", { name: "External portal" })); + + await waitFor(() => expect(screen.queryByText("Sheet content")).not.toBeInTheDocument()); + }); + + it("closes the sheet when a MenuItem child is tapped", async () => { + // ExecutiveReportButton renders variant="menuItem" — a
  • , not a button + const user = userEvent.setup(); + const onClick = vi.fn(); + renderWithProviders( + + Sheet content + Executive Summary + + ); + + await openSheet(user); + await user.click(screen.getByRole("menuitem", { name: "Executive Summary" })); + + expect(onClick).toHaveBeenCalledTimes(1); + await waitFor(() => expect(screen.queryByText("Sheet content")).not.toBeInTheDocument()); + }); + + it("keeps the sheet open when non-interactive content is tapped", async () => { + const user = userEvent.setup(); + renderWithProviders( + + Sheet content + + ); + + await openSheet(user); + await user.click(screen.getByText("Sheet content")); + + expect(screen.getByText("Sheet content")).toBeInTheDocument(); + }); +}); diff --git a/frontend/tests/components/CippComponents/CippReportToolbar.stories.jsx b/frontend/tests/components/CippComponents/CippReportToolbar.stories.jsx new file mode 100644 index 0000000000..20f37116f8 --- /dev/null +++ b/frontend/tests/components/CippComponents/CippReportToolbar.stories.jsx @@ -0,0 +1,99 @@ +import React from 'react' +import { http, HttpResponse } from 'msw' +import { within, expect, userEvent, waitFor } from 'storybook/test' +import { Box } from '@mui/material' +import { CippReportToolbar } from '../../../src/components/CippComponents/CippReportToolbar' + +const testSuites = [ + { + id: 'ztna', + name: 'Zero Trust Network Access Tests', + description: "Microsoft's comprehensive security assessment", + type: 'builtin', + source: 'file', + }, + { + id: 'custom-1', + name: 'My Custom Suite', + description: 'A tenant-specific suite', + type: 'custom', + source: 'table', + }, +] + +const handlers = [ + http.get('*/api/ListTestReports', () => HttpResponse.json(testSuites)), + http.get('*/api/ListAvailableTests', () => + HttpResponse.json({ IdentityTests: [], DevicesTests: [], CustomTests: [] }) + ), +] + +export default { + title: 'Components/CippComponents/CippReportToolbar', + component: CippReportToolbar, + tags: ['autodocs'], + parameters: { msw: { handlers } }, + decorators: [ + (Story) => ( + + + + ), + ], +} + +// The toolbar picks its layout from useIsMobileLayout (a media query), and no story in this +// repo sets a viewport — so the mobile variant is shown by constraining the container and +// documenting the difference rather than by faking the breakpoint. +export const Desktop = { + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement) + + await step('every suite action is an inline button', async () => { + await waitFor(() => + expect(canvas.getByRole('button', { name: 'Refresh' })).toBeInTheDocument() + ) + expect(canvas.getByRole('button', { name: 'Delete' })).toBeInTheDocument() + expect(canvas.getByRole('button', { name: 'Create Suite' })).toBeInTheDocument() + expect(canvas.getByRole('button', { name: 'Refresh test suites' })).toBeInTheDocument() + expect(canvas.queryByRole('button', { name: 'Test suite actions' })).toBeNull() + }) + }, +} + +// Regression guard for the overflow this refactor fixed: the selector must be allowed to +// shrink (minWidth: 0) so the trailing Delete button stays inside the row. +export const NarrowDesktopKeepsButtonsInView = { + decorators: [ + (Story) => ( + + + + ), + ], + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement) + + await step('the last button is not pushed past the container edge', async () => { + const deleteButton = await waitFor(() => canvas.getByRole('button', { name: 'Delete' })) + const row = deleteButton.closest('div[class*="MuiBox"]').parentElement + expect(deleteButton.getBoundingClientRect().right).toBeLessThanOrEqual( + Math.ceil(row.getBoundingClientRect().right) + 1 + ) + }) + }, +} + +export const SuiteSelection = { + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement) + + await step('the default suite is selected once the list loads', async () => { + // Opening the popper is left to CippAutocomplete's own stories — driving it from here + // crashes the browser tab in this harness. + await waitFor(() => + expect(canvas.getByRole('combobox')).toHaveValue('Zero Trust Network Access Tests') + ) + }) + }, +} diff --git a/frontend/tests/components/CippComponents/CippReportToolbar.test.jsx b/frontend/tests/components/CippComponents/CippReportToolbar.test.jsx new file mode 100644 index 0000000000..4aeaa17764 --- /dev/null +++ b/frontend/tests/components/CippComponents/CippReportToolbar.test.jsx @@ -0,0 +1,207 @@ +import React from "react"; +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { renderWithProviders } from "../../test-utils"; + +// jsdom has no width-based matchMedia, so the mobile branch is driven by mocking the hook +const layoutState = vi.hoisted(() => ({ isMobile: false })); +vi.mock("../../../src/hooks/use-breakpoint", () => ({ + useIsMobileLayout: () => layoutState.isMobile, + useIsTabletLayout: () => false, + useTableViewMode: () => "table", +})); + +// One registration only — ApiCall and ApiCall.jsx resolve to the same module, so a second +// vi.mock for the extensioned path would silently replace this one. +const apiState = vi.hoisted(() => ({ reports: [], refetch: () => {}, reportsResult: null })); +const idlePaginated = vi.hoisted(() => ({ + isSuccess: false, + isFetching: false, + isLoading: false, + isError: false, + data: undefined, + fetchNextPage: () => {}, + refetch: () => {}, +})); +const idlePost = vi.hoisted(() => ({ + mutate: () => {}, + isPending: false, + isSuccess: false, + isError: false, + reset: () => {}, +})); +const idleGet = vi.hoisted(() => ({ + isSuccess: false, + isFetching: false, + isLoading: false, + isError: false, + data: undefined, + refetch: () => {}, +})); +vi.mock("../../../src/api/ApiCall", () => ({ + // Stable result identity per test: a fresh literal each call loops the autocomplete's + // option-mapping effect (see tests/mocks/api-call.js). + ApiGetCall: ({ url }) => + url === "/api/ListTestReports" ? apiState.reportsResult : idleGet, + ApiGetCallWithPagination: () => idlePaginated, + ApiPostCall: () => idlePost, +})); + +const routerState = vi.hoisted(() => ({ push: vi.fn(), query: {} })); +vi.mock("next/router", () => ({ + useRouter: () => ({ + isReady: true, + pathname: "/dashboardv2", + query: routerState.query, + push: routerState.push, + }), +})); + +// The drawer pulls in the whole test-picker form; the toolbar contract under test is only +// "is it open, and with which suite" — so it's stubbed down to those observable facts. +const drawerRenders = vi.hoisted(() => ({ calls: [] })); +vi.mock("../../../src/components/CippComponents/CippAddTestReportDrawer", () => ({ + CippAddTestReportDrawer: (props) => { + drawerRenders.calls.push(props); + if (props.hideTrigger) { + return props.open ? ( +
    + {props.reportToEdit?.name ?? "no-report"} +
    + ) : null; + } + return ; + }, +})); + +vi.mock("../../../src/components/CippComponents/CippApiDialog", () => ({ + CippApiDialog: ({ createDialog, title }) => + createDialog?.open ?
    {title}
    : null, +})); + +import { CippReportToolbar } from "../../../src/components/CippComponents/CippReportToolbar"; + +const CUSTOM_SUITE = { + id: "custom-1", + name: "My Custom Suite", + description: "custom", + type: "custom", + source: "table", +}; +const BUILT_IN_SUITE = { + id: "ztna", + name: "Zero Trust Network Access Tests", + description: "built in", + type: "builtin", + source: "file", +}; + +const openActionSheet = async (user) => { + await user.click(screen.getByRole("button", { name: "Test suite actions" })); + const heading = await screen.findByText("Test suite actions"); + return within(heading.closest(".MuiDrawer-paper")); +}; + +describe("CippReportToolbar", () => { + beforeEach(() => { + layoutState.isMobile = false; + apiState.reports = [BUILT_IN_SUITE, CUSTOM_SUITE]; + apiState.refetch = vi.fn(); + apiState.reportsResult = { + isSuccess: true, + isFetching: false, + isLoading: false, + isError: false, + data: apiState.reports, + refetch: apiState.refetch, + }; + routerState.query = {}; + routerState.push = vi.fn(); + drawerRenders.calls = []; + }); + + it("renders the inline desktop action buttons", () => { + renderWithProviders(); + + expect(screen.getByRole("button", { name: "Refresh" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Delete" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Create Suite" })).toBeInTheDocument(); + // The selector's inline "Refresh test suites" icon button is desktop-only too + expect(screen.getByRole("button", { name: "Refresh test suites" })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Test suite actions" })).not.toBeInTheDocument(); + }); + + it("collapses to selector + kebab on mobile", () => { + layoutState.isMobile = true; + renderWithProviders(); + + expect(screen.getByRole("button", { name: "Test suite actions" })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Delete" })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Refresh" })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Refresh test suites" })).not.toBeInTheDocument(); + expect(screen.getByRole("combobox")).toBeInTheDocument(); + }); + + it("offers all five suite actions in the sheet", async () => { + layoutState.isMobile = true; + const user = userEvent.setup(); + renderWithProviders(); + + const sheet = await openActionSheet(user); + ["Create Suite", "Run Tests", "Edit Suite", "Delete Suite", "Reload suite list"].forEach( + (label) => expect(sheet.getByText(label)).toBeInTheDocument() + ); + }); + + it("disables Edit and Delete with a visible reason for a built-in suite", async () => { + layoutState.isMobile = true; + routerState.query = { reportId: "ztna" }; + const user = userEvent.setup(); + renderWithProviders(); + + const sheet = await openActionSheet(user); + expect(sheet.getByText("Built-in test suites cannot be edited")).toBeInTheDocument(); + expect(sheet.getByText("Built-in test suites cannot be deleted")).toBeInTheDocument(); + expect(sheet.getByText("Edit Suite").closest("[role='button']")).toHaveClass("Mui-disabled"); + }); + + it("opens the run-tests dialog and keeps it mounted after the sheet closes", async () => { + layoutState.isMobile = true; + const user = userEvent.setup(); + renderWithProviders(); + + const sheet = await openActionSheet(user); + await user.click(sheet.getByText("Run Tests")); + + expect(await screen.findByTestId("api-dialog")).toHaveTextContent("Refresh Test Data"); + await waitFor(() => + expect(screen.queryByText("Test suite actions")).not.toBeInTheDocument() + ); + expect(screen.getByTestId("api-dialog")).toBeInTheDocument(); + }); + + it("opens the edit drawer pre-filled with the selected custom suite", async () => { + layoutState.isMobile = true; + routerState.query = { reportId: "custom-1" }; + const user = userEvent.setup(); + renderWithProviders(); + + const sheet = await openActionSheet(user); + await user.click(sheet.getByText("Edit Suite")); + + const drawer = await screen.findByTestId("drawer-edit"); + expect(drawer).toHaveTextContent("My Custom Suite"); + }); + + it("reloads the suite list from the sheet", async () => { + layoutState.isMobile = true; + const user = userEvent.setup(); + renderWithProviders(); + + const sheet = await openActionSheet(user); + await user.click(sheet.getByText("Reload suite list")); + + expect(apiState.refetch).toHaveBeenCalled(); + }); +}); diff --git a/frontend/tests/components/CippTable/CIPPTableToptoolbar.test.jsx b/frontend/tests/components/CippTable/CIPPTableToptoolbar.test.jsx index fcaa292b66..f9f3bae112 100644 --- a/frontend/tests/components/CippTable/CIPPTableToptoolbar.test.jsx +++ b/frontend/tests/components/CippTable/CIPPTableToptoolbar.test.jsx @@ -279,6 +279,96 @@ describe('CIPPTableToptoolbar - preset list refresh', () => { expect(screen.getByRole('button', { name: 'Filters (1)' })).toBeInTheDocument() }) + // Regression: the restore effect used to key on getRequestData.isFetching, re-arming its + // 100ms timer on every fetch settle (once per page of an auto-paginated load) and + // overwriting whatever the user had just applied with the persisted filter. + it('does not clobber a user filter applied after the persisted one was restored', async () => { + const user = userEvent.setup() + renderGraphTable({}, { + settings: settingsWith({ + persistFilters: true, + setLastUsedFilter: vi.fn(), + lastUsedFilters: { + '': { type: 'column', value: [{ id: 'department', value: 'IT' }], name: 'IT only' }, + }, + }), + }) + // persisted "IT only" lands first + await waitFor(() => { + expect(screen.getByText('1-2 of 2')).toBeInTheDocument() + }, { timeout: 5000 }) + + // user switches to the other preset + await user.click(screen.getByRole('button', { name: /Filters/ })) + await user.click(await screen.findByRole('menuitem', { name: 'Sales only' })) + await waitFor(() => { + expect(screen.getByText('1-1 of 1')).toBeInTheDocument() + }) + + // well past the restore timer: the persisted filter must not come back + await new Promise((resolve) => setTimeout(resolve, 400)) + expect(screen.getByText('1-1 of 1')).toBeInTheDocument() + }, 30000) + + it('syncs the search box when a global preset is applied and cleared', async () => { + const user = userEvent.setup() + renderGraphTable({ + filters: [{ filterName: 'Named Alice', value: 'alice', type: 'global' }], + }) + await screen.findByText('1-3 of 3') + + await user.click(screen.getByRole('button', { name: /Filters/ })) + await user.click(await screen.findByRole('menuitem', { name: 'Named Alice' })) + await waitFor(() => { + expect(screen.getByPlaceholderText('Search...')).toHaveValue('alice') + }) + + // tapping the active preset again clears the slot — and the box with it + await user.click(screen.getByRole('button', { name: /Filters/ })) + await user.click(await screen.findByRole('menuitem', { name: 'Named Alice' })) + await waitFor(() => { + expect(screen.getByPlaceholderText('Search...')).toHaveValue('') + }) + }, 30000) + + // filterList was state-initialised from the prop and never re-synced, so pages that + // compute `filters` asynchronously showed an empty preset list forever + it('picks up filters that arrive after the first render', async () => { + const user = userEvent.setup() + presetsResult = graphPresetResult + + const LateFilters = () => { + const [filters, setFilters] = React.useState([]) + return ( + <> + + + + ) + } + + renderWithProviders() + await screen.findByText('1-3 of 3') + + await user.click(screen.getByRole('button', { name: /Filters/ })) + expect(screen.queryByRole('menuitem', { name: 'IT only' })).toBeNull() + await user.keyboard('{Escape}') + + await user.click(screen.getByRole('button', { name: 'load filters' })) + await user.click(screen.getByRole('button', { name: /Filters/ })) + expect(await screen.findByRole('menuitem', { name: 'IT only' })).toBeInTheDocument() + // the fetched graph preset is not lost when the prop-driven list arrives + expect(screen.getByRole('menuitem', { name: 'Widget View' })).toBeInTheDocument() + }, 30000) + it('renaming an applied graph preset keeps it marked active', async () => { const user = userEvent.setup() renderGraphTable() diff --git a/frontend/tests/components/CippTable/CippDataTable.test.jsx b/frontend/tests/components/CippTable/CippDataTable.test.jsx index bdc5023185..c58f7a22ff 100644 --- a/frontend/tests/components/CippTable/CippDataTable.test.jsx +++ b/frontend/tests/components/CippTable/CippDataTable.test.jsx @@ -1,5 +1,6 @@ import React from 'react' import { screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' import { vi } from 'vitest' import { renderWithProviders } from '../../test-utils' import { CippDataTable } from '../../../src/components/CippTable/CippDataTable' @@ -310,3 +311,118 @@ describe('CippDataTable', () => { expect(container.querySelector('table')).not.toBeNull() }) }) + +// A card shows a title, subtitle and a few chips/details — on pages that never configured +// an offCanvas the rest of the row used to be unreachable in card view. +describe('CippDataTable card view without an offCanvas', () => { + const wideData = [ + { + displayName: 'Alice Smith', + mail: 'alice@contoso.com', + department: 'IT', + jobTitle: 'Engineer', + city: 'Seattle', + country: 'US', + accountEnabled: true, + }, + ] + const columns = ['displayName', 'mail', 'department', 'jobTitle', 'city', 'country'] + + it('opens an extended-info drawer from a card tap showing every shown column', async () => { + const user = userEvent.setup() + renderWithProviders( + + ) + + await waitFor(() => expect(screen.getByText('Alice Smith')).toBeInTheDocument()) + await user.click(screen.getByText('Alice Smith')) + + // fields that never fit on the card are present in the drawer + await waitFor(() => expect(screen.getAllByText(/Engineer/).length).toBeGreaterThan(0)) + expect(screen.getAllByText(/Seattle/).length).toBeGreaterThan(0) + }) + + it('formats fallback values the way their table cells do', async () => { + const user = userEvent.setup() + renderWithProviders( + + ) + + await waitFor(() => expect(screen.getByText('Alice Smith')).toBeInTheDocument()) + await user.click(screen.getByText('Alice Smith')) + + // 'text' mode would flatten the boolean to the string "Yes"; the cell renderer uses an icon + await waitFor(() => expect(screen.getAllByText(/contoso\.com/).length).toBeGreaterThan(0)) + expect(screen.queryByText('Yes')).toBeNull() + }) + + it('spells out portal links instead of showing a bare icon', async () => { + const user = userEvent.setup() + renderWithProviders( + + ) + + await waitFor(() => expect(screen.getByText('Contoso')).toBeInTheDocument()) + await user.click(screen.getByText('Contoso')) + + const link = await screen.findByRole('link', { name: /open portal/i }) + expect(link).toHaveAttribute('href', 'https://admin.cloud.microsoft/?delegatedOrg=contoso') + expect(link).toHaveAttribute('target', '_blank') + }) + + it('links portal values on the card itself, scheme-less ones included', async () => { + renderWithProviders( + + ) + + await waitFor(() => expect(screen.getByText('Contoso')).toBeInTheDocument()) + // rendered on the card, without opening the drawer + const link = await screen.findByRole('link', { name: /open portal/i }) + expect(link).toHaveAttribute('href', 'https://contoso-admin.sharepoint.com') + }) + + it('leaves a page-supplied offCanvas in charge', async () => { + const user = userEvent.setup() + renderWithProviders( + + ) + + await waitFor(() => expect(screen.getByText('Alice Smith')).toBeInTheDocument()) + await user.click(screen.getByText('Alice Smith')) + + // the page's own drawer opens — the fallback never substitutes for a configured one + await waitFor(() => expect(screen.getByText('User Details')).toBeInTheDocument()) + expect(screen.getAllByText(/Seattle/).length).toBeGreaterThan(0) + }) +}) diff --git a/frontend/tests/components/CippTable/CippGraphExplorerFilter.test.jsx b/frontend/tests/components/CippTable/CippGraphExplorerFilter.test.jsx index 497382cdca..49df07503e 100644 --- a/frontend/tests/components/CippTable/CippGraphExplorerFilter.test.jsx +++ b/frontend/tests/components/CippTable/CippGraphExplorerFilter.test.jsx @@ -276,4 +276,48 @@ describe('CippGraphExplorerFilter', () => { expect(onSubmitFilter.mock.calls[0][0]).toEqual({ version: 'beta' }) }) }) + + // Seeding from endpointFilter moved out of the render body (it updated the subscribed + // Controller mid-render, which the browser reports as "Cannot update a component while + // rendering a different component"). These cover the behaviour that move had to preserve — + // the warning itself doesn't reproduce under jsdom, so it can't be asserted here. + describe('endpointFilter prop', () => { + it('seeds the endpoint field from the prop', async () => { + renderWithProviders( + + ) + + await waitFor(() => { + expect(screen.getByRole('textbox', { name: 'Endpoint' })).toHaveValue('users') + }) + }) + + it('submits the seeded endpoint', async () => { + const onSubmitFilter = vi.fn() + const user = userEvent.setup() + renderWithProviders( + + ) + await waitFor(() => { + expect(screen.getByRole('textbox', { name: 'Endpoint' })).toHaveValue('users') + }) + + await user.click(screen.getByRole('button', { name: 'Apply Filter' })) + await waitFor(() => { + expect(onSubmitFilter).toHaveBeenCalledTimes(1) + }) + expect(onSubmitFilter.mock.calls[0][0]).toMatchObject({ endpoint: 'users' }) + }) + + it('leaves the endpoint field empty when no endpointFilter is given', async () => { + renderWithProviders() + await waitFor(() => { + expect(screen.getByRole('textbox', { name: 'Endpoint' })).toHaveValue('') + }) + }) + }) }) diff --git a/frontend/tests/components/CippTable/CippMobileCardList.stories.jsx b/frontend/tests/components/CippTable/CippMobileCardList.stories.jsx new file mode 100644 index 0000000000..9c06031353 --- /dev/null +++ b/frontend/tests/components/CippTable/CippMobileCardList.stories.jsx @@ -0,0 +1,244 @@ +import React from 'react' +import { within, expect, userEvent, waitFor } from 'storybook/test' +import { Box, Button } from '@mui/material' +import { Add, Block, Delete, Edit } from '@mui/icons-material' +import { CippDataTable } from '../../../src/components/CippTable/CippDataTable' +import { SettingsProvider } from '../../../src/contexts/settings-context' + +// Card view is normally chosen by viewport (below md), but no story in this repo sets a +// viewport — the explicit viewMode prop is the supported override and is what the unit +// tests use too. +const users = [ + { + id: 'u-1', + displayName: 'Alice Smith', + userPrincipalName: 'alice@contoso.com', + mail: 'alice@contoso.com', + department: 'IT', + jobTitle: 'Engineer', + accountEnabled: true, + createdDateTime: '2024-01-15T10:30:00Z', + }, + { + id: 'u-2', + displayName: 'Bob Johnson', + userPrincipalName: 'bob@contoso.com', + mail: 'bob@contoso.com', + department: 'Sales', + jobTitle: 'Account Manager', + accountEnabled: true, + createdDateTime: '2024-03-22T14:15:00Z', + }, + { + id: 'u-3', + displayName: 'Carol Williams', + userPrincipalName: 'carol@contoso.com', + mail: 'carol@contoso.com', + department: 'IT', + jobTitle: 'Director', + accountEnabled: false, + createdDateTime: '2023-11-01T09:00:00Z', + }, +] + +const manyUsers = Array.from({ length: 120 }, (_, i) => ({ + id: `bulk-${i}`, + displayName: `User ${String(i).padStart(3, '0')}`, + userPrincipalName: `user${i}@contoso.com`, + mail: `user${i}@contoso.com`, + department: i % 2 ? 'Sales' : 'IT', + accountEnabled: i % 5 !== 0, +})) + +const simpleColumns = ['displayName', 'userPrincipalName', 'accountEnabled', 'department', 'jobTitle'] + +const actions = [ + { label: 'Edit user', icon: , link: '/identity/administration/users/edit?id=[id]' }, + { label: 'Block sign-in', icon: , type: 'POST', url: '/api/ExecDisableUser' }, + { label: 'Delete user', icon: , type: 'POST', url: '/api/RemoveUser', color: 'error' }, +] + +export default { + title: 'Components/CippTable/CippMobileCardList', + component: CippDataTable, + tags: ['autodocs'], + args: { + viewMode: 'cards', + maxHeightOffset: '100px', + }, + decorators: [ + (Story) => ( + + + + + + ), + ], +} + +export const Default = { + args: { + title: 'Users', + data: users, + simpleColumns, + actions, + }, + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement) + + await step('one card per row, titled by the name column', async () => { + await waitFor(() => expect(canvas.getByText('Alice Smith')).toBeInTheDocument()) + expect(canvas.getByText('Carol Williams')).toBeInTheDocument() + // no in card view + expect(canvasElement.querySelector('table')).toBeNull() + }) + + await step('row kebab opens the action sheet with the page actions', async () => { + const kebabs = canvas.getAllByRole('button', { name: /row actions/i }) + await userEvent.click(kebabs[0]) + const body = within(document.body) + await waitFor(() => expect(body.getByText('Block sign-in')).toBeInTheDocument()) + expect(body.getByText('Delete user')).toBeInTheDocument() + }) + }, +} + +export const SelectMode = { + args: { + title: 'Users', + data: users, + simpleColumns, + actions, + }, + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement) + await waitFor(() => expect(canvas.getByText('Alice Smith')).toBeInTheDocument()) + + await step('Select reveals per-card checkboxes and the bulk bar', async () => { + await userEvent.click(canvas.getByRole('button', { name: /select/i })) + const checkboxes = await canvas.findAllByRole('checkbox') + await userEvent.click(checkboxes[0]) + await waitFor(() => expect(canvasElement.textContent).toContain('1 selected')) + }) + }, +} + +export const PageActionsFab = { + args: { + title: 'Users', + data: users, + simpleColumns, + cardButton: ( + + + + + ), + }, + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement) + const body = within(document.body) + await waitFor(() => expect(canvas.getByText('Alice Smith')).toBeInTheDocument()) + + await step('cardButton children live behind the FAB', async () => { + await userEvent.click(body.getByRole('button', { name: 'Page actions' })) + await waitFor(() => expect(body.getByRole('button', { name: 'Add User' })).toBeInTheDocument()) + expect(body.getByRole('button', { name: 'Bulk Add' })).toBeInTheDocument() + }) + }, +} + +export const LoadMore = { + args: { + title: 'Users', + data: manyUsers, + simpleColumns: ['displayName', 'userPrincipalName', 'accountEnabled'], + }, + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement) + + await step('starts at the configured page size', async () => { + await waitFor(() => expect(canvasElement.textContent).toContain('Showing 25 of 120'), { + timeout: 10000, + }) + }) + + await step('Load more grows the same list rather than paging', async () => { + await userEvent.click(canvas.getByRole('button', { name: /load 50 more/i })) + await waitFor(() => expect(canvasElement.textContent).toContain('Showing 75 of 120')) + // still one continuous list — no pagination control appeared + expect(canvas.queryByRole('button', { name: /go to next page/i })).toBeNull() + }) + }, +} + +export const EmptyAfterFilter = { + args: { + title: 'Users', + data: users, + simpleColumns, + }, + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement) + await waitFor(() => expect(canvas.getByText('Alice Smith')).toBeInTheDocument()) + + await step('a search with no matches offers to clear filters', async () => { + await userEvent.type(canvas.getByPlaceholderText(/search/i), 'zzzzz') + await waitFor( + () => expect(canvas.getByRole('button', { name: /clear filters/i })).toBeInTheDocument(), + { timeout: 3000 } + ) + }) + }, +} + +// The pair that proves "one table instance, two presentations": same data, same filter, +// same resulting row set — only the presentation differs. +const FILTERED_DEPARTMENT = 'IT' + +// The search box is debounced 200ms, so the filter landing is observed by waiting for the +// excluded row to disappear — the included rows are on screen before the filter applies. +const applyDepartmentSearch = async (canvas) => { + await userEvent.type(canvas.getByPlaceholderText(/search/i), FILTERED_DEPARTMENT) + await waitFor(() => expect(canvas.queryByText('Bob Johnson')).toBeNull(), { timeout: 5000 }) + return [canvas.getByText('Alice Smith'), canvas.getByText('Carol Williams')] +} + +export const DesktopTable = { + args: { + title: 'Users', + viewMode: 'table', + data: users, + simpleColumns, + }, + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement) + + await step('table view lists exactly the IT users', async () => { + expect(canvasElement.querySelector('table')).not.toBeNull() + const matched = await applyDepartmentSearch(canvas) + expect(matched).toHaveLength(2) + }) + }, +} + +export const MobileCards = { + args: { + title: 'Users', + viewMode: 'cards', + data: users, + simpleColumns, + }, + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement) + + await step('card view yields the identical row set from the same state', async () => { + expect(canvasElement.querySelector('table')).toBeNull() + const matched = await applyDepartmentSearch(canvas) + expect(matched).toHaveLength(2) + }) + }, +} diff --git a/frontend/tests/components/CippTable/util-mobile-card-slots.test.js b/frontend/tests/components/CippTable/util-mobile-card-slots.test.js new file mode 100644 index 0000000000..0cf344b0eb --- /dev/null +++ b/frontend/tests/components/CippTable/util-mobile-card-slots.test.js @@ -0,0 +1,142 @@ +import { getMobileCardSlots, isStatusLike } from '../../../src/components/CippTable/util-mobile-card-slots' + +// Shorthand column factory mirroring what table.getVisibleLeafColumns() yields +const col = (id, def = {}) => ({ id, columnDef: { id, ...def } }) +const bool = (id) => col(id, { sortingFn: 'boolean', filterVariant: 'select', filterSelectOptions: ['Yes', 'No'] }) + +const ids = (cols) => cols.map((c) => c.id) + +// The real /identity/administration/users simpleColumns, in page order. +// accountEnabled mirrors its explicit get-cipp-filter-variant case: select variant, +// alphanumeric sorting, NO options — only the STATUS_FIELDS id match can catch it. +const USERS_COLUMNS = [ + col('accountEnabled', { filterVariant: 'select', sortingFn: 'alphanumeric', filterFn: 'equals' }), + col('userPrincipalName'), + col('displayName'), + col('mail'), + col('businessPhones'), + col('proxyAddresses'), + col('assignedLicenses'), + col('licenseAssignmentStates'), + col('userType', { filterVariant: 'select', filterSelectOptions: ['Member', 'Guest'] }), +] + +describe('getMobileCardSlots', () => { + it('resolves the users page correctly — never titles cards "Yes"', () => { + const slots = getMobileCardSlots(USERS_COLUMNS) + expect(slots.primary.id).toBe('displayName') + expect(slots.secondary.id).toBe('userPrincipalName') + expect(ids(slots.chips)).toEqual(['accountEnabled', 'userType']) + expect(ids(slots.details)).toEqual(['mail', 'businessPhones', 'proxyAddresses']) + expect(ids(slots.rest)).toEqual(['assignedLicenses', 'licenseAssignmentStates']) + expect(slots.restCount).toBe(2) + }) + + it('filters out mrt-* utility columns', () => { + const slots = getMobileCardSlots([col('mrt-row-select'), col('displayName'), col('mrt-row-actions')]) + expect(slots.primary.id).toBe('displayName') + expect(slots.secondary).toBeNull() + expect(slots.restCount).toBe(0) + }) + + it('handles an empty column set', () => { + expect(getMobileCardSlots([])).toEqual({ + primary: null, + secondary: null, + chips: [], + details: [], + rest: [], + restCount: 0, + }) + expect(getMobileCardSlots(undefined).primary).toBeNull() + }) + + it('handles a single column', () => { + const slots = getMobileCardSlots([col('Tenant')]) + expect(slots.primary.id).toBe('Tenant') + expect(slots.secondary).toBeNull() + expect(slots.chips).toEqual([]) + expect(slots.details).toEqual([]) + }) + + it('falls back to first non-status textual column when nothing matches NAME_FIELDS', () => { + const slots = getMobileCardSlots([bool('isCompliant'), col('osVersion'), col('manufacturer')]) + expect(slots.primary.id).toBe('osVersion') + expect(ids(slots.chips)).toEqual(['isCompliant']) + expect(ids(slots.details)).toEqual(['manufacturer']) + }) + + it('falls back to the first column when everything is status-like', () => { + const slots = getMobileCardSlots([bool('enabled'), bool('isCompliant')]) + expect(slots.primary.id).toBe('enabled') + expect(ids(slots.chips)).toEqual(['isCompliant']) + }) + + it('caps chips at 3 and details at 3, remainder goes to rest', () => { + const slots = getMobileCardSlots([ + col('displayName'), + bool('a'), bool('b'), bool('c'), bool('d'), + col('e'), col('f'), col('g'), col('h'), + ]) + expect(ids(slots.chips)).toEqual(['a', 'b', 'c']) + // 'd' overflowed the chip cap — it flows into details ("whatever remains"), not rest + expect(ids(slots.details)).toEqual(['d', 'e', 'f']) + expect(ids(slots.rest)).toEqual(['g', 'h']) + }) + + it('respects mobileCard overrides for every slot', () => { + const slots = getMobileCardSlots(USERS_COLUMNS, { + primary: 'userPrincipalName', + secondary: 'mail', + chips: ['userType'], + details: ['assignedLicenses'], + }) + expect(slots.primary.id).toBe('userPrincipalName') + expect(slots.secondary.id).toBe('mail') + expect(ids(slots.chips)).toEqual(['userType']) + expect(ids(slots.details)).toEqual(['assignedLicenses']) + // everything unassigned lands in rest + expect(ids(slots.rest)).toEqual([ + 'accountEnabled', + 'displayName', + 'businessPhones', + 'proxyAddresses', + 'licenseAssignmentStates', + ]) + }) + + it('ignores override ids that are not visible and empty override arrays fall through to rest', () => { + const slots = getMobileCardSlots(USERS_COLUMNS, { primary: 'notAColumn', chips: [], details: [] }) + expect(slots.primary.id).toBe('displayName') // heuristic fallback + expect(slots.chips).toEqual([]) + expect(slots.details).toEqual([]) + expect(slots.restCount).toBe(7) + }) + + it('secondary never duplicates primary', () => { + const slots = getMobileCardSlots([col('RowKey'), col('Timestamp')]) + // RowKey matches both NAME_FIELDS and IDENTIFIER_FIELDS — must not appear twice + expect(slots.primary.id).toBe('RowKey') + expect(slots.secondary).toBeNull() + expect(ids(slots.details)).toEqual(['Timestamp']) + }) +}) + +describe('isStatusLike', () => { + it('detects boolean sortingFn (the get-cipp-filter-variant signal)', () => { + expect(isStatusLike(bool('anything'))).toBe(true) + }) + it('detects known status ids case-insensitively', () => { + expect(isStatusLike(col('complianceState'))).toBe(true) + expect(isStatusLike(col('Severity'))).toBe(true) + }) + it('detects small select filters, rejects large ones', () => { + expect(isStatusLike(col('x', { filterVariant: 'select', filterSelectOptions: ['a', 'b'] }))).toBe(true) + expect( + isStatusLike(col('x', { filterVariant: 'select', filterSelectOptions: ['a', 'b', 'c', 'd', 'e', 'f', 'g'] })) + ).toBe(false) + }) + it('rejects plain text columns', () => { + expect(isStatusLike(col('displayName'))).toBe(false) + }) +}) diff --git a/frontend/tests/hooks/use-breakpoint.test.jsx b/frontend/tests/hooks/use-breakpoint.test.jsx new file mode 100644 index 0000000000..6a11579a46 --- /dev/null +++ b/frontend/tests/hooks/use-breakpoint.test.jsx @@ -0,0 +1,43 @@ +import React from 'react' +import { screen } from '@testing-library/react' +import { renderWithProviders, settingsWith } from '../test-utils' +import { useTableViewMode } from '../../src/hooks/use-breakpoint' + +// jsdom has no width-based matchMedia, so useIsMobileLayout is always false here — +// which is exactly why the explicit settings/prop path must exist and is what we test. +const Probe = (props) =>
    {useTableViewMode(props)}
    + +const renderMode = (props, settings) => + renderWithProviders(, settings ? { settings: settingsWith(settings) } : undefined) + +describe('useTableViewMode', () => { + it("defaults to 'table' on desktop-width (auto + not mobile)", () => { + renderMode() + expect(screen.getByTestId('mode')).toHaveTextContent('table') + }) + + it('settings.tableViewMode=cards forces cards', () => { + renderMode({}, { tableViewMode: 'cards' }) + expect(screen.getByTestId('mode')).toHaveTextContent('cards') + }) + + it('accepts {value,label} shaped settings', () => { + renderMode({}, { tableViewMode: { value: 'cards', label: 'Card list' } }) + expect(screen.getByTestId('mode')).toHaveTextContent('cards') + }) + + it('per-call viewMode prop beats settings', () => { + renderMode({ viewMode: 'table' }, { tableViewMode: 'cards' }) + expect(screen.getByTestId('mode')).toHaveTextContent('table') + }) + + it('simple always forces table, even against explicit cards', () => { + renderMode({ viewMode: 'cards', simple: true }, { tableViewMode: 'cards' }) + expect(screen.getByTestId('mode')).toHaveTextContent('table') + }) + + it('invalid mode values fall back to auto behavior', () => { + renderMode({}, { tableViewMode: 'bogus' }) + expect(screen.getByTestId('mode')).toHaveTextContent('table') + }) +}) diff --git a/frontend/tests/layouts/TabbedLayout.test.jsx b/frontend/tests/layouts/TabbedLayout.test.jsx new file mode 100644 index 0000000000..572a78ec29 --- /dev/null +++ b/frontend/tests/layouts/TabbedLayout.test.jsx @@ -0,0 +1,165 @@ +import React from "react"; +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { Button } from "@mui/material"; +import { renderWithProviders } from "../test-utils"; + +// jsdom has no width-based matchMedia, so the mobile branch is driven by mocking the hook +const layoutState = vi.hoisted(() => ({ isMobile: false })); +vi.mock("../../src/hooks/use-breakpoint", () => ({ + useIsMobileLayout: () => layoutState.isMobile, + useIsTabletLayout: () => false, + useTableViewMode: () => "table", +})); + +const routerState = vi.hoisted(() => ({ push: vi.fn(), pathname: "/dashboardv2" })); +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: routerState.push }), + usePathname: () => routerState.pathname, + useSearchParams: () => new URLSearchParams(""), +})); + +vi.mock("../../src/api/ApiCall", () => ({ + ApiGetCall: () => ({ isSuccess: false, isFetching: false, data: undefined }), +})); + +import { TabbedLayout } from "../../src/layouts/TabbedLayout"; +import { CippPageActionsFab } from "../../src/components/CippComponents/CippPageActionsFab"; + +const tabOptions = [ + { label: "Overview", path: "/dashboardv2", icon: "Dashboard" }, + { label: "Identity", path: "/dashboardv2/identity", icon: "Person" }, + { label: "Devices", path: "/dashboardv2/devices", icon: "Devices" }, +]; + +const openFab = async (user, name = "Views") => { + await user.click(screen.getByRole("button", { name })); +}; + +describe("TabbedLayout", () => { + beforeEach(() => { + layoutState.isMobile = false; + routerState.push = vi.fn(); + routerState.pathname = "/dashboardv2"; + }); + + it("renders a tab bar on desktop and no FAB", () => { + renderWithProviders( + +
    page content
    +
    + ); + + expect(screen.getByRole("tab", { name: /Overview/ })).toBeInTheDocument(); + expect(screen.getByRole("tab", { name: /Devices/ })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Views" })).not.toBeInTheDocument(); + }); + + it("replaces the tab bar with a Views FAB on mobile", async () => { + layoutState.isMobile = true; + const user = userEvent.setup(); + renderWithProviders( + +
    page content
    +
    + ); + + expect(screen.queryByRole("tab")).not.toBeInTheDocument(); + await openFab(user); + + // every tab is a full-width row now, none of them scrolled off the edge + expect(await screen.findByText("Overview")).toBeInTheDocument(); + tabOptions.forEach((tab) => expect(screen.getByText(tab.label)).toBeInTheDocument()); + }); + + it("navigates when a tab row is tapped, and does nothing for the current tab", async () => { + layoutState.isMobile = true; + const user = userEvent.setup(); + renderWithProviders( + +
    page content
    +
    + ); + + await openFab(user); + await user.click(await screen.findByText("Devices")); + expect(routerState.push).toHaveBeenCalledWith("/dashboardv2/devices"); + + routerState.push = vi.fn(); + await openFab(user); + await user.click(await screen.findByText("Overview")); + expect(routerState.push).not.toHaveBeenCalled(); + }); + + // The corner fits one FAB, and about half the tabbed pages already grow one from a + // table's cardButton — the page's FAB must adopt the tabs rather than stack beside it. + it("hands the tabs to a page FAB instead of adding a second one", async () => { + layoutState.isMobile = true; + const user = userEvent.setup(); + renderWithProviders( + + + + + + ); + + await waitFor(() => + expect(screen.queryByRole("button", { name: "Views" })).not.toBeInTheDocument() + ); + const fabs = screen.getAllByRole("button", { name: /Page actions|Views/ }); + expect(fabs).toHaveLength(1); + + await user.click(fabs[0]); + expect(await screen.findByRole("button", { name: "Add Variable" })).toBeInTheDocument(); + expect(screen.getByText("Identity")).toBeInTheDocument(); + }); + + // The sheet heading and the section subheader were both saying "Views" + it("names the views once when the sheet holds nothing else", async () => { + layoutState.isMobile = true; + const user = userEvent.setup(); + renderWithProviders( + +
    page content
    +
    + ); + + await openFab(user); + await screen.findByText("Overview"); + expect(screen.getAllByText("Views")).toHaveLength(1); + }); + + it("labels both sections when a page action shares the sheet", async () => { + layoutState.isMobile = true; + const user = userEvent.setup(); + renderWithProviders( + + + + + + ); + + await user.click(screen.getByRole("button", { name: "Page actions" })); + await screen.findByText("Overview"); + // one "Views" subheader, and no sheet title repeating it + expect(screen.getAllByText("Views")).toHaveLength(1); + expect(screen.queryByText("Actions")).not.toBeInTheDocument(); + }); + + it("hides tabs that the user's advanced setting gates off", async () => { + layoutState.isMobile = true; + const user = userEvent.setup(); + renderWithProviders( + +
    page content
    +
    + ); + + await openFab(user); + await screen.findByText("Overview"); + expect(screen.queryByText("Diagnostics")).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/tests/utils/get-filtered-portals.test.js b/frontend/tests/utils/get-filtered-portals.test.js new file mode 100644 index 0000000000..bbd1fbc0ad --- /dev/null +++ b/frontend/tests/utils/get-filtered-portals.test.js @@ -0,0 +1,49 @@ +import { describe, it, expect } from "vitest"; +import { getFilteredPortals } from "../../src/utils/get-filtered-portals"; +import Portals from "../../src/data/portals"; + +const names = (portals) => portals.map((p) => p.name); + +// Pre-existing mismatch, documented rather than fixed here: portals.json splits Power +// Platform into _Admin/_Maker entries, while the defaults map (and the preferences toggle, +// and dashboardv1) still key on the un-suffixed Power_Platform_Portal — so those two are +// filtered out for everyone and their preference toggle controls nothing. +const UNREACHABLE_BY_DEFAULT = ["Power_Platform_Portal_Admin", "Power_Platform_Portal_Maker"]; +const defaultVisible = names(Portals).filter((n) => !UNREACHABLE_BY_DEFAULT.includes(n)); + +describe("getFilteredPortals", () => { + it("returns every default-on portal when settings carry no preferences", () => { + expect(names(getFilteredPortals({}))).toEqual(defaultVisible); + }); + + it("tolerates undefined settings", () => { + expect(names(getFilteredPortals(undefined))).toEqual(defaultVisible); + }); + + it("hides a portal turned off in UserSpecificSettings", () => { + const result = getFilteredPortals({ + UserSpecificSettings: { portalLinks: { Exchange_Portal: false } }, + }); + + expect(names(result)).not.toContain("Exchange_Portal"); + expect(names(result)).toContain("M365_Portal"); + }); + + it("falls back to tenant-level portalLinks when no user-specific ones exist", () => { + const result = getFilteredPortals({ portalLinks: { Azure_Portal: false } }); + + expect(names(result)).not.toContain("Azure_Portal"); + expect(names(result)).toContain("M365_Portal"); + }); + + it("prefers UserSpecificSettings over tenant-level portalLinks", () => { + const result = getFilteredPortals({ + portalLinks: { Teams_Portal: false }, + UserSpecificSettings: { portalLinks: { Entra_Portal: false } }, + }); + + // The user-specific object wins outright — the tenant-level opt-out is not merged in. + expect(names(result)).toContain("Teams_Portal"); + expect(names(result)).not.toContain("Entra_Portal"); + }); +}); From 7b060d9351bb6c06719c9ce8f130a10da31920da Mon Sep 17 00:00:00 2001 From: John Duprey Date: Sun, 9 Aug 2026 09:29:21 -0400 Subject: [PATCH 004/226] fix(mobile): prevent modal race on bottom sheet handoff Introduce `useSheetHandoff` hook that parks the follow-up action and runs it only after the Drawer's exit transition fires (`onExited`). This prevents two MUI Modals from being in-flight simultaneously, which caused the new overlay to open dead or not at all. Also fix iOS Safari viewport zoom by bumping input font size to 16px on coarse-pointer devices, and merge page-configured `extendedInfoFields` with remaining visible columns in card view instead of dropping them. --- .../CippComponents/CippBottomSheet.jsx | 5 +- .../CippComponents/CippPageActionsFab.jsx | 10 +- .../CippComponents/CippReportToolbar.jsx | 31 ++---- .../src/components/CippTable/CippDataTable.js | 42 +++++--- .../CippTable/CippMobileCardList.jsx | 18 ++-- .../CippTable/CippMobileTableControls.jsx | 26 +++-- frontend/src/hooks/use-sheet-handoff.js | 59 +++++++++++ frontend/src/theme/base/create-components.js | 10 ++ .../CippComponents/CippReportToolbar.test.jsx | 3 +- .../CippTable/CippDataTable.test.jsx | 47 ++++++++- .../tests/hooks/use-sheet-handoff.test.jsx | 64 ++++++++++++ .../layouts/HeaderedTabbedLayout.test.jsx | 97 +++++++++++++++++++ frontend/tests/theme/input-zoom.test.js | 16 +++ 13 files changed, 365 insertions(+), 63 deletions(-) create mode 100644 frontend/src/hooks/use-sheet-handoff.js create mode 100644 frontend/tests/hooks/use-sheet-handoff.test.jsx create mode 100644 frontend/tests/layouts/HeaderedTabbedLayout.test.jsx create mode 100644 frontend/tests/theme/input-zoom.test.js diff --git a/frontend/src/components/CippComponents/CippBottomSheet.jsx b/frontend/src/components/CippComponents/CippBottomSheet.jsx index 2712725a43..03b611514f 100644 --- a/frontend/src/components/CippComponents/CippBottomSheet.jsx +++ b/frontend/src/components/CippComponents/CippBottomSheet.jsx @@ -3,12 +3,15 @@ import { Box, Drawer, Typography } from "@mui/material"; // Mobile bottom sheet — the house rule for the mobile surface is that anything rendered // as a Menu on desktop becomes one of these: predictable position, 44px+ rows, thumb reach. export const CippBottomSheet = (props) => { - const { open, onClose, title, children, footer, ...other } = props; + const { open, onClose, title, children, footer, onExited, SlideProps, ...other } = props; return ( { } = props const [open, setOpen] = useState(false) + const sheet = useSheetHandoff(() => setOpen(false)) const tabNav = useTabNavigation() const showTabs = Boolean(tabNav?.enabled && tabNav.tabs?.length) // A tabbed layout may own page-level actions too (HeaderedTabbedLayout's ActionsMenu); @@ -74,7 +76,8 @@ export const CippPageActionsFab = (props) => { setOpen(false)} + onClose={sheet.cancel} + onExited={sheet.handleExited} title={resolvedTitle} {...sheetProps} > @@ -134,10 +137,7 @@ export const CippPageActionsFab = (props) => { key={action.label ?? index} disabled={action.disabled} sx={{ minHeight: 48, color: action.color }} - onClick={() => { - setOpen(false) - action.onClick?.() - }} + onClick={() => sheet.run(action.onClick)} > {action.icon && ( diff --git a/frontend/src/components/CippComponents/CippReportToolbar.jsx b/frontend/src/components/CippComponents/CippReportToolbar.jsx index 4e97dbe9ee..7f22b508ab 100644 --- a/frontend/src/components/CippComponents/CippReportToolbar.jsx +++ b/frontend/src/components/CippComponents/CippReportToolbar.jsx @@ -27,6 +27,7 @@ import CippFormComponent from './CippFormComponent' import { CippAddTestReportDrawer } from './CippAddTestReportDrawer' import { CippApiDialog } from './CippApiDialog' import { CippBottomSheet } from './CippBottomSheet' +import { useSheetHandoff } from '../../hooks/use-sheet-handoff' export const CippReportToolbar = () => { const settings = useSettings() @@ -37,6 +38,8 @@ export const CippReportToolbar = () => { const [deleteDialog, setDeleteDialog] = useState({ open: false }) const [refreshDialog, setRefreshDialog] = useState({ open: false }) const [actionSheetOpen, setActionSheetOpen] = useState(false) + // Every row here opens a drawer or dialog — let the sheet close first + const actionSheet = useSheetHandoff(() => setActionSheetOpen(false)) const [createDrawerOpen, setCreateDrawerOpen] = useState(false) const [editDrawerOpen, setEditDrawerOpen] = useState(false) @@ -224,16 +227,14 @@ export const CippReportToolbar = () => { <> setActionSheetOpen(false)} + onClose={actionSheet.cancel} + onExited={actionSheet.handleExited} title="Test suite actions" > { - setActionSheetOpen(false) - setCreateDrawerOpen(true) - }} + onClick={() => actionSheet.run(() => setCreateDrawerOpen(true))} > @@ -242,10 +243,7 @@ export const CippReportToolbar = () => { { - setActionSheetOpen(false) - openRefreshDialog() - }} + onClick={() => actionSheet.run(() => openRefreshDialog())} > @@ -255,10 +253,7 @@ export const CippReportToolbar = () => { { - setActionSheetOpen(false) - setEditDrawerOpen(true) - }} + onClick={() => actionSheet.run(() => setEditDrawerOpen(true))} > @@ -274,10 +269,7 @@ export const CippReportToolbar = () => { { - setActionSheetOpen(false) - openDeleteDialog() - }} + onClick={() => actionSheet.run(() => openDeleteDialog())} > @@ -289,10 +281,7 @@ export const CippReportToolbar = () => { { - setActionSheetOpen(false) - handleRefresh() - }} + onClick={() => actionSheet.run(() => handleRefresh())} > diff --git a/frontend/src/components/CippTable/CippDataTable.js b/frontend/src/components/CippTable/CippDataTable.js index e63ba73904..e68bd9ea94 100644 --- a/frontend/src/components/CippTable/CippDataTable.js +++ b/frontend/src/components/CippTable/CippDataTable.js @@ -1017,18 +1017,40 @@ export const CippDataTable = (props) => { renderColumnFilterModeMenuItems: renderColumnFilterModeMenuItemsFn, }) - // A card shows at most a title, subtitle, three chips and three detail rows, so on pages - // that never configured an offCanvas the rest of the row would be unreachable. Fall back - // to the columns the user has chosen to show, which is what the card was summarising. - const cardFallbackInfoFields = useMemo(() => { - if (offCanvas || !isCardView) return undefined - return table + // A card shows at most a title, subtitle, three chips and three detail rows, so the rest + // of the row has to live in the drawer. On a page with no offCanvas that means every + // column the user has chosen to show; on a page that configured one, its curated fields + // come first and the remaining visible columns are appended rather than dropped — on + // desktop those columns are still on screen in the table, on mobile they are not. + const cardInfoFields = useMemo(() => { + if (!isCardView) return undefined + const visible = table .getVisibleLeafColumns() .map((column) => column.id) .filter((id) => !id.startsWith('mrt-')) + const curated = offCanvas?.extendedInfoFields + if (!curated?.length) return visible + // Curated order wins; dedupe case-insensitively across both lists, and within the + // curated list itself, so a field can't be shown twice. + const seen = new Set() + return [...curated, ...visible].filter((id) => { + if (typeof id !== 'string') return false + const key = id.toLowerCase() + if (seen.has(key)) return false + seen.add(key) + return true + }) // eslint-disable-next-line react-hooks/exhaustive-deps }, [offCanvas, isCardView, table, columnVisibility, usedColumns]) + // Applied after the {...offCanvas} spread below, which carries the page's own + // extendedInfoFields and would otherwise overwrite the merged list. Card view renders + // them the way the table cells do, since the appended entries are columns. + const cardInfoOverride = + isCardView && cardInfoFields?.length + ? { extendedInfoFields: cardInfoFields, richFormatting: true } + : {} + // Remove the useEffect that was resetting filters on table changes // The initial filter application is now handled by the columnFilters state // and the useEffect above that only triggers on actual filter prop changes @@ -1147,7 +1169,7 @@ export const CippDataTable = (props) => { { visible={offcanvasVisible} onClose={() => setOffcanvasVisible(false)} extendedData={offCanvasData} - extendedInfoFields={offCanvas?.extendedInfoFields ?? cardFallbackInfoFields} - // The fallback's fields are table columns, so render them the way their cells - // do — links, copy chips and status icons rather than flattened text. - richFormatting={!offCanvas && Boolean(cardFallbackInfoFields?.length)} + extendedInfoFields={offCanvas?.extendedInfoFields} actions={actions} title={offCanvasData?.Name || offCanvas?.title || 'Extended Info'} children={ @@ -1263,6 +1282,7 @@ export const CippDataTable = (props) => { total: filteredRows?.length ?? 0, }} {...offCanvas} + {...cardInfoOverride} /> {/* Render custom component */} {customComponentVisible && diff --git a/frontend/src/components/CippTable/CippMobileCardList.jsx b/frontend/src/components/CippTable/CippMobileCardList.jsx index e5afc34404..94b21bcdd7 100644 --- a/frontend/src/components/CippTable/CippMobileCardList.jsx +++ b/frontend/src/components/CippTable/CippMobileCardList.jsx @@ -22,6 +22,7 @@ import { getMobileCardSlots } from "./util-mobile-card-slots"; import { CippBottomSheet } from "../CippComponents/CippBottomSheet"; import { CippPageActionsFab } from "../CippComponents/CippPageActionsFab"; import { useTabFabClaim } from "../../layouts/tab-navigation-context"; +import { useSheetHandoff } from "../../hooks/use-sheet-handoff"; // Mobile card pageSize ceiling: a desktop tablePageSize of 250/500 must not become // 250 unvirtualized cards. "Load more" grows pageSize from here in steps of LOAD_STEP. @@ -100,6 +101,8 @@ export const CippMobileCardList = (props) => { } = props; const [actionSheetRow, setActionSheetRow] = useState(null); + // Row actions and More info both open a Modal — hand the sheet off rather than racing it + const rowSheet = useSheetHandoff(() => setActionSheetRow(null)); // Select mode's bulk bar owns the bottom of the screen, so the page FAB steps aside. Hold // the claim through it anyway: a tabbed layout would otherwise drop its own FAB in behind @@ -383,7 +386,8 @@ export const CippMobileCardList = (props) => { {/* Row actions sheet — same actions array, same dispatch as the desktop row menu */} setActionSheetRow(null)} + onClose={rowSheet.cancel} + onExited={rowSheet.handleExited} title={actionSheetRow ? (textValue(actionSheetRow, slots.primary) ?? "Row actions") : ""} > {actionSheetRow && @@ -393,10 +397,9 @@ export const CippMobileCardList = (props) => { { - setActionSheetRow(null); - onRowAction?.(action, actionSheetRow.original); - }} + onClick={() => + rowSheet.run(() => onRowAction?.(action, actionSheetRow.original)) + } sx={{ minHeight: 48, color: action.color }} > @@ -408,10 +411,7 @@ export const CippMobileCardList = (props) => { })} {actionSheetRow && hasOffCanvas && ( { - setActionSheetRow(null); - onMoreInfo?.(actionSheetRow.original); - }} + onClick={() => rowSheet.run(() => onMoreInfo?.(actionSheetRow.original))} sx={{ minHeight: 48 }} > diff --git a/frontend/src/components/CippTable/CippMobileTableControls.jsx b/frontend/src/components/CippTable/CippMobileTableControls.jsx index 1ee5ef0122..f03dab2c9a 100644 --- a/frontend/src/components/CippTable/CippMobileTableControls.jsx +++ b/frontend/src/components/CippTable/CippMobileTableControls.jsx @@ -32,6 +32,7 @@ import { } from "@mui/icons-material"; import { getCippTranslation } from "../../utils/get-cipp-translation"; import { CippBottomSheet } from "../CippComponents/CippBottomSheet"; +import { useSheetHandoff } from "../../hooks/use-sheet-handoff"; // Presentational mobile controls for the card list. All filter/sort/visibility state and // handlers are owned by CIPPTableToptoolbar (the same instance the desktop toolbar uses), @@ -70,6 +71,10 @@ export const CippMobileTableControls = (props) => { const [sortOpen, setSortOpen] = useState(false); const [filterOpen, setFilterOpen] = useState(false); const [bulkOpen, setBulkOpen] = useState(false); + // Graph filters, the API-response drawer and bulk dialogs are all Modals; let the sheet + // finish closing before they mount (see useSheetHandoff). + const filterSheet = useSheetHandoff(() => setFilterOpen(false)); + const bulkSheet = useSheetHandoff(() => setBulkOpen(false)); const sorting = table.getState().sorting ?? []; const sortableColumns = table @@ -234,7 +239,8 @@ export const CippMobileTableControls = (props) => { {/* Filter sheet — presets first, then card fields, then table utilities */} setFilterOpen(false)} + onClose={filterSheet.cancel} + onExited={filterSheet.handleExited} title="Filters" footer={ + + sheet.run(onAction)}> + + + + + ); +}; + +describe("useSheetHandoff", () => { + it("runs the action only after the sheet has finished closing", async () => { + const user = userEvent.setup(); + const onAction = vi.fn(); + renderWithProviders(); + + await user.click(screen.getByRole("button", { name: "Open sheet" })); + await user.click(await screen.findByText("Do the thing")); + + // the tap closes the sheet immediately, but the action is still parked + expect(onAction).not.toHaveBeenCalled(); + + await waitFor(() => expect(onAction).toHaveBeenCalledTimes(1)); + expect(screen.queryByText("Do the thing")).not.toBeInTheDocument(); + }); + + it("drops the parked action when the sheet is dismissed instead", async () => { + const user = userEvent.setup(); + const onAction = vi.fn(); + renderWithProviders(); + + await user.click(screen.getByRole("button", { name: "Open sheet" })); + await screen.findByText("Do the thing"); + await user.keyboard("{Escape}"); + + await waitFor(() => expect(screen.queryByText("Do the thing")).not.toBeInTheDocument()); + await new Promise((resolve) => setTimeout(resolve, 500)); + expect(onAction).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/tests/layouts/HeaderedTabbedLayout.test.jsx b/frontend/tests/layouts/HeaderedTabbedLayout.test.jsx new file mode 100644 index 0000000000..77946ced52 --- /dev/null +++ b/frontend/tests/layouts/HeaderedTabbedLayout.test.jsx @@ -0,0 +1,97 @@ +import React from "react"; +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { renderWithProviders } from "../test-utils"; + +const layoutState = vi.hoisted(() => ({ mdDown: true })); +vi.mock("@mui/material", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, useMediaQuery: () => layoutState.mdDown }; +}); + +vi.mock("next/router", () => ({ + useRouter: () => ({ query: {}, push: vi.fn(), pathname: "/tenant/manage/edit" }), +})); +vi.mock("next/navigation", () => ({ usePathname: () => "/tenant/manage/edit" })); + +const idle = vi.hoisted(() => ({ + isSuccess: false, + isFetching: false, + isPending: false, + isError: false, + data: undefined, + mutate: () => {}, + reset: () => {}, + refetch: () => {}, +})); +vi.mock("../../src/api/ApiCall", () => ({ + ApiGetCall: () => idle, + ApiPostCall: () => idle, + ApiGetCallWithPagination: () => ({ ...idle, fetchNextPage: () => {} }), +})); + +import { HeaderedTabbedLayout } from "../../src/layouts/HeaderedTabbedLayout"; + +const tabOptions = [ + { label: "Edit Tenant", path: "/tenant/manage/edit", icon: "Settings" }, + { label: "Manage Drift", path: "/tenant/manage/drift", icon: "Sync" }, +]; + +const actions = [ + { + label: "Reset Password", + type: "POST", + url: "/api/ExecResetPass", + confirmText: "Reset the password?", + }, +]; + +const renderLayout = () => + renderWithProviders( + +
    page content
    +
    + ); + +describe("HeaderedTabbedLayout mobile actions", () => { + beforeEach(() => { + layoutState.mdDown = true; + }); + + it("keeps the header Actions menu on desktop and drops it on mobile", async () => { + renderLayout(); + expect(screen.queryByRole("button", { name: "Actions" })).not.toBeInTheDocument(); + + layoutState.mdDown = false; + renderLayout(); + await waitFor(() => + expect(screen.getAllByRole("button", { name: "Actions" }).length).toBeGreaterThan(0) + ); + }); + + // The sheet closing and the overlay opening happen in one tick; MUI's modal manager has + // to settle the unmounting Drawer before the new one registers, or the overlay never + // becomes interactive. + it("opens the action's overlay from the sheet and leaves it open", async () => { + const user = userEvent.setup(); + renderLayout(); + + await user.click(screen.getByRole("button", { name: "Views" })); + await user.click(await screen.findByText("Reset Password")); + + // sheet goes away + await waitFor(() => expect(screen.queryByText("Manage Drift")).not.toBeInTheDocument()); + + // and the confirmation overlay is present and stays present + const confirm = await screen.findByText(/Reset the password\?/i, {}, { timeout: 3000 }); + expect(confirm).toBeInTheDocument(); + await new Promise((resolve) => setTimeout(resolve, 400)); + expect(screen.getByText(/Reset the password\?/i)).toBeInTheDocument(); + }); +}); diff --git a/frontend/tests/theme/input-zoom.test.js b/frontend/tests/theme/input-zoom.test.js new file mode 100644 index 0000000000..4a47e93e3e --- /dev/null +++ b/frontend/tests/theme/input-zoom.test.js @@ -0,0 +1,16 @@ +import { describe, it, expect } from "vitest"; +import { createTheme } from "../../src/theme"; + +// iOS Safari zooms the viewport when a focused input renders text below 16px, and it does +// not zoom back out afterwards. Every MUI input must reach 16px on coarse pointers. +const COARSE = "@media (pointer: coarse)"; + +describe("input font size on touch devices", () => { + const theme = createTheme({ colorPreset: "orange", contrast: "high", paletteMode: "light" }); + + it.each(["MuiInputBase", "MuiFilledInput"])("%s inputs reach 16px on coarse pointers", (key) => { + const input = theme.components[key].styleOverrides.input; + expect(input.fontSize).toBeLessThan(16); // pointer devices stay compact + expect(input[COARSE]?.fontSize).toBe(16); + }); +}); From 323e6299cc17ebc4f1bc537e9e0c182460487d66 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Sun, 9 Aug 2026 09:55:06 -0400 Subject: [PATCH 005/226] fix(fab): keep sheet children mounted on close MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `keepMounted: true` to the FAB sheet's ModalProps so that child-owned overlays (e.g. CippAddUserDrawer) are not unmounted when the sheet closes — otherwise the child's drawer/dialog would vanish the instant it opened. Also fix responsive grid breakpoints in CippAddEditUser so switch and input fields stack properly on small screens, and update tests/stories to expect hidden rather than absent elements now that keepMounted is set. --- .../CippComponents/CippPageActionsFab.jsx | 4 ++ .../CippFormPages/CippAddEditUser.jsx | 18 +++---- .../CippPageActionsFab.stories.jsx | 4 +- .../CippPageActionsFab.test.jsx | 47 +++++++++++++++++-- .../layouts/HeaderedTabbedLayout.test.jsx | 4 +- 5 files changed, 60 insertions(+), 17 deletions(-) diff --git a/frontend/src/components/CippComponents/CippPageActionsFab.jsx b/frontend/src/components/CippComponents/CippPageActionsFab.jsx index 610dd195ad..7f5aa411f4 100644 --- a/frontend/src/components/CippComponents/CippPageActionsFab.jsx +++ b/frontend/src/components/CippComponents/CippPageActionsFab.jsx @@ -80,6 +80,10 @@ export const CippPageActionsFab = (props) => { onExited={sheet.handleExited} title={resolvedTitle} {...sheetProps} + // A cardButton child owns its own drawer/dialog (CippAddUserDrawer renders both the + // trigger and the CippOffCanvas). Unmounting the sheet would take that overlay with + // it the instant it opened, so the children stay mounted. + ModalProps={{ keepMounted: true, ...sheetProps?.ModalProps }} > { Settings - + { - + { compareValue="(0 available)" labelCompare={true} > - + { )} - + { {userSettingsDefaults?.userAttributes ?.filter((attribute) => attribute.value !== 'sponsor') .map((attribute, idx) => ( - + { {formType === 'add' && ( <> - + { formControl={formControl} /> - + { formControl={formControl} /> - + { formControl={formControl} /> - + { await userEvent.click(body.getByRole('button', { name: 'Bulk Add' })) - await waitFor(() => expect(body.queryByText('Actions')).not.toBeInTheDocument()) + // keepMounted: a cardButton child owns its own drawer, so the sheet hides rather + // than unmounting — otherwise that drawer would vanish the moment it opened. + await waitFor(() => expect(body.getByText('Actions')).not.toBeVisible()) }) }, } diff --git a/frontend/tests/components/CippComponents/CippPageActionsFab.test.jsx b/frontend/tests/components/CippComponents/CippPageActionsFab.test.jsx index 6409f53684..c3696107ec 100644 --- a/frontend/tests/components/CippComponents/CippPageActionsFab.test.jsx +++ b/frontend/tests/components/CippComponents/CippPageActionsFab.test.jsx @@ -1,7 +1,8 @@ +import React from "react"; import { describe, it, expect, vi } from "vitest"; import { screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { Button, ListItemButton, MenuItem, Typography } from "@mui/material"; +import { Button, Drawer, ListItemButton, MenuItem, Typography } from "@mui/material"; import { CippPageActionsFab } from "../../../src/components/CippComponents/CippPageActionsFab"; import { renderWithProviders } from "../../test-utils"; @@ -20,7 +21,9 @@ describe("CippPageActionsFab", () => { ); - expect(screen.queryByText("Sheet content")).not.toBeInTheDocument(); + // keepMounted: the children stay mounted so a child-owned overlay survives the + // sheet closing, so "closed" means hidden rather than absent. + expect(screen.getByText("Sheet content")).not.toBeVisible(); await openSheet(user); expect(screen.getByText("Sheet content")).toBeInTheDocument(); @@ -54,7 +57,7 @@ describe("CippPageActionsFab", () => { await user.click(screen.getByRole("button", { name: "Do a thing" })); expect(onClick).toHaveBeenCalledTimes(1); - await waitFor(() => expect(screen.queryByText("Sheet content")).not.toBeInTheDocument()); + await waitFor(() => expect(screen.getByText("Sheet content")).not.toBeVisible()); }); it("closes the sheet when a child link is tapped", async () => { @@ -71,7 +74,7 @@ describe("CippPageActionsFab", () => { await openSheet(user); await user.click(screen.getByRole("link", { name: "External portal" })); - await waitFor(() => expect(screen.queryByText("Sheet content")).not.toBeInTheDocument()); + await waitFor(() => expect(screen.getByText("Sheet content")).not.toBeVisible()); }); it("closes the sheet when a MenuItem child is tapped", async () => { @@ -89,7 +92,7 @@ describe("CippPageActionsFab", () => { await user.click(screen.getByRole("menuitem", { name: "Executive Summary" })); expect(onClick).toHaveBeenCalledTimes(1); - await waitFor(() => expect(screen.queryByText("Sheet content")).not.toBeInTheDocument()); + await waitFor(() => expect(screen.getByText("Sheet content")).not.toBeVisible()); }); it("keeps the sheet open when non-interactive content is tapped", async () => { @@ -106,3 +109,37 @@ describe("CippPageActionsFab", () => { expect(screen.getByText("Sheet content")).toBeInTheDocument(); }); }); + +// A cardButton child renders both its trigger and its own overlay (CippAddUserDrawer is a +// button plus a CippOffCanvas). If the sheet unmounts its children on close, that overlay +// disappears the instant it opens. +describe("CippPageActionsFab with a child that owns an overlay", () => { + const DrawerAction = () => { + const [open, setOpen] = React.useState(false); + return ( + <> + + setOpen(false)}> +
    Add user form
    +
    + + ); + }; + + it("keeps the child's overlay open after the sheet closes", async () => { + const user = userEvent.setup(); + renderWithProviders( + + + + ); + + await user.click(screen.getByRole("button", { name: "Page actions" })); + await user.click(await screen.findByRole("button", { name: "Add User" })); + + // the tap closes the sheet and opens the child's drawer — the drawer must survive it + expect(await screen.findByText("Add user form")).toBeInTheDocument(); + await new Promise((resolve) => setTimeout(resolve, 500)); + expect(screen.getByText("Add user form")).toBeInTheDocument(); + }); +}); diff --git a/frontend/tests/layouts/HeaderedTabbedLayout.test.jsx b/frontend/tests/layouts/HeaderedTabbedLayout.test.jsx index 77946ced52..ef6f4d756c 100644 --- a/frontend/tests/layouts/HeaderedTabbedLayout.test.jsx +++ b/frontend/tests/layouts/HeaderedTabbedLayout.test.jsx @@ -85,8 +85,8 @@ describe("HeaderedTabbedLayout mobile actions", () => { await user.click(screen.getByRole("button", { name: "Views" })); await user.click(await screen.findByText("Reset Password")); - // sheet goes away - await waitFor(() => expect(screen.queryByText("Manage Drift")).not.toBeInTheDocument()); + // sheet goes away — keepMounted keeps its rows in the DOM, so closed means hidden + await waitFor(() => expect(screen.getByText("Manage Drift")).not.toBeVisible()); // and the confirmation overlay is present and stays present const confirm = await screen.findByText(/Reset the password\?/i, {}, { timeout: 3000 }); From 9a668dec2460eb32a2de633425a14fe19d25a4f5 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Sun, 9 Aug 2026 11:04:04 -0400 Subject: [PATCH 006/226] fix: comprehensive mobile layout improvements Fixes mobile layout issues across the app: - Replace fixed Grid column splits (xs: N < 12) with responsive breakpoints - Add `useFlexGap` to all wrapping Stacks to fix indented rows on wrap - Make MUI Dialogs full-width on mobile via theme override - Responsive padding/heights for cards, page containers, and form actions - CippSankey adapts node thickness, spacing, label orientation on mobile - CippSponsor gains a `compact` prop for the mobile nav drawer - Mobile nav drawer pins sponsor below the scrollable menu - ReleaseNotesDialog defaults to the newest vX.Y.0 instead of hotfix tags - Add lint tests to catch fixed-column Grid splits and unwrapped Stacks --- .../CippAllTenants/AllTenantsPrimitives.jsx | 2 +- .../CippCards/CippBannerListCard.jsx | 2 +- .../components/CippCards/CippDomainCards.jsx | 4 +- .../src/components/CippCards/CippPageCard.jsx | 2 +- .../CippCards/CippPropertyListCard.jsx | 2 +- .../CippCards/CippStandardsDialog.jsx | 8 +- .../CippComponents/AlertsOverviewCard.jsx | 2 +- .../CippComponents/AuthMethodCard.jsx | 2 +- .../CippComponents/CippAppTemplateDrawer.jsx | 4 +- .../CippApplicationDeployDrawer.jsx | 4 +- .../CippComponents/CippDateRangeFilter.jsx | 8 +- .../CippIntuneSettingsEditor.jsx | 4 +- .../CippMailboxRestoreDrawer.jsx | 4 +- .../CippComponents/CippMaintenanceBanner.jsx | 2 +- .../CippMessageDeliveryInfo.jsx | 2 +- .../CippComponents/CippPropertyList.jsx | 2 +- .../CippComponents/CippRestoreWizard.jsx | 2 +- .../components/CippComponents/CippSankey.jsx | 22 +++- .../CippSharePointPermissionEditor.jsx | 4 +- .../components/CippComponents/CippSponsor.jsx | 19 +++- .../CippComponents/CippTemplateCatalog.jsx | 2 +- .../CippTenantGroupRuleBuilder.jsx | 4 +- .../CippTransportRuleDrawer.jsx | 12 +- .../components/CippComponents/LicenseCard.jsx | 2 +- .../src/components/CippComponents/MFACard.jsx | 2 +- .../CippComponents/TenantMetricsGrid.jsx | 2 +- .../components/CippFormPages/CippFormPage.jsx | 31 +++++- .../CippFormPages/CippSchedulerForm.jsx | 8 +- .../CippSettings/CippAppServiceDomains.jsx | 4 +- .../CippSettings/CippBrandingSettings.jsx | 2 +- .../CippSettings/CippGDAP/CippFlowDiagram.jsx | 2 +- .../CippGDAP/CippGDAPTraceResults.jsx | 2 +- .../CippGDAP/CippPathVisualization.jsx | 2 +- .../CippSettings/CippSSOSettings.jsx | 20 ++-- .../CippStandards/CippStandardAccordion.jsx | 4 +- .../CippStandards/CippStandardDialog.jsx | 2 +- .../src/components/CippTable/CippDataTable.js | 2 +- .../CippTable/CippGraphExplorerFilter.js | 24 ++-- .../CippTable/CippMobileCardList.jsx | 2 +- .../components/CippTable/CippQueueTracker.js | 2 +- .../CippWizard/CippIntunePolicy.jsx | 2 +- .../CippWizard/CippWizardOffboarding.jsx | 4 +- .../components/CippWizard/CippWizardPage.jsx | 2 +- frontend/src/components/ReleaseNotesDialog.js | 67 +++++++++--- frontend/src/components/images-dialog.js | 2 +- frontend/src/layouts/HeaderedTabbedLayout.jsx | 1 + frontend/src/layouts/index.js | 2 +- frontend/src/layouts/mobile-nav.js | 23 +++- .../src/layouts/tab-navigation-context.js | 6 +- .../container-management/worker-health.js | 2 +- .../pages/cipp/advanced/table-maintenance.js | 4 +- frontend/src/pages/cipp/logs/index.js | 2 +- frontend/src/pages/copilot/shadow-ai/index.js | 2 +- .../administration/mailboxes/addshared.jsx | 4 +- .../list-quarantine-policies/add.jsx | 8 +- .../email/tools/mailbox-restores/add.jsx | 6 +- .../pages/teams-share/sharing-report/index.js | 2 +- .../administration/add-subscription/index.jsx | 4 +- .../alert-configuration/alert.jsx | 8 +- .../pages/tenant/manage/applied-standards.js | 2 +- .../reports/graph-office-reports/index.js | 2 +- .../tenant/standards/bpa-report/builder.js | 6 +- .../pages/tenant/standards/bpa-report/view.js | 6 +- .../pages/tenant/standards/templates/index.js | 4 +- .../pages/tenant/tools/geoiplookup/index.js | 12 +- .../src/pages/tools/breachlookup/index.js | 14 +-- .../tools/report-builder/builder/index.js | 4 +- frontend/src/theme/base/create-components.js | 18 +++ .../CippComponents/CippSankey.test.jsx | 58 ++++++++++ .../components/ReleaseNotesDialog.test.jsx | 26 ++++- .../tests/lint/mobile-layout-patterns.test.js | 103 ++++++++++++++++++ 71 files changed, 470 insertions(+), 168 deletions(-) create mode 100644 frontend/tests/components/CippComponents/CippSankey.test.jsx create mode 100644 frontend/tests/lint/mobile-layout-patterns.test.js diff --git a/frontend/src/components/CippAllTenants/AllTenantsPrimitives.jsx b/frontend/src/components/CippAllTenants/AllTenantsPrimitives.jsx index dd3f075ac3..f2037f50e8 100644 --- a/frontend/src/components/CippAllTenants/AllTenantsPrimitives.jsx +++ b/frontend/src/components/CippAllTenants/AllTenantsPrimitives.jsx @@ -391,7 +391,7 @@ export const AllTenantsTrendChart = ({ /** Band heading that separates the dashboard into Portfolio / Security / Operations. */ export const AllTenantsBandHeading = ({ title, description }) => ( - { {[...Array(1)].map((_, index) => ( - + diff --git a/frontend/src/components/CippCards/CippDomainCards.jsx b/frontend/src/components/CippCards/CippDomainCards.jsx index fede72bd13..981fd377e2 100644 --- a/frontend/src/components/CippCards/CippDomainCards.jsx +++ b/frontend/src/components/CippCards/CippDomainCards.jsx @@ -503,7 +503,7 @@ export const CippDomainCards = ({ domain: propDomain = "", fullwidth = false }) } > - + - + diff --git a/frontend/src/components/CippCards/CippPageCard.jsx b/frontend/src/components/CippCards/CippPageCard.jsx index 07b278b4bd..02ab599fad 100644 --- a/frontend/src/components/CippCards/CippPageCard.jsx +++ b/frontend/src/components/CippCards/CippPageCard.jsx @@ -25,7 +25,7 @@ const CippPageCard = (props) => { diff --git a/frontend/src/components/CippCards/CippPropertyListCard.jsx b/frontend/src/components/CippCards/CippPropertyListCard.jsx index a35203323f..d437c0487c 100644 --- a/frontend/src/components/CippCards/CippPropertyListCard.jsx +++ b/frontend/src/components/CippCards/CippPropertyListCard.jsx @@ -50,7 +50,7 @@ export const CippPropertyListCard = (props) => { ) } - const setPadding = isLabelPresent ? { py: 0.5, px: 3 } : { py: 1.5, px: 3 } + const setPadding = isLabelPresent ? { py: 0.5, px: { xs: 2, md: 3 } } : { py: 1.5, px: { xs: 2, md: 3 } } const handleActionDisabled = (row, action) => { if (action?.condition) { return !action.condition(row) diff --git a/frontend/src/components/CippCards/CippStandardsDialog.jsx b/frontend/src/components/CippCards/CippStandardsDialog.jsx index 4281848178..5b7e95dd8a 100644 --- a/frontend/src/components/CippCards/CippStandardsDialog.jsx +++ b/frontend/src/components/CippCards/CippStandardsDialog.jsx @@ -258,7 +258,7 @@ export const CippStandardsDialog = ({ open, onClose, standardsData, currentTenan {info.helpText} - + Actions: - + {templateItem.action && Array.isArray(templateItem.action) ? ( templateItem.action.map((action, actionIndex) => ( - + Actions: - + {config.action && Array.isArray(config.action) ? ( config.action.map((action, index) => ( { return ( <> - + { sx={{ pb: 1 }} /> - + {isLoading ? ( ) : processedData ? ( diff --git a/frontend/src/components/CippComponents/CippAppTemplateDrawer.jsx b/frontend/src/components/CippComponents/CippAppTemplateDrawer.jsx index adabc2c716..8af173b11d 100644 --- a/frontend/src/components/CippComponents/CippAppTemplateDrawer.jsx +++ b/frontend/src/components/CippComponents/CippAppTemplateDrawer.jsx @@ -564,7 +564,7 @@ export const CippAppTemplateDrawer = ({ formControl={formControl} />
    - + - + - +
    @@ -423,8 +442,25 @@ export const ReleaseNotesDialog = forwardRef((_props, ref) => { { - + {pageTitle} @@ -144,7 +144,7 @@ const Page = () => { {currentTenant === "AllTenants" && layoutMode !== "Table" ? ( - + { <> {blockCards.map((block, index) => ( { disable the schedule. After conversion, please check the new templates to ensure they are correct and re-enable the schedule. - + - + diff --git a/frontend/src/pages/tenant/tools/geoiplookup/index.js b/frontend/src/pages/tenant/tools/geoiplookup/index.js index 58b739e439..08a1ffecab 100644 --- a/frontend/src/pages/tenant/tools/geoiplookup/index.js +++ b/frontend/src/pages/tenant/tools/geoiplookup/index.js @@ -88,13 +88,13 @@ const Page = () => { > - + - + { required /> - + + {open && ( + <> +
    Row details
    + + + )} + + ); +}; + +afterEach(() => { + resetOverlayHistory(); +}); + +describe("useHistoryDismiss", () => { + it("dismisses the overlay on a back press instead of navigating the page", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await user.click(screen.getByRole("button", { name: "Open details" })); + expect(screen.getByTestId("overlay")).toBeInTheDocument(); + + await swipeBack(); + + expect(screen.queryByTestId("overlay")).not.toBeInTheDocument(); + }); + + it("gives the entry back when the overlay closes on its own", async () => { + const user = userEvent.setup(); + const onClose = vi.fn(); + renderWithProviders(); + + await user.click(screen.getByRole("button", { name: "Open details" })); + await act(async () => { + const settled = nextPop(); + await user.click(screen.getByRole("button", { name: "Close details" })); + await settled; + }); + + // Closed once, by the button — and the history entry went with it, so the next back + // press is the page's again rather than a dead tap. + expect(onClose).toHaveBeenCalledTimes(1); + expect(window.history.state?.__cippOverlay).toBeUndefined(); + }); + + it("stays out of history when disabled", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await user.click(screen.getByRole("button", { name: "Open details" })); + // Somewhere to go back to, so the gesture is a real navigation attempt. + window.history.pushState({}, ""); + await swipeBack(); + + // Desktop keeps today's behaviour: back belongs to the router, not the overlay. + expect(screen.getByTestId("overlay")).toBeInTheDocument(); + }); +}); diff --git a/frontend/tests/utils/overlay-history.test.js b/frontend/tests/utils/overlay-history.test.js new file mode 100644 index 0000000000..a62820d551 --- /dev/null +++ b/frontend/tests/utils/overlay-history.test.js @@ -0,0 +1,170 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + installOverlayHistory, + pushOverlayEntry, + releaseOverlayEntry, + resetOverlayHistory, +} from "../../src/utils/overlay-history"; + +// The shape Next's pages router keeps in history.state for the current route. +const routeState = (as) => ({ __N: true, url: as, as, key: `key-${as}`, options: {} }); + +// jsdom traverses asynchronously, same as a browser: back() queues the task and popstate +// lands later. Every assertion about a back press has to wait for it. +const nextPop = () => + new Promise((resolve) => window.addEventListener("popstate", resolve, { once: true })); + +const goBack = async () => { + const settled = nextPop(); + window.history.back(); + await settled; +}; + +// A browser fires a single popstate for a multi-entry jump, e.g. the long-press back menu. +const goTo = async (delta) => { + const settled = nextPop(); + window.history.go(delta); + await settled; +}; + +beforeEach(() => { + window.history.replaceState(routeState("/identity/users"), ""); +}); + +afterEach(() => { + resetOverlayHistory(); +}); + +describe("overlay history", () => { + it("closes the overlay on a back press instead of letting the page navigate", async () => { + const close = vi.fn(); + const url = window.location.href; + installOverlayHistory(); + pushOverlayEntry(close); + + // The entry sits at the same url — nothing about the page changed. + expect(window.location.href).toBe(url); + await goBack(); + + expect(close).toHaveBeenCalledTimes(1); + }); + + it("keeps the pushed entry recognisable to Next's router", () => { + installOverlayHistory(); + pushOverlayEntry(vi.fn()); + + // Cloning the router's own state is what makes this entry survive a navigation away and + // back: Next ignores any history entry without __N, and would leave the app on a blank + // route if it landed on one. + expect(window.history.state.__N).toBe(true); + expect(window.history.state.as).toBe("/identity/users"); + }); + + it("dismisses one overlay per back press, deepest first", async () => { + const closeOuter = vi.fn(); + const closeInner = vi.fn(); + installOverlayHistory(); + pushOverlayEntry(closeOuter); + pushOverlayEntry(closeInner); + + await goBack(); + expect(closeInner).toHaveBeenCalledTimes(1); + expect(closeOuter).not.toHaveBeenCalled(); + + await goBack(); + expect(closeOuter).toHaveBeenCalledTimes(1); + }); + + it("takes its history entry back when the overlay is closed by hand", async () => { + const close = vi.fn(); + installOverlayHistory(); + const entry = pushOverlayEntry(close); + + const settled = nextPop(); + releaseOverlayEntry(entry); + await settled; + + // The component closed itself, so the callback must not fire again — and the entry is + // gone, so the user's next back press belongs to the page. + expect(close).not.toHaveBeenCalled(); + expect(window.history.state.__cippOverlay).toBeUndefined(); + }); + + it("leaves history alone when its entry has been buried by a navigation", () => { + const back = vi.spyOn(window.history, "back"); + const close = vi.fn(); + installOverlayHistory(); + const entry = pushOverlayEntry(close); + + // A link inside the overlay navigated: Next pushed a route entry over ours. + window.history.pushState(routeState("/identity/users/user"), ""); + releaseOverlayEntry(entry); + + // Popping here would drag the user back off the page they just opened. + expect(back).not.toHaveBeenCalled(); + back.mockRestore(); + }); +}); + +describe("overlay history / Next router handoff", () => { + // Next's own popstate listener is registered at app boot, before ours, and calls + // beforePopState from inside it. Registering this listener before installOverlayHistory + // reproduces that ordering — which matters, because the answer depends on state our + // listener is about to overwrite. + const withRouter = () => { + const answers = []; + let handler = null; + const listener = (event) => { + if (handler) answers.push(handler(event.state)); + }; + window.addEventListener("popstate", listener); + installOverlayHistory({ + beforePopState: (cb) => { + handler = cb; + }, + }); + return { + answers, + teardown: () => window.removeEventListener("popstate", listener), + }; + }; + + it("stops Next from re-rendering the route when the pop was ours", async () => { + const router = withRouter(); + pushOverlayEntry(vi.fn()); + + await goBack(); + + // false means "handled downstream". Letting Next through would emit route events and + // reset scroll — a long list would jump to the top every time a row was dismissed. + expect(router.answers).toEqual([false]); + router.teardown(); + }); + + it("leaves ordinary back presses to Next", async () => { + const router = withRouter(); + window.history.pushState(routeState("/identity/users"), ""); + + await goBack(); + + expect(router.answers).toEqual([true]); + router.teardown(); + }); + + it("leaves a real navigation to Next even with an overlay open", async () => { + window.history.replaceState(routeState("/identity/devices"), ""); + window.history.pushState(routeState("/identity/users"), ""); + const router = withRouter(); + const close = vi.fn(); + pushOverlayEntry(close); + + // The long-press back menu jumps straight past our entry to another route. That pop + // lands on a different page, so Next has to run — and the overlay closes with the page + // it belonged to. + await goTo(-2); + + expect(router.answers).toEqual([true]); + expect(close).toHaveBeenCalledTimes(1); + router.teardown(); + }); +}); From a55440ad8a4e70b18ea9cd5dceece9c0d3bac89a Mon Sep 17 00:00:00 2001 From: John Duprey Date: Sun, 9 Aug 2026 14:07:32 -0400 Subject: [PATCH 008/226] fix(table): use sorted row model for offcanvas navigation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the filtered-row-model state mirror with a live read of the sorted row model for prev/next drawer navigation. The filtered model is pre-sort, so positions taken from it broke as soon as a column was sorted. The state copy was also snapshotted at mount — before async data arrived — causing the counter to always report '0 of 0' for server-loaded tables. Also improves mobile layout: several Grid items that were xs:12-only now use responsive breakpoints (sm/md), the TenantMetricsGrid switches to xs:6 (two-up) with an exemption marker, and the mobile-layout lint rule gains an opt-out mechanism via a `mobile-layout-ok` comment. --- .../components/CippCards/CippDomainCards.jsx | 4 +- .../CippComponents/CippAppTemplateDrawer.jsx | 4 +- .../CippApplicationDeployDrawer.jsx | 4 +- .../CippComponents/CippDateRangeFilter.jsx | 4 +- .../CippComponents/TenantMetricsGrid.jsx | 109 ++++++++------- .../CippSettings/CippSSOSettings.jsx | 20 +-- .../src/components/CippTable/CippDataTable.js | 79 +++++------ .../CippWizard/CippIntunePolicy.jsx | 2 +- .../administration/add-subscription/index.jsx | 4 +- .../tenant/standards/bpa-report/builder.js | 4 +- .../pages/tenant/tools/geoiplookup/index.js | 2 +- .../src/pages/tools/breachlookup/index.js | 14 +- .../CippTable/CippDataTable.test.jsx | 132 ++++++++++++++++++ .../tests/lint/mobile-layout-patterns.test.js | 80 ++++++++--- 14 files changed, 326 insertions(+), 136 deletions(-) diff --git a/frontend/src/components/CippCards/CippDomainCards.jsx b/frontend/src/components/CippCards/CippDomainCards.jsx index 981fd377e2..0d8ae28916 100644 --- a/frontend/src/components/CippCards/CippDomainCards.jsx +++ b/frontend/src/components/CippCards/CippDomainCards.jsx @@ -152,7 +152,7 @@ function DomainResultCard({ title, data, isFetching, info, type }) { ? { children: ( - + {info} @@ -503,7 +503,7 @@ export const CippDomainCards = ({ domain: propDomain = "", fullwidth = false }) } > - + 0 && ( - + Apps in this template: @@ -421,7 +421,7 @@ export const CippAppTemplateDrawer = ({ formControl={formControl} /> - + Enter tenant-specific parameters (keys, URLs, IDs) below. You can enter a literal value that is the same for every tenant, or reference a CIPP custom variable like{' '} diff --git a/frontend/src/components/CippComponents/CippApplicationDeployDrawer.jsx b/frontend/src/components/CippComponents/CippApplicationDeployDrawer.jsx index 2d93bf46d1..ec7855ce41 100644 --- a/frontend/src/components/CippComponents/CippApplicationDeployDrawer.jsx +++ b/frontend/src/components/CippComponents/CippApplicationDeployDrawer.jsx @@ -376,7 +376,7 @@ export const CippApplicationDeployDrawer = ({ {/* Assign To Options */} - + - + - + - + { - if (num >= 1000000) return (num / 1000000).toFixed(1) + "M"; - if (num >= 1000) return (num / 1000).toFixed(1) + "K"; - return num?.toString() || "0"; -}; + if (num >= 1000000) return (num / 1000000).toFixed(1) + 'M' + if (num >= 1000) return (num / 1000).toFixed(1) + 'K' + return num?.toString() || '0' +} export const TenantMetricsGrid = ({ data, isLoading }) => { - const router = useRouter(); + const router = useRouter() const metrics = [ { - label: "Users", + label: 'Users', value: data?.UserCount || 0, icon: UserIcon, - color: "primary", - path: "/identity/administration/users", + color: 'primary', + path: '/identity/administration/users', }, { - label: "Guests", + label: 'Guests', value: data?.GuestCount || 0, icon: GuestIcon, - color: "info", - path: "/identity/administration/users", + color: 'info', + path: '/identity/administration/users', }, { - label: "Groups", + label: 'Groups', value: data?.GroupCount || 0, icon: GroupIcon, - color: "secondary", - path: "/identity/administration/groups", + color: 'secondary', + path: '/identity/administration/groups', }, { - label: "Service Principals", + label: 'Service Principals', value: data?.ApplicationCount || 0, icon: AppsIcon, - color: "error", - path: "/tenant/administration/applications/enterprise-apps", + color: 'error', + path: '/tenant/administration/applications/enterprise-apps', }, { - label: "Devices", + label: 'Devices', value: data?.DeviceCount || 0, icon: DevicesIcon, - color: "warning", - path: "/identity/administration/devices", + color: 'warning', + path: '/identity/administration/devices', }, { - label: "Managed", + label: 'Managed', value: data?.ManagedDeviceCount || 0, icon: ManagedIcon, - color: "success", - path: "/identity/administration/devices", + color: 'success', + path: '/identity/administration/devices', }, - ]; + ] const handleClick = (metric) => { if (metric.path) { - router.push(metric.path); + router.push(metric.path) } - }; + } return ( {metrics.map((metric) => { - const IconComponent = metric.icon; + const IconComponent = metric.icon + // Two-up at every width on purpose, phones included: the tile is sized for a + // narrow column (28px avatar, 0.6rem label) and the dashboard reads better as a + // 2x3 block than as six stacked rows. mobile-layout-ok return ( - + { handleClick(metric)} sx={{ - display: "flex", - alignItems: "center", + display: 'flex', + alignItems: 'center', gap: { xs: 1, sm: 1.5 }, p: { xs: 1, sm: 1.5, md: 2 }, border: 1, - borderColor: "divider", + borderColor: 'divider', borderRadius: 1, - cursor: "pointer", + cursor: 'pointer', minWidth: 0, - transition: "all 0.2s ease-in-out", - "&:hover": { + transition: 'all 0.2s ease-in-out', + '&:hover': { borderColor: `${metric.color}.main`, - backgroundColor: "action.hover", - transform: "translateY(-2px)", - boxShadow: "0 4px 8px rgba(0,0,0,0.1)", + backgroundColor: 'action.hover', + transform: 'translateY(-2px)', + boxShadow: '0 4px 8px rgba(0,0,0,0.1)', }, }} > @@ -109,26 +112,38 @@ export const TenantMetricsGrid = ({ data, isLoading }) => { flexShrink: 0, }} > - + {metric.label} - - {isLoading ? : formatNumber(metric.value)} + + {isLoading ? ( + + ) : ( + formatNumber(metric.value) + )} - ); + ) })} - ); -}; + ) +} diff --git a/frontend/src/components/CippSettings/CippSSOSettings.jsx b/frontend/src/components/CippSettings/CippSSOSettings.jsx index cf6e12fc1d..8d7413f34f 100644 --- a/frontend/src/components/CippSettings/CippSSOSettings.jsx +++ b/frontend/src/components/CippSettings/CippSSOSettings.jsx @@ -402,23 +402,23 @@ export const CippSSOSettings = () => { - + Status - + {hasAppId && ( <> - + Admin Consent - + { {data?.appId && ( <> - + App ID - + {data.appId} @@ -456,12 +456,12 @@ export const CippSSOSettings = () => { {signInHosts.length > 0 && ( <> - + Sign-in URLs - + {signInHosts.map((host) => ( { {data?.createdAt && ( <> - + Created - + {new Date(data.createdAt).toLocaleString()} diff --git a/frontend/src/components/CippTable/CippDataTable.js b/frontend/src/components/CippTable/CippDataTable.js index 2330ba1ca9..821eaabff3 100644 --- a/frontend/src/components/CippTable/CippDataTable.js +++ b/frontend/src/components/CippTable/CippDataTable.js @@ -424,7 +424,6 @@ export const CippDataTable = (props) => { const [offcanvasVisible, setOffcanvasVisible] = useState(false) const [offCanvasData, setOffCanvasData] = useState({}) const [offCanvasRowIndex, setOffCanvasRowIndex] = useState(0) - const [filteredRows, setFilteredRows] = useState([]) const [customComponentData, setCustomComponentData] = useState({}) const [customComponentVisible, setCustomComponentVisible] = useState(false) const [actionData, setActionData] = useState({ @@ -749,12 +748,12 @@ export const CippDataTable = (props) => { } setOffCanvasData(row.original) - const filteredRowsArray = table?.getFilteredRowModel?.()?.rows - if (filteredRowsArray) { - const indexInFiltered = filteredRowsArray.findIndex( + const navigable = table?.getSortedRowModel?.()?.rows + if (navigable) { + const indexInList = navigable.findIndex( (r) => r.original === row.original ) - setOffCanvasRowIndex(indexInFiltered >= 0 ? indexInFiltered : 0) + setOffCanvasRowIndex(indexInList >= 0 ? indexInList : 0) } setOffcanvasVisible(true) }, @@ -843,16 +842,15 @@ export const CippDataTable = (props) => { [settings, createDialog] ) - // Open the extended-info offcanvas for a row, recording its position in the filtered - // row model so prev/next navigation works. Shared by the row menu, the mobile action - // sheet, and card taps. + // Open the extended-info offcanvas for a row, recording its position in the row model so + // prev/next navigation works. Shared by the row menu, the mobile action sheet, and card + // taps. The SORTED model is the one on screen — the filtered model is pre-sort, so a + // position taken from it stops matching the list the moment a column is sorted. const openRowOffCanvas = useCallback((rowOriginal) => { setOffCanvasData(rowOriginal) - const filteredRowsArray = table.getFilteredRowModel().rows - const indexInFiltered = filteredRowsArray.findIndex( - (r) => r.original === rowOriginal - ) - setOffCanvasRowIndex(indexInFiltered >= 0 ? indexInFiltered : 0) + const navigable = table.getSortedRowModel().rows + const indexInList = navigable.findIndex((r) => r.original === rowOriginal) + setOffCanvasRowIndex(indexInList >= 0 ? indexInList : 0) setOffcanvasVisible(true) // eslint-disable-next-line react-hooks/exhaustive-deps }, []) @@ -1051,6 +1049,24 @@ export const CippDataTable = (props) => { ? { extendedInfoFields: cardInfoFields, richFormatting: true } : {} + // The rows the drawer's Prev/Next walks, in the order they are on screen. Read live from + // the table on every render: this used to be mirrored into state by an effect keyed on + // filters and sorting, so the copy was taken once at mount — before the rows had arrived + // — and every table that loads its data asynchronously reported nothing to navigate. + const navigationRows = table.getSortedRowModel().rows + // Prefer the position of the row actually on show, so sorting or filtering while the + // drawer is open carries the counter with it. The stored index covers the case where the + // row has left the list entirely — a background refetch replaces every object, so + // identity alone would strand the position — and is clamped so a list that shrank under + // it can't report "6 of 2". + const derivedRowIndex = offcanvasVisible + ? navigationRows.findIndex((row) => row.original === offCanvasData) + : -1 + const currentRowIndex = + derivedRowIndex >= 0 + ? derivedRowIndex + : Math.min(offCanvasRowIndex, Math.max(navigationRows.length - 1, 0)) + // Remove the useEffect that was resetting filters on table changes // The initial filter application is now handled by the columnFilters state // and the useEffect above that only triggers on actual filter prop changes @@ -1080,19 +1096,6 @@ export const CippDataTable = (props) => { } }, [table.getSelectedRowModel().rows]) - useEffect(() => { - // Update filtered rows whenever table filtering/sorting changes - if (table && table.getFilteredRowModel) { - const rows = table.getFilteredRowModel().rows - setFilteredRows(rows.map((row) => row.original)) - } - }, [ - table, - table.getState().columnFilters, - table.getState().globalFilter, - table.getState().sorting, - ]) - useEffect(() => { //check if the simplecolumns are an array, if (Array.isArray(simpleColumns) && simpleColumns.length > 0) { @@ -1255,31 +1258,29 @@ export const CippDataTable = (props) => { title={offCanvasData?.Name || offCanvas?.title || 'Extended Info'} children={ offCanvas?.children - ? (row) => offCanvas.children(row, offCanvasRowIndex) + ? (row) => offCanvas.children(row, currentRowIndex) : undefined } customComponent={offCanvas?.customComponent} onNavigateUp={() => { - const newIndex = offCanvasRowIndex - 1 - if (newIndex >= 0 && filteredRows && filteredRows[newIndex]) { + const newIndex = currentRowIndex - 1 + if (newIndex >= 0 && navigationRows[newIndex]) { setOffCanvasRowIndex(newIndex) - setOffCanvasData(filteredRows[newIndex]) + setOffCanvasData(navigationRows[newIndex].original) } }} onNavigateDown={() => { - const newIndex = offCanvasRowIndex + 1 - if (filteredRows && newIndex < filteredRows.length) { + const newIndex = currentRowIndex + 1 + if (navigationRows[newIndex]) { setOffCanvasRowIndex(newIndex) - setOffCanvasData(filteredRows[newIndex]) + setOffCanvasData(navigationRows[newIndex].original) } }} - canNavigateUp={offCanvasRowIndex > 0} - canNavigateDown={ - filteredRows && offCanvasRowIndex < filteredRows.length - 1 - } + canNavigateUp={currentRowIndex > 0} + canNavigateDown={currentRowIndex < navigationRows.length - 1} navigationPosition={{ - index: offCanvasRowIndex + 1, - total: filteredRows?.length ?? 0, + index: currentRowIndex + 1, + total: navigationRows.length, }} {...offCanvas} {...cardInfoOverride} diff --git a/frontend/src/components/CippWizard/CippIntunePolicy.jsx b/frontend/src/components/CippWizard/CippIntunePolicy.jsx index 41cbb6ef35..0325ced59f 100644 --- a/frontend/src/components/CippWizard/CippIntunePolicy.jsx +++ b/frontend/src/components/CippWizard/CippIntunePolicy.jsx @@ -185,7 +185,7 @@ export const CippIntunePolicy = (props) => { return null } return filteredPlaceholders.map((placeholder) => ( - + {selectedTenants.map((tenant, idx) => ( { {/* Conditional Access Policy Selector */} - + { sortOptions={true} /> - + { {/* Report Style - Full Width */} - + - + { > - + { - + { required /> - + +
    + + ) + } + + beforeEach(() => { + useMobileViewport() + }) + + afterEach(() => { + resetOverlayHistory() + delete window.matchMedia + }) + + it('counts rows that arrived after the table mounted', async () => { + const user = userEvent.setup() + renderWithProviders() + + await user.click(screen.getByRole('button', { name: 'Load rows' })) + await waitFor(() => expect(screen.getByText('Carol Williams')).toBeInTheDocument()) + await user.click(screen.getByText('Carol Williams')) + + expect(await screen.findByText('1 of 3')).toBeInTheDocument() + expect(screen.getByRole('button', { name: /prev/i })).toBeDisabled() + expect(screen.getByRole('button', { name: /next/i })).toBeEnabled() + }) + + it('numbers rows in the order they are shown, not the order they arrived', async () => { + const user = userEvent.setup() + renderWithProviders( +
    + ) + + // Sorted, Carol is last on screen — so she is the last row, with nowhere to go next. + await waitFor(() => expect(screen.getByText('Carol Williams')).toBeInTheDocument()) + await user.click(screen.getByText('Carol Williams')) + + expect(await screen.findByText('3 of 3')).toBeInTheDocument() + expect(screen.getByRole('button', { name: /next/i })).toBeDisabled() + }) + + it('counts only the rows left after a search', async () => { + const user = userEvent.setup() + const withTwoBobs = [ + ...people, + { displayName: 'Bob Marley', mail: 'bob.marley@contoso.com' }, + ] + renderWithProviders( +
    + ) + + await waitFor(() => expect(screen.getByText('Alice Smith')).toBeInTheDocument()) + await user.type(screen.getByRole('searchbox', { name: 'Search' }), 'bob') + await waitFor(() => expect(screen.queryByText('Alice Smith')).not.toBeInTheDocument()) + + await user.click(screen.getByText('Bob Marley')) + + // the sorted model is built from the FILTERED rows, so the search narrows the walk + // too: two Bobs, not four people. + expect(await screen.findByText('2 of 2')).toBeInTheDocument() + expect(screen.getByRole('button', { name: /next/i })).toBeDisabled() + expect(screen.getByRole('button', { name: /prev/i })).toBeEnabled() + }) + + it('steps to the next row as displayed', async () => { + const user = userEvent.setup() + renderWithProviders( +
    + ) + + await waitFor(() => expect(screen.getByText('Alice Smith')).toBeInTheDocument()) + await user.click(screen.getByText('Alice Smith')) + expect(await screen.findByText('1 of 3')).toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: /next/i })) + + // Bob follows Alice on screen; Carol is where the raw arrival order would have landed. + expect(await screen.findByText('2 of 3')).toBeInTheDocument() + // scoped by the drawer's own heading — the toolbar renders a filter Drawer too + const drawer = screen.getByText('Extended Info').closest('.MuiDrawer-paper') + expect(within(drawer).getByText('bob@contoso.com')).toBeInTheDocument() + }) +}) diff --git a/frontend/tests/lint/mobile-layout-patterns.test.js b/frontend/tests/lint/mobile-layout-patterns.test.js index c1fb97eb72..33cb2aab1b 100644 --- a/frontend/tests/lint/mobile-layout-patterns.test.js +++ b/frontend/tests/lint/mobile-layout-patterns.test.js @@ -45,7 +45,9 @@ const openingTags = (source, name) => { if (char === "{") depth += 1; else if (char === "}") depth -= 1; else if (char === ">" && depth === 0) { - tags.push({ text: source.slice(match.index, i + 1), line: source.slice(0, match.index).split("\n").length }); + const text = source.slice(match.index, i + 1); + const line = source.slice(0, match.index).split("\n").length; + tags.push({ text, line, endLine: line + text.split("\n").length - 1 }); break; } } @@ -53,6 +55,48 @@ const openingTags = (source, name) => { return tags; }; +// Not every fixed split is a bug — a tile can be designed to sit two-up at 390px. Marking +// the site opts it out, deliberately, in the source, next to the reason, where +// `rg mobile-layout-ok` finds every one of them. Read from the RAW source because comments +// are stripped before matching, and counted on the tag's own lines or the three above it, +// since JSX has nowhere to put a comment between props. +const MARKER = "mobile-layout-ok"; +const LOOKBACK = 3; + +const isExempt = (marked, tag) => { + for (let line = tag.line - LOOKBACK; line <= tag.endLine; line += 1) { + if (marked.has(line)) return true; + } + return false; +}; + +/** Grid splits that survive a phone, as `line reason` strings. */ +export const gridOffenders = (rawSource) => { + const source = stripComments(rawSource); + const marked = new Set(); + rawSource.split("\n").forEach((text, index) => { + if (text.includes(MARKER)) marked.add(index + 1); + }); + + const offenders = []; + for (const tag of openingTags(source, "Grid")) { + if (isExempt(marked, tag)) continue; + const bare = tag.text.match(/\bsize=\{(\d+(?:\.\d+)?)\}/); + if (bare && Number(bare[1]) !== 12) { + offenders.push(`${tag.line} size={${bare[1]}}`); + } + const xs = tag.text.match(/\bsize=\{\{[^}]*?\bxs:\s*(\d+(?:\.\d+)?)/); + if (xs && Number(xs[1]) < 12) { + offenders.push(`${tag.line} xs: ${xs[1]}`); + } + // v1 props are silently inert under Grid v2 — the split never applied at all + if (/]*\bxs=\{/.test(tag.text)) { + offenders.push(`${tag.line} legacy xs= prop (inert under Grid v2)`); + } + } + return offenders; +}; + const files = walk(SRC); describe("mobile layout patterns", () => { @@ -61,29 +105,27 @@ describe("mobile layout patterns", () => { }); it("declares no Grid column split that survives a phone", () => { - const offenders = []; - for (const file of files) { - const source = stripComments(fs.readFileSync(file, "utf8")); - for (const { text, line } of openingTags(source, "Grid")) { - const bare = text.match(/\bsize=\{(\d+(?:\.\d+)?)\}/); - if (bare && Number(bare[1]) !== 12) { - offenders.push(`${rel(file)}:${line} size={${bare[1]}}`); - } - const xs = text.match(/\bsize=\{\{[^}]*?\bxs:\s*(\d+(?:\.\d+)?)/); - if (xs && Number(xs[1]) < 12) { - offenders.push(`${rel(file)}:${line} xs: ${xs[1]}`); - } - // v1 props are silently inert under Grid v2 — the split never applied at all - if (/]*\bxs=\{/.test(text)) { - offenders.push(`${rel(file)}:${line} legacy xs= prop (inert under Grid v2)`); - } - } - } + const offenders = files.flatMap((file) => + gridOffenders(fs.readFileSync(file, "utf8")).map((offender) => `${rel(file)}:${offender}`) + ); expect(offenders, `Use size={{ xs: 12, sm|md: N }} instead:\n${offenders.join("\n")}`).toEqual( [] ); }); + it("takes a marked split at its word", () => { + const split = " \n"; + expect(gridOffenders(split)).toEqual(["1 xs: 6"]); + // on a line above, which is the only place JSX leaves room for one + expect(gridOffenders(` // two-up by design: ${MARKER}\n${split}`)).toEqual([]); + // or among the props of a tag spanning several lines + expect( + gridOffenders(` \n`) + ).toEqual([]); + // but a marker further up the file does not blanket the rest of it + expect(gridOffenders(` // ${MARKER}\n\n\n\n\n${split}`)).toEqual(["6 xs: 6"]); + }); + it("gives every wrapping Stack useFlexGap", () => { const offenders = []; for (const file of files) { From aef31c8d12a79401b9c034a3b83f9626eaf6b2eb Mon Sep 17 00:00:00 2001 From: John Duprey Date: Sun, 9 Aug 2026 14:25:49 -0400 Subject: [PATCH 009/226] fix(ui): reduce horizontal gutters on mobile viewports Cards, accordions, and nested permission builder components consumed too much horizontal space on phones (390px). Fixes include: - Theme-level overrides to trim Card and Accordion padding below md breakpoint - Responsive accordion summary layout in CippAppPermissionBuilder to prevent app-id chip overflow - Reduced drawer padding on mobile in CippPermissionSetDrawer - Responsive paper padding in CippAddTestReportDrawer - Storybook story and vitest unit tests covering the new behavior --- .../CippAddTestReportDrawer.jsx | 2 +- .../CippAppPermissionBuilder.jsx | 50 +++++++-- .../CippPermissionSetDrawer.jsx | 4 +- frontend/src/theme/base/create-components.js | 39 +++++++ .../CippAppPermissionBuilder.stories.jsx | 105 ++++++++++++++++++ frontend/tests/theme/mobile-gutters.test.js | 36 ++++++ 6 files changed, 225 insertions(+), 11 deletions(-) create mode 100644 frontend/tests/components/CippComponents/CippAppPermissionBuilder.stories.jsx create mode 100644 frontend/tests/theme/mobile-gutters.test.js diff --git a/frontend/src/components/CippComponents/CippAddTestReportDrawer.jsx b/frontend/src/components/CippComponents/CippAddTestReportDrawer.jsx index 4de49cf6ee..602415a3ea 100644 --- a/frontend/src/components/CippComponents/CippAddTestReportDrawer.jsx +++ b/frontend/src/components/CippComponents/CippAddTestReportDrawer.jsx @@ -262,7 +262,7 @@ export const CippAddTestReportDrawer = ({ > {/* Test Suite Details Section */} - + Test Suite Details diff --git a/frontend/src/components/CippComponents/CippAppPermissionBuilder.jsx b/frontend/src/components/CippComponents/CippAppPermissionBuilder.jsx index 013fe84309..ffc1b49bf3 100644 --- a/frontend/src/components/CippComponents/CippAppPermissionBuilder.jsx +++ b/frontend/src/components/CippComponents/CippAppPermissionBuilder.jsx @@ -1013,23 +1013,55 @@ const CippAppPermissionBuilder = ({ onChange={handleChange(sp.appId)} slotProps={{ transition: { unmountOnExit: true } }} > - }> + } + // Flex children default to min-width:auto, so without this the + // 36-character app-id chip refuses to shrink and pushes the whole + // summary — display name first — off the left edge of a phone. + sx={{ "& .MuiAccordionSummary-content": { minWidth: 0 } }} + > - {sp.displayName} - + + {sp.displayName} + + @@ -1047,7 +1079,7 @@ const CippAppPermissionBuilder = ({ variant="outlined" size="small" label={getPermissionCounts(sp.appId)} - sx={{ width: "100px" }} + sx={{ width: "100px", flexShrink: 0 }} icon={ diff --git a/frontend/src/components/CippComponents/CippPermissionSetDrawer.jsx b/frontend/src/components/CippComponents/CippPermissionSetDrawer.jsx index cd432409a1..8d44885752 100644 --- a/frontend/src/components/CippComponents/CippPermissionSetDrawer.jsx +++ b/frontend/src/components/CippComponents/CippPermissionSetDrawer.jsx @@ -148,7 +148,9 @@ export const CippPermissionSetDrawer = ({ onClose={handleDrawerClose} size="xl" > - + {/* The drawer already pays contentPadding on a phone; 24px more on top of it, plus + each card's own gutters, leaves the form reading through a third of the screen. */} + {isEditMode diff --git a/frontend/src/theme/base/create-components.js b/frontend/src/theme/base/create-components.js index c03f8c6f17..6d84b1f6db 100644 --- a/frontend/src/theme/base/create-components.js +++ b/frontend/src/theme/base/create-components.js @@ -71,6 +71,29 @@ export const createComponents = () => { disableRipple: true, }, }, + MuiAccordionDetails: { + styleOverrides: { + // An accordion is almost always nested inside a card that already pays for gutters, + // and its own content usually adds a third layer. Halve the horizontal padding on a + // phone so the innermost text is not reading through 70px of chrome. + root: { + "@media (max-width: 899.95px)": { + paddingLeft: 8, + paddingRight: 8, + }, + }, + }, + }, + MuiAccordionSummary: { + styleOverrides: { + root: { + "@media (max-width: 899.95px)": { + paddingLeft: 8, + paddingRight: 8, + }, + }, + }, + }, MuiCardActions: { styleOverrides: { root: { @@ -78,6 +101,10 @@ export const createComponents = () => { paddingLeft: 24, paddingRight: 24, paddingTop: 16, + "@media (max-width: 899.95px)": { + paddingLeft: 16, + paddingRight: 16, + }, }, }, }, @@ -88,6 +115,13 @@ export const createComponents = () => { paddingLeft: 24, paddingRight: 24, paddingTop: 20, + // 48px of the 390 a phone has is 12% of the screen spent on one card's gutters, + // and cards nest — a card inside an accordion inside a page card pays it three + // times over. Vertical padding is left alone; it isn't what runs out. + "@media (max-width: 899.95px)": { + paddingLeft: 16, + paddingRight: 16, + }, }, }, }, @@ -98,6 +132,11 @@ export const createComponents = () => { paddingLeft: 24, paddingRight: 24, paddingTop: 16, + // Matches MuiCardContent, or the header would sit inset from its own card body. + "@media (max-width: 899.95px)": { + paddingLeft: 16, + paddingRight: 16, + }, }, subheader: { fontSize: 14, diff --git a/frontend/tests/components/CippComponents/CippAppPermissionBuilder.stories.jsx b/frontend/tests/components/CippComponents/CippAppPermissionBuilder.stories.jsx new file mode 100644 index 0000000000..ebbf226dd1 --- /dev/null +++ b/frontend/tests/components/CippComponents/CippAppPermissionBuilder.stories.jsx @@ -0,0 +1,105 @@ +import React from 'react' +import { http, HttpResponse } from 'msw' +import { within, expect, waitFor } from 'storybook/test' +import { Box } from '@mui/material' +import { useForm } from 'react-hook-form' +import CippAppPermissionBuilder from '../../../src/components/CippComponents/CippAppPermissionBuilder' + +// The summary row carries a 36-character app id, so this is where the overflow shows up. +const graph = { + id: 'sp-graph', + appId: '00000003-0000-0000-c000-000000000000', + displayName: 'Microsoft Graph', + appRoles: [], + publishedPermissionScopes: [], +} + +const servicePrincipals = { Metadata: { Success: true }, Results: [graph] } + +// The same route serves the list and, with ?Id=, one principal's detail — where Results is +// an object rather than an array. +const handlers = [ + http.get('*/api/ExecServicePrincipals', ({ request }) => { + const id = new URL(request.url).searchParams.get('Id') + return HttpResponse.json( + id ? { Metadata: { Success: true }, Results: graph } : servicePrincipals + ) + }), +] + +// Only the vitest browser runner can resize the iframe, and importing its context at module +// scope throws in the Storybook app itself ("can be imported only inside the Browser Mode"), +// which breaks the story for anyone opening it. Ask for it lazily and carry on without it. +const shrinkToPhoneViewport = async () => { + try { + const { page } = await import('@vitest/browser/context') + await page.viewport(390, 844) + return true + } catch { + return false + } +} + +const Harness = (props) => { + const formControl = useForm({ mode: 'onChange', defaultValues: { servicePrincipal: null } }) + return ( + {}} + updatePermissions={{ isPending: false, isSuccess: false, isError: false }} + currentPermissions={{ + Permissions: { + '00000003-0000-0000-c000-000000000000': { + applicationPermissions: [{ id: '1', value: 'Application.ReadWrite.All' }], + delegatedPermissions: [{ id: '2', value: 'User.Read' }], + }, + }, + }} + {...props} + /> + ) +} + +export default { + title: 'Components/CippComponents/CippAppPermissionBuilder', + component: CippAppPermissionBuilder, + parameters: { msw: { handlers } }, +} + +// jsdom has no layout engine, so overflow is invisible to the unit tests — this is the one +// place a real browser can measure it. 390px is an iPhone 14/15 in portrait. +// +// The VIEWPORT has to shrink, not a wrapper: MUI's breakpoints are media queries, so a +// 390px-wide Box inside a desktop-width iframe still renders every `md` branch. +export const PhoneWidth = { + render: () => ( + + + + ), + play: async ({ canvasElement }) => { + const onAPhone = await shrinkToPhoneViewport() + const canvas = within(canvasElement) + await canvas.findByText('Microsoft Graph', {}, { timeout: 10000 }) + // Opened in the Storybook app rather than the test runner: the layout is on show, but + // measuring it against a desktop-width iframe would only assert the wrong thing. + if (!onAPhone) return + + // The app-id chip used to force the row wider than the phone, pushing the service + // principal's name off the left edge — the row scrolled, the name was unreachable. + await waitFor(() => { + const rows = canvasElement.querySelectorAll('.MuiAccordionSummary-root') + expect(rows.length).toBeGreaterThan(0) + rows.forEach((row) => { + expect(row.scrollWidth).toBeLessThanOrEqual(row.clientWidth) + }) + }) + + // and the name is inside the viewport, not off to the left of it + const name = canvas.getByText('Microsoft Graph') + const phone = canvasElement.querySelector('[data-testid="phone"]') + expect(name.getBoundingClientRect().left).toBeGreaterThanOrEqual( + phone.getBoundingClientRect().left + ) + }, +} diff --git a/frontend/tests/theme/mobile-gutters.test.js b/frontend/tests/theme/mobile-gutters.test.js new file mode 100644 index 0000000000..3b77d52bef --- /dev/null +++ b/frontend/tests/theme/mobile-gutters.test.js @@ -0,0 +1,36 @@ +import { describe, it, expect } from "vitest"; +import { createTheme } from "../../src/theme"; + +// Card gutters are set once in the theme and paid at every nesting level: a card inside an +// accordion inside a page card spends most of a phone's width on chrome before any content +// gets a pixel. These have to stay narrower below md — and desktop has to keep its 24px. +const MOBILE = "@media (max-width: 899.95px)"; + +describe("horizontal gutters on small screens", () => { + const theme = createTheme({ colorPreset: "orange", contrast: "high", paletteMode: "light" }); + const root = (key) => theme.components[key].styleOverrides.root; + + it.each(["MuiCardContent", "MuiCardHeader", "MuiCardActions"])( + "%s trims its 24px gutters on a phone", + (key) => { + expect(root(key).paddingLeft).toBe(24); + expect(root(key)[MOBILE]?.paddingLeft).toBe(16); + expect(root(key)[MOBILE]?.paddingRight).toBe(16); + } + ); + + it.each(["MuiAccordionSummary", "MuiAccordionDetails"])( + "%s halves the padding it adds inside a card", + (key) => { + expect(root(key)[MOBILE]?.paddingLeft).toBe(8); + expect(root(key)[MOBILE]?.paddingRight).toBe(8); + } + ); + + it("leaves vertical rhythm alone — width is what runs out, not height", () => { + const content = root("MuiCardContent"); + expect(content.paddingTop).toBe(20); + expect(content[MOBILE]?.paddingTop).toBeUndefined(); + expect(content[MOBILE]?.paddingBottom).toBeUndefined(); + }); +}); From b89a47f4e75a658ef2d1873b98f551987d3333ed Mon Sep 17 00:00:00 2001 From: John Duprey Date: Sun, 9 Aug 2026 14:38:23 -0400 Subject: [PATCH 010/226] fix(layout): normalize container padding at sm breakpoint MUI's Container increases gutters at the sm breakpoint (600px), causing inconsistent 24px padding while the rest of the app uses 16px. Override px to match the app's md-based layout breakpoint. --- frontend/src/components/CippCards/CippPageCard.jsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/CippCards/CippPageCard.jsx b/frontend/src/components/CippCards/CippPageCard.jsx index 02ab599fad..0766b0b9e3 100644 --- a/frontend/src/components/CippCards/CippPageCard.jsx +++ b/frontend/src/components/CippCards/CippPageCard.jsx @@ -28,7 +28,9 @@ const CippPageCard = (props) => { pb: { xs: 2, md: 4 }, }} > - + {/* MUI's Container widens its gutters at sm; every layout in this app switches at + md, so a 600-900px viewport got 24px here and 16px everywhere else. */} + {hideTitleText !== true && ( From 8ed2e1fcfe4c2b0417293038cdbdaf8b043600ae Mon Sep 17 00:00:00 2001 From: John Duprey Date: Sun, 9 Aug 2026 14:55:18 -0400 Subject: [PATCH 011/226] feat(reports): responsive mobile layout for report preview dialogs On small screens the 320px config rail leaves the PDF preview unusably narrow. Below md it now hides behind a CippOffCanvas drawer opened from a Settings icon in the dialog title bar. - Add `aboveModal` prop to CippOffCanvas to lift its z-index above a parent Dialog - Refactor section config into a shared `sectionPanel()` render function used by both the desktop rail and mobile drawer - Switch dialog height to `100dvh` on mobile to avoid iOS address-bar overflow - Stack DialogActions vertically on mobile so the primary action stays in thumb reach - Apply `minWidth: 0` to flex children to prevent overflow - Cover the new behaviour with jsdom tests scoped to the drawer element --- .../CippComponents/CippOffCanvas.jsx | 4 + .../src/components/ExecutiveReportButton.js | 262 ++++++++++-------- .../src/components/ShadowAIReportButton.js | 204 +++++++++----- .../components/ExecutiveReportButton.test.jsx | 48 +++- 4 files changed, 329 insertions(+), 189 deletions(-) diff --git a/frontend/src/components/CippComponents/CippOffCanvas.jsx b/frontend/src/components/CippComponents/CippOffCanvas.jsx index ddf8229fcd..66705e0752 100644 --- a/frontend/src/components/CippComponents/CippOffCanvas.jsx +++ b/frontend/src/components/CippComponents/CippOffCanvas.jsx @@ -30,6 +30,7 @@ export const CippOffCanvas = (props) => { contentPadding = 2, keepMounted = false, richFormatting = false, + aboveModal = false, } = props; const mdDown = useMediaQuery((theme) => theme.breakpoints.down("md")); @@ -91,6 +92,9 @@ export const CippOffCanvas = (props) => { ModalProps={{ keepMounted: keepMounted, }} + // A stock Drawer sits at 1200 and a Dialog at 1300, so a drawer opened from inside a + // dialog renders behind it. Same lift CippBottomSheet takes, for the same reason. + sx={aboveModal ? { zIndex: (theme) => theme.zIndex.modal + 1 } : undefined} anchor={"right"} open={visible} onClose={onClose} diff --git a/frontend/src/components/ExecutiveReportButton.js b/frontend/src/components/ExecutiveReportButton.js index d02189760c..e76126114e 100644 --- a/frontend/src/components/ExecutiveReportButton.js +++ b/frontend/src/components/ExecutiveReportButton.js @@ -19,6 +19,7 @@ import { } from '@mui/material' import { PictureAsPdf, Download, Close, Settings } from '@mui/icons-material' import { CippAutoComplete } from './CippComponents/CippAutocomplete' +import { CippOffCanvas } from './CippComponents/CippOffCanvas' import { Document, Page, Text, View, PDFViewer, Image } from '@react-pdf/renderer' import { useSettings } from '../hooks/use-settings' import { useSecureScore } from '../hooks/use-securescore' @@ -1622,6 +1623,10 @@ export const ExecutiveReportButton = (props) => { setPreviewOpen(false) } + // Below md the 320px config rail would leave the preview about 70px wide, so it moves into + // a drawer and the preview takes the whole dialog. + const [sectionsOpen, setSectionsOpen] = useState(false) + // Section configuration options const sectionOptions = [ { @@ -1671,6 +1676,102 @@ export const ExecutiveReportButton = (props) => { }, ] + // One definition, two homes: the desktop rail and the mobile drawer. The drawer's own + // header already says "Report Sections", so it takes the panel without the heading. + const sectionPanel = ({ showHeading = true } = {}) => ( + + {showHeading && ( + + + Report Sections + + )} + + Configure which sections to include in your executive report. Changes are reflected in + real-time. + + + + option.value === brandingPresetId) ?? presetOptions[0] + } + onChange={(option) => setPresetOverride(option?.value ?? '')} + /> + + Presets are managed in Settings → Branding + + + + + {sectionOptions.map((option) => ( + handleSectionToggle(option.key)} + sx={{ + p: 1.5, + border: '1px solid', + borderColor: sectionConfig[option.key] ? 'primary.main' : 'divider', + bgcolor: sectionConfig[option.key] ? 'primary.50' : 'background.paper', + cursor: 'pointer', + transition: 'all 0.2s ease-in-out', + display: 'flex', + alignItems: 'center', + '&:hover': { + borderColor: 'primary.main', + bgcolor: sectionConfig[option.key] ? 'primary.100' : 'primary.25', + }, + }} + > + { + event.stopPropagation() + handleSectionToggle(option.key) + }} + onClick={(event) => event.stopPropagation()} + color="primary" + size="small" + disabled={ + sectionConfig[option.key] && + Object.values(sectionConfig).filter(Boolean).length === 1 + } + /> + + + {option.label} + + + {option.description} + + + + ))} + + + + + 💡 Pro Tip + + + Enable only the sections relevant to your audience to create focused, impactful reports. + At least one section must be enabled. + + + + ) + return ( <> {/* Main Executive Summary Button - Always available */} @@ -1742,8 +1843,9 @@ export const ExecutiveReportButton = (props) => { fullWidth sx={{ '& .MuiDialog-paper': { - height: '95vh', - maxHeight: '95vh', + // dvh, not vh: iOS counts the collapsing address bar in vh, so 95vh overflows. + height: { xs: '100dvh', md: '95vh' }, + maxHeight: { xs: '100dvh', md: '95vh' }, }, }} > @@ -1757,16 +1859,28 @@ export const ExecutiveReportButton = (props) => { borderColor: 'divider', }} > - + Executive Report - {tenantName} - - - + + {/* The config rail's stand-in below md, in the title bar because the dialog is + full-screen there and this is the only chrome that stays put. */} + setSectionsOpen(true)} + size="small" + aria-label="Report sections" + sx={{ display: { xs: 'inline-flex', md: 'none' } }} + > + + + + + + - {/* Left Panel - Section Configuration */} + {/* Left Panel - Section Configuration. Below md it lives in the drawer instead. */} { borderColor: 'divider', height: '100%', overflow: 'auto', + display: { xs: 'none', md: 'block' }, }} > - - - - Report Sections - - - Configure which sections to include in your executive report. Changes are reflected - in real-time. - - - - option.value === brandingPresetId) ?? - presetOptions[0] - } - onChange={(option) => setPresetOverride(option?.value ?? '')} - /> - - Presets are managed in Settings → Branding - - - - - {sectionOptions.map((option) => ( - handleSectionToggle(option.key)} - sx={{ - p: 1.5, - border: '1px solid', - borderColor: sectionConfig[option.key] ? 'primary.main' : 'divider', - bgcolor: sectionConfig[option.key] ? 'primary.50' : 'background.paper', - cursor: 'pointer', - transition: 'all 0.2s ease-in-out', - display: 'flex', - alignItems: 'center', - '&:hover': { - borderColor: 'primary.main', - bgcolor: sectionConfig[option.key] ? 'primary.100' : 'primary.25', - }, - }} - > - { - event.stopPropagation() - handleSectionToggle(option.key) - }} - onClick={(event) => event.stopPropagation()} - color="primary" - size="small" - disabled={ - sectionConfig[option.key] && - Object.values(sectionConfig).filter(Boolean).length === 1 - } - /> - - - {option.label} - - - {option.description} - - - - ))} - - - - - 💡 Pro Tip - - - Enable only the sections relevant to your audience to create focused, impactful - reports. At least one section must be enabled. - - - + {sectionPanel()} {/* Right Panel - PDF Preview */} - + {isDataLoading ? ( { - + :not(style) ~ :not(style)': { ml: { xs: 0, md: 1 } }, + }} + > Sections enabled: {Object.values(sectionConfig).filter(Boolean).length} of{' '} @@ -2000,6 +2025,19 @@ export const ExecutiveReportButton = (props) => { Close + + {/* Mounted inside the Dialog so it inherits its theme scope; aboveModal lifts it over + the dialog it is opened from. */} + setSectionsOpen(false)} + title="Report Sections" + size="sm" + contentPadding={0} + aboveModal + > + {sectionPanel({ showHeading: false })} + ) diff --git a/frontend/src/components/ShadowAIReportButton.js b/frontend/src/components/ShadowAIReportButton.js index 54153c42a0..2464eb788a 100644 --- a/frontend/src/components/ShadowAIReportButton.js +++ b/frontend/src/components/ShadowAIReportButton.js @@ -16,6 +16,7 @@ import { } from '@mui/material' import { Close, Download, PictureAsPdf, Settings } from '@mui/icons-material' import { PDFViewer } from '@react-pdf/renderer' +import { CippOffCanvas } from './CippComponents/CippOffCanvas' import { useReportVariables } from './CippPdf/useReportVariables' import { useBrandingSettings } from './CippPdf/useBrandingSettings' import { @@ -576,6 +577,9 @@ export const ShadowAIReportButton = ({ data, tenantName, disabled }) => { const brandingSettings = useBrandingSettings() const variables = useReportVariables() const [previewOpen, setPreviewOpen] = useState(false) + // Below md the 320px config rail would leave the preview about 70px wide, so it moves into + // a drawer and the preview takes the whole dialog. Same treatment as the executive report. + const [sectionsOpen, setSectionsOpen] = useState(false) const [sectionConfig, setSectionConfig] = useState({ coverPage: true, executiveSummary: true, @@ -603,6 +607,73 @@ export const ShadowAIReportButton = ({ data, tenantName, disabled }) => { new Date().toISOString().split('T')[0] }.pdf` + // One definition, two homes: the desktop rail and the mobile drawer. The drawer's own + // header already says "Report Sections", so it takes the panel without the heading. + const sectionPanel = ({ showHeading = true } = {}) => ( + + {showHeading && ( + + + Report Sections + + )} + + Configure which sections to include in your Shadow AI report. Changes are reflected in + real-time. + + + + {sectionOptions.map((option) => ( + handleSectionToggle(option.key)} + sx={{ + p: 1.5, + border: '1px solid', + borderColor: sectionConfig[option.key] ? 'primary.main' : 'divider', + bgcolor: sectionConfig[option.key] ? 'primary.50' : 'background.paper', + cursor: 'pointer', + transition: 'all 0.2s ease-in-out', + display: 'flex', + alignItems: 'center', + '&:hover': { + borderColor: 'primary.main', + bgcolor: sectionConfig[option.key] ? 'primary.100' : 'primary.25', + }, + }} + > + { + event.stopPropagation() + handleSectionToggle(option.key) + }} + onClick={(event) => event.stopPropagation()} + color="primary" + size="small" + disabled={ + sectionConfig[option.key] && + Object.values(sectionConfig).filter(Boolean).length === 1 + } + /> + + + {option.label} + + + {option.description} + + + + ))} + + + ) + const reportDocument = useMemo(() => { if (!previewOpen) return null return ( @@ -642,7 +713,13 @@ export const ShadowAIReportButton = ({ data, tenantName, disabled }) => { onClose={() => setPreviewOpen(false)} maxWidth="xl" fullWidth - sx={{ '& .MuiDialog-paper': { height: '95vh', maxHeight: '95vh' } }} + sx={{ + '& .MuiDialog-paper': { + // dvh, not vh: iOS counts the collapsing address bar in vh, so 95vh overflows. + height: { xs: '100dvh', md: '95vh' }, + maxHeight: { xs: '100dvh', md: '95vh' }, + }, + }} > { borderColor: 'divider', }} > - + Shadow AI Report - {tenantName} - setPreviewOpen(false)} size="small"> - - + + {/* The config rail's stand-in below md, in the title bar because the dialog is + full-screen there and this is the only chrome that stays put. */} + setSectionsOpen(true)} + size="small" + aria-label="Report sections" + sx={{ display: { xs: 'inline-flex', md: 'none' } }} + > + + + setPreviewOpen(false)} + size="small" + aria-label="Close preview" + > + + + - {/* Left Panel - Section Configuration */} + {/* Left Panel - Section Configuration. Below md it lives in the drawer instead. */} { borderColor: 'divider', height: '100%', overflow: 'auto', + display: { xs: 'none', md: 'block' }, }} > - - - - Report Sections - - - Configure which sections to include in your Shadow AI report. Changes are reflected - in real-time. - - - - {sectionOptions.map((option) => ( - handleSectionToggle(option.key)} - sx={{ - p: 1.5, - border: '1px solid', - borderColor: sectionConfig[option.key] ? 'primary.main' : 'divider', - bgcolor: sectionConfig[option.key] ? 'primary.50' : 'background.paper', - cursor: 'pointer', - transition: 'all 0.2s ease-in-out', - display: 'flex', - alignItems: 'center', - '&:hover': { - borderColor: 'primary.main', - bgcolor: sectionConfig[option.key] ? 'primary.100' : 'primary.25', - }, - }} - > - { - event.stopPropagation() - handleSectionToggle(option.key) - }} - onClick={(event) => event.stopPropagation()} - color="primary" - size="small" - disabled={ - sectionConfig[option.key] && - Object.values(sectionConfig).filter(Boolean).length === 1 - } - /> - - - {option.label} - - - {option.description} - - - - ))} - - + {sectionPanel()} {/* Right Panel - PDF Preview */} - + {reportDocument && ( { )} - + :not(style) ~ :not(style)': { ml: { xs: 0, md: 1 } }, + }} + > Sections enabled: {Object.values(sectionConfig).filter(Boolean).length} of{' '} @@ -803,6 +842,19 @@ export const ShadowAIReportButton = ({ data, tenantName, disabled }) => { Close + + {/* Mounted inside the Dialog so it inherits its theme scope; aboveModal lifts it over + the dialog it is opened from. */} + setSectionsOpen(false)} + title="Report Sections" + size="sm" + contentPadding={0} + aboveModal + > + {sectionPanel({ showHeading: false })} + ) diff --git a/frontend/tests/components/ExecutiveReportButton.test.jsx b/frontend/tests/components/ExecutiveReportButton.test.jsx index 0c522fc1d5..cf86374b85 100644 --- a/frontend/tests/components/ExecutiveReportButton.test.jsx +++ b/frontend/tests/components/ExecutiveReportButton.test.jsx @@ -1,5 +1,5 @@ import React from 'react' -import { screen } from '@testing-library/react' +import { screen, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { renderWithProviders } from '../test-utils' import { ExecutiveReportButton } from '../../src/components/ExecutiveReportButton' @@ -99,4 +99,50 @@ describe('ExecutiveReportButton', () => { expect(onClick).toHaveBeenCalled() expect(await screen.findByRole('dialog')).toBeInTheDocument() }) + + // The 320px config rail would leave the preview about 70px wide on a phone, so below md it + // moves into a drawer. Both homes render the same panel, and the toggles have to keep + // working from the drawer. + describe('section configuration on a phone', () => { + const openSections = async () => { + renderWithProviders() + await userEvent.click(screen.getByRole('button', { name: /executive summary/i })) + await screen.findByRole('dialog') + await userEvent.click(screen.getByRole('button', { name: 'Report sections' })) + // jsdom applies no media queries, so the desktop rail is in the document too — every + // query here has to be scoped to the drawer or it matches both copies. + return within(document.querySelector('.MuiDrawer-paper')) + } + + it('opens the sections panel in a drawer', async () => { + const drawer = await openSections() + + expect(drawer.getByText('Report Sections')).toBeVisible() + expect(drawer.getByText('Executive Summary')).toBeVisible() + expect(drawer.getByText('Shadow AI Report')).toBeVisible() + }) + + it('toggles a section from inside the drawer', async () => { + const drawer = await openSections() + + const deviceRow = drawer.getByText('Device Management').closest('.MuiPaper-root') + const toggle = within(deviceRow).getByRole('switch') + expect(toggle).toBeChecked() + + await userEvent.click(toggle) + + expect(toggle).not.toBeChecked() + // the footer count is the shared state both panels read + expect(screen.getByText(/Sections enabled: 6 of 9/)).toBeInTheDocument() + }) + + it('lifts the drawer above the dialog that opened it', async () => { + await openSections() + + // A stock Drawer sits below a Dialog and would open behind the preview. + const drawer = document.querySelector('.MuiDrawer-root') + expect(drawer).not.toBeNull() + expect(window.getComputedStyle(drawer).zIndex).toBe('1301') + }) + }) }) From 4f9cda1d996a360afbe220c48b3d69784f4c7ce5 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Mon, 10 Aug 2026 00:09:16 -0400 Subject: [PATCH 012/226] feat(ui): mobile-responsive wizard and filter layouts Overhauls wizard and filter components for mobile viewports: - Replace horizontal wizard stepper with a compact progress header (step N of M + LinearProgress) below md; vertical status list (GDAP onboarding) is unchanged - Add CippWizardActionsRow: shared Back/Next row that stacks column-reverse on phones so the primary action is always first - Add CippWizardActionsRow and CippWizardProgressHeader shared components - Fix handleNext counting against unfiltered steps, which caused an out-of-bounds read on wizards with conditional steps - Autopilot manual-entry dialog: switch to per-device cards on phones instead of a horizontally-scrolling row of six fields - Switch sidebar/mobile-nav breakpoint from md to lg so the side nav persists up to the tablet/laptop boundary - Make button rows in diagnostics, graph explorer, logs, sign-in, mailbox and incidents filters wrap instead of overflow - Add tests and stories for all new behaviours; extract shrinkToPhoneViewport to a shared viewport helper --- .../CippComponents/CippPageActionsFab.jsx | 14 +- .../CippTable/CippDiagnosticsFilter.js | 7 +- .../CippTable/CippGraphExplorerFilter.js | 6 +- .../CippGraphExplorerSimpleFilter.js | 21 ++- .../CippWizard/CippPSACredentialsStep.jsx | 11 +- .../CippWizard/CippPSASyncOptions.jsx | 5 +- .../src/components/CippWizard/CippWizard.jsx | 26 ++- .../CippWizard/CippWizardActionsRow.jsx | 47 +++++ .../CippWizard/CippWizardAutopilotImport.jsx | 167 +++++++++++------- .../CippWizard/CippWizardOffboarding.jsx | 4 +- .../components/CippWizard/CippWizardPage.jsx | 28 ++- .../CippWizard/CippWizardProgressHeader.jsx | 45 +++++ .../CippWizard/CippWizardStepButtons.jsx | 15 +- .../CippWizardVacationConfirmation.jsx | 11 +- .../src/components/CippWizard/wizard-steps.js | 16 +- frontend/src/layouts/index.js | 6 +- .../advanced/container-management/logs.js | 4 +- frontend/src/pages/cipp/logs/index.js | 42 +++-- .../email/reports/mailbox-activity/index.js | 2 +- .../identity/reports/signin-report/index.js | 2 +- .../incidents/list-incidents/index.js | 2 +- .../CippAppPermissionBuilder.stories.jsx | 14 +- .../CippPageActionsFab.test.jsx | 54 +++++- .../CippTable/CippDiagnosticsFilter.test.jsx | 47 +++++ .../CippWizardAutopilotImport.stories.jsx | 75 ++++++++ .../CippWizardAutopilotImport.test.jsx | 83 +++++++++ .../CippWizard/CippWizardPage.stories.jsx | 80 +++++++++ .../CippWizard/wizard-steps.test.jsx | 100 +++++++++++ frontend/tests/viewport.js | 22 +++ 29 files changed, 800 insertions(+), 156 deletions(-) create mode 100644 frontend/src/components/CippWizard/CippWizardActionsRow.jsx create mode 100644 frontend/src/components/CippWizard/CippWizardProgressHeader.jsx create mode 100644 frontend/tests/components/CippTable/CippDiagnosticsFilter.test.jsx create mode 100644 frontend/tests/components/CippWizard/CippWizardAutopilotImport.stories.jsx create mode 100644 frontend/tests/components/CippWizard/CippWizardAutopilotImport.test.jsx create mode 100644 frontend/tests/components/CippWizard/CippWizardPage.stories.jsx create mode 100644 frontend/tests/components/CippWizard/wizard-steps.test.jsx create mode 100644 frontend/tests/viewport.js diff --git a/frontend/src/components/CippComponents/CippPageActionsFab.jsx b/frontend/src/components/CippComponents/CippPageActionsFab.jsx index 7f5aa411f4..a4d9550ed3 100644 --- a/frontend/src/components/CippComponents/CippPageActionsFab.jsx +++ b/frontend/src/components/CippComponents/CippPageActionsFab.jsx @@ -91,17 +91,29 @@ export const CippPageActionsFab = (props) => { p: restackButtons ? 2 : 0, ...(restackButtons && { '& > * ': { width: '100%' }, - '& .MuiBox-root': { + // A cardButton is as often a Stack as a Box (autopilot's three import + // buttons are a `direction="row"` Stack). Matching only Box left those in a + // row while the rule below stretched each button to 100% — three full-width + // buttons side by side, running off the sheet. + '& .MuiBox-root, & .MuiStack-root': { display: 'flex', flexDirection: 'column', alignItems: 'stretch', gap: 1, }, + // Stack's `spacing` compiles to margin-left between children, which survives + // the flip to a column and would indent every row after the first. + '& .MuiStack-root > *': { marginLeft: 0, marginTop: 0 }, '& .MuiButton-root': { width: '100%', justifyContent: 'flex-start', minHeight: 44, }, + // Text buttons default to the primary accent, which on the sheet's paper + // reads as orange-on-grey and doesn't match the ListItemButton rows below + // them. Contained and outlined buttons keep their colour — those are + // deliberate calls to action, not list rows. + '& .MuiButton-text': { color: 'text.primary' }, }), }} onClick={(event) => { diff --git a/frontend/src/components/CippTable/CippDiagnosticsFilter.js b/frontend/src/components/CippTable/CippDiagnosticsFilter.js index e8118a10e0..3eeb44cf00 100644 --- a/frontend/src/components/CippTable/CippDiagnosticsFilter.js +++ b/frontend/src/components/CippTable/CippDiagnosticsFilter.js @@ -19,8 +19,11 @@ import { CippFormComponent } from "../CippComponents/CippFormComponent"; import { ApiGetCall, ApiPostCall } from "../../api/ApiCall"; import { Grid } from "@mui/system"; import defaultPresets from "../../data/DiagnosticsPresets.json"; +import { useIsMobileLayout } from "../../hooks/use-breakpoint"; const CippDiagnosticsFilter = ({ onSubmitFilter }) => { + // A 12-row monospace query box is roughly half a phone viewport before anything else. + const isMobile = useIsMobileLayout(); const [expanded, setExpanded] = useState(true); const [selectedPreset, setSelectedPreset] = useState(null); const [presetOptions, setPresetOptions] = useState([]); @@ -270,7 +273,7 @@ const CippDiagnosticsFilter = ({ onSubmitFilter }) => { label="KQL Query" formControl={formControl} multiline - rows={12} + rows={isMobile ? 6 : 12} placeholder={`Enter your KQL query here, for example:\n\ntraces\n| where timestamp > ago(1h)\n| where severityLevel >= 2\n| project timestamp, message, severityLevel\n| order by timestamp desc`} helperText="Enter a valid Kusto Query Language (KQL) query to execute against Application Insights" sx={{ @@ -281,7 +284,7 @@ const CippDiagnosticsFilter = ({ onSubmitFilter }) => { }} /> - + @@ -171,7 +180,7 @@ const CippGraphExplorerSimpleFilter = ({ variant="outlined" startIcon={} onClick={() => setOffCanvasVisible(true)} - sx={{ minWidth: "120px" }} + sx={{ minWidth: { md: "120px" } }} > Edit Query @@ -180,7 +189,7 @@ const CippGraphExplorerSimpleFilter = ({ variant="outlined" startIcon={viewMode === "table" ? : } onClick={() => onViewModeChange(viewMode === "table" ? "json" : "table")} - sx={{ minWidth: "120px" }} + sx={{ minWidth: { md: "120px" } }} > {viewMode === "table" ? "View JSON" : "View Table"} diff --git a/frontend/src/components/CippWizard/CippPSACredentialsStep.jsx b/frontend/src/components/CippWizard/CippPSACredentialsStep.jsx index 4aa1d94687..cb8a987b11 100644 --- a/frontend/src/components/CippWizard/CippPSACredentialsStep.jsx +++ b/frontend/src/components/CippWizard/CippPSACredentialsStep.jsx @@ -14,6 +14,7 @@ import { LoadingButton } from "@mui/lab"; import { Quiz } from "@mui/icons-material"; import { ApiPostCall } from "../../api/ApiCall"; import { Box } from "@mui/system"; +import { CippWizardActionsRow } from "./CippWizardActionsRow"; export const CippPSACredentialsStep = (props) => { const { values: initialValues, onPreviousStep, onNextStep } = props; const [values, setValues] = useState(initialValues); @@ -210,20 +211,14 @@ export const CippPSACredentialsStep = (props) => { )} - + - + ); diff --git a/frontend/src/components/CippWizard/CippPSASyncOptions.jsx b/frontend/src/components/CippWizard/CippPSASyncOptions.jsx index 146d5b2627..a5ee2e01e6 100644 --- a/frontend/src/components/CippWizard/CippPSASyncOptions.jsx +++ b/frontend/src/components/CippWizard/CippPSASyncOptions.jsx @@ -12,6 +12,7 @@ import { TextField, Typography, } from "@mui/material"; +import { CippWizardActionsRow } from "./CippWizardActionsRow"; const options = [ { @@ -147,14 +148,14 @@ export const CippPSASyncOptions = (props) => { )} - + - + ); diff --git a/frontend/src/components/CippWizard/CippWizard.jsx b/frontend/src/components/CippWizard/CippWizard.jsx index 22f24de234..0c35ce677d 100644 --- a/frontend/src/components/CippWizard/CippWizard.jsx +++ b/frontend/src/components/CippWizard/CippWizard.jsx @@ -34,9 +34,14 @@ export const CippWizard = (props) => { setActiveStep((prevState) => (prevState > 0 ? prevState - 1 : prevState)); }, []); + // Counts against the VISIBLE steps. `steps` is the unfiltered prop — the onboarding + // wizard passes 14 and shows 3-7 — so clamping against it let activeStep run past the + // end of stepsWithVisibility, and the render below then read `.component` of undefined. const handleNext = useCallback(() => { - setActiveStep((prevState) => (prevState < steps.length - 1 ? prevState + 1 : prevState)); - }, []); + setActiveStep((prevState) => + prevState < stepsWithVisibility.length - 1 ? prevState + 1 : prevState + ); + }, [stepsWithVisibility.length]); const content = useMemo(() => { const currentStep = stepsWithVisibility[activeStep]; @@ -57,7 +62,7 @@ export const CippWizard = (props) => { {...currentStep.componentProps} /> ); - }, [activeStep, handleNext, handleBack, stepsWithVisibility, formControl]); + }, [activeStep, handleNext, handleBack, stepsWithVisibility, formControl, postUrl]); // Get the maxWidth for the current step, fallback to global setting const currentStepMaxWidth = useMemo(() => { @@ -85,7 +90,9 @@ export const CippWizard = (props) => { ) : ( - + {/* 48px under a three-line stepper is right; under the compact mobile header it + is dead space. */} + { steps={stepsWithVisibility} />
    - {content} + {/* Below md this Container clamps nothing — maxWidth is md/lg — and its + gutters only duplicate the ones CardContent already pays. disableGutters + with px at md restores exactly Container's own value from md up. */} + + {content} +
    diff --git a/frontend/src/components/CippWizard/CippWizardActionsRow.jsx b/frontend/src/components/CippWizard/CippWizardActionsRow.jsx new file mode 100644 index 0000000000..0641f9dbb1 --- /dev/null +++ b/frontend/src/components/CippWizard/CippWizardActionsRow.jsx @@ -0,0 +1,47 @@ +import PropTypes from "prop-types"; +import { Stack } from "@mui/material"; + +/** + * The Back / Next / Submit row shared by the wizard step buttons and the three steps that + * roll their own. + * + * Presentational only — no behaviour, because the four call sites disagree about what the + * buttons DO (some gate Next on form validity, some own their submit) and only agree about + * how the row should sit. + * + * Below md the row stacks in `column-reverse`, which puts the primary action at the top and + * Close at the bottom. Two details are load-bearing: + * - `alignItems: stretch`, or a column would shrink every child to its content width. + * - the descendant selector rather than per-button `fullWidth`: the Submit button is + * wrapped in its own
    , so the form is the flex item and the button inside it is + * what needs the width. + */ +export const CippWizardActionsRow = (props) => { + const { sx, children } = props; + + return ( + + {children} + + ); +}; + +CippWizardActionsRow.propTypes = { + sx: PropTypes.object, + children: PropTypes.node, +}; diff --git a/frontend/src/components/CippWizard/CippWizardAutopilotImport.jsx b/frontend/src/components/CippWizard/CippWizardAutopilotImport.jsx index fe840fa8e8..33be2733ed 100644 --- a/frontend/src/components/CippWizard/CippWizardAutopilotImport.jsx +++ b/frontend/src/components/CippWizard/CippWizardAutopilotImport.jsx @@ -10,6 +10,8 @@ import { DialogActions, TextField, Alert, + Paper, + IconButton, } from '@mui/material' import { CippWizardStepButtons } from './CippWizardStepButtons' import { CippDataTable } from '../CippTable/CippDataTable' @@ -17,6 +19,7 @@ import { useWatch } from 'react-hook-form' import { Delete, FileDownload, Upload, Add } from '@mui/icons-material' import { useEffect, useState } from 'react' import React from 'react' +import { useIsMobileLayout } from '../../hooks/use-breakpoint' export const CippWizardAutopilotImport = (props) => { const { @@ -35,6 +38,7 @@ export const CippWizardAutopilotImport = (props) => { const [manualDialogOpen, setManualDialogOpen] = useState(false) const [manualInputs, setManualInputs] = useState([{}]) const inputRefs = React.useRef([]) + const isMobile = useIsMobileLayout() const [validationErrors, setValidationErrors] = useState([]) const handleRemoveItem = (row) => { @@ -404,28 +408,35 @@ export const CippWizardAutopilotImport = (props) => { ))} )} - {manualInputs.map((row, rowIndex) => ( - - {/* Row identifier */} + {manualInputs.map((row, rowIndex) => { + // Defined once and placed by either branch, so the two layouts cannot drift. + const fieldInputs = fields.map((field) => ( + + { + if (!inputRefs.current[rowIndex]) { + inputRefs.current[rowIndex] = {} + } + inputRefs.current[rowIndex][field.propertyName] = el + }} + label={field.friendlyName} + value={row[field.propertyName] || ''} + onChange={(e) => + handleManualInputChange(rowIndex, field.propertyName, e.target.value) + } + onKeyDown={(e) => + field.propertyName === 'productKey' && handleKeyPress(e, rowIndex) + } + fullWidth + size="small" + /> + + )) + + const rowNumber = ( { fontSize: '0.875rem', fontWeight: 600, flexShrink: 0, - ml: 1, + ml: isMobile ? 0 : 1, }} > {rowIndex + 1} - {fields.map((field) => ( - - { - if (!inputRefs.current[rowIndex]) { - inputRefs.current[rowIndex] = {} - } - inputRefs.current[rowIndex][field.propertyName] = el - }} - label={field.friendlyName} - value={row[field.propertyName] || ''} - onChange={(e) => - handleManualInputChange(rowIndex, field.propertyName, e.target.value) - } - onKeyDown={(e) => - field.propertyName === 'productKey' && handleKeyPress(e, rowIndex) - } - fullWidth - size="small" - /> - - ))} - - - ))} + {rowNumber} + {fieldInputs} + +
    + ) + })} { return ( - + @@ -266,7 +266,7 @@ export const CippWizardOffboarding = (props) => { - + diff --git a/frontend/src/components/CippWizard/CippWizardPage.jsx b/frontend/src/components/CippWizard/CippWizardPage.jsx index b1eb00f3fc..d2d38e35cc 100644 --- a/frontend/src/components/CippWizard/CippWizardPage.jsx +++ b/frontend/src/components/CippWizard/CippWizardPage.jsx @@ -8,9 +8,7 @@ import { DialogTitle, Divider, IconButton, - Stack, SvgIcon, - useMediaQuery, } from "@mui/material"; import { Close } from "@mui/icons-material"; import { CippWizard } from "./CippWizard"; @@ -18,6 +16,7 @@ import { useRouter } from "next/router"; import { ArrowLeftIcon } from "@mui/x-date-pickers"; import { CippHead } from "../CippComponents/CippHead"; import { CippWizardDialogContext } from "./CippWizardDialogContext"; +import { useIsMobileLayout } from "../../hooks/use-breakpoint"; import { useState, useCallback } from "react"; const CippWizardPage = (props) => { @@ -39,7 +38,7 @@ const CippWizardPage = (props) => { ...other } = props; - const mdDown = useMediaQuery((theme) => theme.breakpoints.down("md")); + const mdDown = useIsMobileLayout(); const [actionsEl, setActionsEl] = useState(null); const actionsRef = useCallback((el) => setActionsEl(el), []); @@ -76,7 +75,7 @@ const CippWizardPage = (props) => { - + @@ -84,7 +83,7 @@ const CippWizardPage = (props) => { - + ); } @@ -100,17 +99,14 @@ const CippWizardPage = (props) => { }} > - - - - - {wizardNode} - - - - + {/* Three nested Stacks used to sit here, each wrapping exactly one child. Stack + spacing only emits a margin on :not(:first-of-type), so all three were inert + at every width. */} + + {wizardNode} + diff --git a/frontend/src/components/CippWizard/CippWizardProgressHeader.jsx b/frontend/src/components/CippWizard/CippWizardProgressHeader.jsx new file mode 100644 index 0000000000..bdb14abaf4 --- /dev/null +++ b/frontend/src/components/CippWizard/CippWizardProgressHeader.jsx @@ -0,0 +1,45 @@ +import PropTypes from "prop-types"; +import { LinearProgress, Stack, Typography } from "@mui/material"; + +/** + * The wizard's step indicator below md. + * + * A horizontal MUI Stepper gives every step a 36px icon beside two lines of text; with the + * 3-7 steps these wizards have, and ~326px of usable width on a phone, the labels collapse + * into each other. This says the same thing in the space available: where you are, what + * this step is, and how much is left. + * + * Takes the same two props as WizardSteps so the swap needs no new plumbing. + */ +export const CippWizardProgressHeader = (props) => { + const { activeStep = 0, steps = [] } = props; + + const total = steps.length; + // Clamped because handleNext currently counts against the unfiltered step list, so + // activeStep can point past the end of a wizard whose steps are conditionally hidden. + const index = total > 0 ? Math.min(Math.max(activeStep, 0), total - 1) : 0; + const current = steps[index]; + const value = total > 0 ? ((index + 1) / total) * 100 : 0; + + return ( + + + {total > 0 ? `Step ${index + 1} of ${total}` : "No steps"} + + {current?.description ?? current?.title ?? ""} + {/* Carries the same error/loading states the step icons show on desktop, so the + GDAP-style "this step failed" signal survives the swap. */} + + + ); +}; + +CippWizardProgressHeader.propTypes = { + activeStep: PropTypes.number, + steps: PropTypes.array, +}; diff --git a/frontend/src/components/CippWizard/CippWizardStepButtons.jsx b/frontend/src/components/CippWizard/CippWizardStepButtons.jsx index 7a070d124e..55fb723d07 100644 --- a/frontend/src/components/CippWizard/CippWizardStepButtons.jsx +++ b/frontend/src/components/CippWizard/CippWizardStepButtons.jsx @@ -1,9 +1,10 @@ -import { Button, Stack } from "@mui/material"; +import { Button } from "@mui/material"; import { useFormState } from "react-hook-form"; import { createPortal } from "react-dom"; import { ApiPostCall } from "../../api/ApiCall"; import { CippApiResults } from "../CippComponents/CippApiResults"; import { useCippWizardDialog } from "./CippWizardDialogContext"; +import { CippWizardActionsRow } from "./CippWizardActionsRow"; export const CippWizardStepButtons = (props) => { const { @@ -47,20 +48,14 @@ export const CippWizardStepButtons = (props) => { }; const buttonStack = ( - + {dialogContext?.onClose && ( @@ -98,7 +93,7 @@ export const CippWizardStepButtons = (props) => { {dialogContext.completionButton.label} )} - + ); return ( diff --git a/frontend/src/components/CippWizard/CippWizardVacationConfirmation.jsx b/frontend/src/components/CippWizard/CippWizardVacationConfirmation.jsx index f011e7d0e2..2ed126c790 100644 --- a/frontend/src/components/CippWizard/CippWizardVacationConfirmation.jsx +++ b/frontend/src/components/CippWizard/CippWizardVacationConfirmation.jsx @@ -15,6 +15,7 @@ import { CippApiResults } from '../CippComponents/CippApiResults' import { ApiPostCall } from '../../api/ApiCall' import { useWatch } from 'react-hook-form' import Link from 'next/link' +import { CippWizardActionsRow } from "./CippWizardActionsRow"; export const CippWizardVacationConfirmation = (props) => { const { formControl, onPreviousStep, currentStep, lastStep } = props @@ -439,13 +440,7 @@ export const CippWizardVacationConfirmation = (props) => { {values.enableOOO && } {/* Navigation + Custom Submit */} - + {currentStep > 0 && ( )} - +
    ) } diff --git a/frontend/src/components/CippWizard/wizard-steps.js b/frontend/src/components/CippWizard/wizard-steps.js index 67b79a6541..bfdc473a7e 100644 --- a/frontend/src/components/CippWizard/wizard-steps.js +++ b/frontend/src/components/CippWizard/wizard-steps.js @@ -1,5 +1,7 @@ import PropTypes from "prop-types"; import CheckIcon from "@heroicons/react/24/outline/CheckIcon"; +import { useIsMobileLayout } from "../../hooks/use-breakpoint"; +import { CippWizardProgressHeader } from "./CippWizardProgressHeader"; import { Box, Step, @@ -137,6 +139,14 @@ const WizardStepIcon = (props) => { export const WizardSteps = (props) => { const { activeStep = 1, orientation = "vertical", steps = [] } = props; + const isMobile = useIsMobileLayout(); + + // Only the horizontal stepper is wizard navigation. The vertical one is a status list — + // GDAP onboarding feeds it server-side steps where each step's message and pass/fail + // state IS the content, so collapsing it to a progress bar would delete that. + if (isMobile && orientation === "horizontal") { + return ; + } return (
    @@ -145,8 +155,10 @@ export const WizardSteps = (props) => { activeStep={activeStep} connector={} > - {steps.map((step) => ( - + {/* Onboarding's steps carry only a description, so keying on title alone made + every key undefined and reconciliation index-driven by accident. */} + {steps.map((step, index) => ( + { // showBreadcrumb: the error routes opt out — there is no trail to a page that // doesn't exist or just crashed, and the bookmark button lives in there too. const { children, allTenantsSupport = true, showBreadcrumb = true } = props - const mdDown = useMediaQuery((theme) => theme.breakpoints.down('md')) + const lgDown = useMediaQuery((theme) => theme.breakpoints.down('lg')) const settings = useSettings() const mobileNav = useMobileNav() const [fetchingVisible, setFetchingVisible] = useState([]) @@ -308,7 +308,7 @@ export const Layout = (props) => { {hideSidebar === false && ( <> - {mdDown && ( + {lgDown && ( { open={mobileNav.open} /> )} - {!mdDown && } + {!lgDown && } )} { }} /> - + diff --git a/frontend/src/pages/cipp/logs/index.js b/frontend/src/pages/cipp/logs/index.js index 8369829c48..43fc649526 100644 --- a/frontend/src/pages/cipp/logs/index.js +++ b/frontend/src/pages/cipp/logs/index.js @@ -151,14 +151,22 @@ const Page = () => { tableFilter={ setExpanded(!expanded)}> }> - + - + Logbook Filters {filterEnabled ? ( - + ( {startDate || endDate ? ( <> @@ -179,11 +187,19 @@ const Page = () => { {username && <>User: {username}} {severity && (username || startDate || endDate) && ' | '} {severity && <>Severity: {severity.replace(/,/g, ', ')}}) - + ) : ( - + (Today: {new Date().toLocaleDateString()}) - + )} @@ -192,7 +208,7 @@ const Page = () => { {/* Date Filter */} - + Use the filters below to narrow down your logbook results. You can filter by date range, username, and severity levels. By default, the logbook shows the @@ -200,8 +216,10 @@ const Page = () => { {new Date().getTimezoneOffset() / -60} hours offset from UTC. - - + + {/* Two full-width pickers side by side leaves each about 150px on a + phone, which is narrower than the date they have to show. */} + { {/* Username Filter */} - + { {/* Severity Filter */} - + { {/* Action Buttons */} - + diff --git a/frontend/tests/components/CippComponents/CippAppPermissionBuilder.stories.jsx b/frontend/tests/components/CippComponents/CippAppPermissionBuilder.stories.jsx index ebbf226dd1..1fff3b9167 100644 --- a/frontend/tests/components/CippComponents/CippAppPermissionBuilder.stories.jsx +++ b/frontend/tests/components/CippComponents/CippAppPermissionBuilder.stories.jsx @@ -3,6 +3,7 @@ import { http, HttpResponse } from 'msw' import { within, expect, waitFor } from 'storybook/test' import { Box } from '@mui/material' import { useForm } from 'react-hook-form' +import { shrinkToPhoneViewport } from '../../viewport' import CippAppPermissionBuilder from '../../../src/components/CippComponents/CippAppPermissionBuilder' // The summary row carries a 36-character app id, so this is where the overflow shows up. @@ -27,19 +28,6 @@ const handlers = [ }), ] -// Only the vitest browser runner can resize the iframe, and importing its context at module -// scope throws in the Storybook app itself ("can be imported only inside the Browser Mode"), -// which breaks the story for anyone opening it. Ask for it lazily and carry on without it. -const shrinkToPhoneViewport = async () => { - try { - const { page } = await import('@vitest/browser/context') - await page.viewport(390, 844) - return true - } catch { - return false - } -} - const Harness = (props) => { const formControl = useForm({ mode: 'onChange', defaultValues: { servicePrincipal: null } }) return ( diff --git a/frontend/tests/components/CippComponents/CippPageActionsFab.test.jsx b/frontend/tests/components/CippComponents/CippPageActionsFab.test.jsx index c3696107ec..9019a21edf 100644 --- a/frontend/tests/components/CippComponents/CippPageActionsFab.test.jsx +++ b/frontend/tests/components/CippComponents/CippPageActionsFab.test.jsx @@ -2,7 +2,7 @@ import React from "react"; import { describe, it, expect, vi } from "vitest"; import { screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { Button, Drawer, ListItemButton, MenuItem, Typography } from "@mui/material"; +import { Button, Drawer, ListItemButton, MenuItem, Stack, Typography } from "@mui/material"; import { CippPageActionsFab } from "../../../src/components/CippComponents/CippPageActionsFab"; import { renderWithProviders } from "../../test-utils"; @@ -31,6 +31,58 @@ describe("CippPageActionsFab", () => { expect(screen.getByText("Actions")).toBeInTheDocument(); }); + // A cardButton laid out for a desktop CardHeader is as often a Stack as a Box. Matching + // only Box left the row intact while every button was stretched to 100%, so three import + // buttons ran off the side of the sheet. + it("restacks a row of buttons that arrived as a Stack", async () => { + const user = userEvent.setup(); + renderWithProviders( + + + + + + + + ); + await openSheet(user); + + const row = screen.getByText("Manual Import").closest(".MuiStack-root"); + const styles = window.getComputedStyle(row); + expect(styles.flexDirection).toBe("column"); + // Stack's spacing is a margin-left that would survive the flip and indent each row + const button = screen.getByText("Manual Import").closest("button"); + expect(window.getComputedStyle(button).marginLeft).toBe("0px"); + }); + + // The sheet's paper is grey; a text button's default primary accent reads as + // orange-on-grey and doesn't match the list rows underneath it. + it("neutralises text buttons without flattening the branded ones", async () => { + const user = userEvent.setup(); + renderWithProviders( + <> + + + + + + + + + ); + await openSheet(user); + + // Compared against the same button outside the sheet, so the assertion fails if the + // override goes away rather than merely describing MUI's defaults. + const inSheet = screen.getByText("Sheet content").closest("button"); + const outside = screen.getByText("Untouched").closest("button"); + expect(window.getComputedStyle(inSheet).color).not.toBe( + window.getComputedStyle(outside).color + ); + // a deliberate call to action keeps its branding + expect(screen.getByText("Add User").closest("button").className).toMatch(/containedPrimary/); + }); + it("uses custom title and aria-label", async () => { const user = userEvent.setup(); renderWithProviders( diff --git a/frontend/tests/components/CippTable/CippDiagnosticsFilter.test.jsx b/frontend/tests/components/CippTable/CippDiagnosticsFilter.test.jsx new file mode 100644 index 0000000000..b12e89ea84 --- /dev/null +++ b/frontend/tests/components/CippTable/CippDiagnosticsFilter.test.jsx @@ -0,0 +1,47 @@ +import React from 'react' +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { renderWithProviders } from '../../test-utils' +import CippDiagnosticsFilter from '../../../src/components/CippTable/CippDiagnosticsFilter' + +// jsdom has no width-based matchMedia, so the mobile branch is driven by mocking the hook +const layoutState = vi.hoisted(() => ({ isMobile: false })) +vi.mock('../../../src/hooks/use-breakpoint', () => ({ + useIsMobileLayout: () => layoutState.isMobile, + useIsTabletLayout: () => false, + useTableViewMode: () => 'table', +})) + +// Stable identities: a fresh object per call changes on every render and spins a loop. +const idleGet = vi.hoisted(() => ({ data: [], isFetching: false, isSuccess: true })) +const idlePost = vi.hoisted(() => ({ mutate: () => {}, isPending: false })) +const idlePaginated = vi.hoisted(() => ({ data: undefined, isFetching: false })) +vi.mock('../../../src/api/ApiCall', () => ({ + ApiGetCall: () => idleGet, + ApiPostCall: () => idlePost, + ApiGetCallWithPagination: () => idlePaginated, +})) + +beforeEach(() => { + layoutState.isMobile = false +}) + +describe('CippDiagnosticsFilter', () => { + // `rows` is a DOM attribute, so this cannot come from a responsive sx value. + // MUI renders a hidden shadow textarea beside the real one; only the real one carries + // the rows attribute this test is about. + const queryBox = (container) => + Array.from(container.querySelectorAll('textarea')).find((el) => el.hasAttribute('rows')) + + it('shortens the KQL box on a phone', () => { + layoutState.isMobile = true + const { container } = renderWithProviders( {}} />) + + expect(queryBox(container)).toHaveAttribute('rows', '6') + }) + + it('keeps twelve rows on desktop', () => { + const { container } = renderWithProviders( {}} />) + + expect(queryBox(container)).toHaveAttribute('rows', '12') + }) +}) diff --git a/frontend/tests/components/CippWizard/CippWizardAutopilotImport.stories.jsx b/frontend/tests/components/CippWizard/CippWizardAutopilotImport.stories.jsx new file mode 100644 index 0000000000..5ad28e86c8 --- /dev/null +++ b/frontend/tests/components/CippWizard/CippWizardAutopilotImport.stories.jsx @@ -0,0 +1,75 @@ +import React from 'react' +import { http, HttpResponse } from 'msw' +import { within, expect, userEvent } from 'storybook/test' +import { useForm } from 'react-hook-form' +import { CippWizardAutopilotImport } from '../../../src/components/CippWizard/CippWizardAutopilotImport' +import { shrinkToPhoneViewport } from '../../viewport' + +// The six the real autopilot wizard passes — the count is what made the row overflow. +const fields = [ + { friendlyName: 'Serialnumber', propertyName: 'SerialNumber' }, + { friendlyName: 'Manufacturer', propertyName: 'oemManufacturerName' }, + { friendlyName: 'Model', propertyName: 'modelName' }, + { friendlyName: 'Product ID', propertyName: 'productKey' }, + { friendlyName: 'Hardware hash', propertyName: 'hardwareHash' }, + { friendlyName: 'Group Tag', propertyName: 'groupTag' }, +] + +const handlers = [ + http.get('*/api/ListGraphRequest', () => HttpResponse.json({ Results: [] })), + http.get('*/api/ListGraphExplorerPresets', () => HttpResponse.json({ Results: [] })), +] + +const Harness = () => { + const formControl = useForm({ mode: 'onChange', defaultValues: { autopilotData: [] } }) + return ( + {}} + onPreviousStep={() => {}} + /> + ) +} + +export default { + title: 'Components/CippWizard/CippWizardAutopilotImport', + component: CippWizardAutopilotImport, + parameters: { msw: { handlers } }, +} + +// A 32px badge, six 150px fields and a 48px delete came to ~1010px in a row whose only +// concession was overflowX:auto — a nested sideways scroller inside a full-screen dialog. +// Whether it fits now is a claim only a real browser can settle. +export const PhoneWidth = { + render: () => , + play: async ({ canvasElement }) => { + const onAPhone = await shrinkToPhoneViewport() + const body = within(document.body) + + // At phone width the table is a card list, so the import buttons are behind the FAB + // rather than in a card header — the same route a user takes. + if (onAPhone) { + await userEvent.click(await body.findByRole('button', { name: 'Page actions' })) + } + await userEvent.click(await body.findByRole('button', { name: /manual import/i })) + const dialog = await body.findByRole('dialog') + if (!onAPhone) return + + const rows = dialog.querySelectorAll('[data-testid="manual-row"]') + expect(rows.length).toBeGreaterThan(0) + rows.forEach((row) => { + expect(row.scrollWidth).toBeLessThanOrEqual(row.clientWidth) + }) + + // and the fields are stacked, not side by side + const inputs = rows[0].querySelectorAll('input') + expect(inputs.length).toBe(fields.length) + expect(inputs[1].getBoundingClientRect().top).toBeGreaterThan( + inputs[0].getBoundingClientRect().bottom + ) + }, +} diff --git a/frontend/tests/components/CippWizard/CippWizardAutopilotImport.test.jsx b/frontend/tests/components/CippWizard/CippWizardAutopilotImport.test.jsx new file mode 100644 index 0000000000..8839c9858d --- /dev/null +++ b/frontend/tests/components/CippWizard/CippWizardAutopilotImport.test.jsx @@ -0,0 +1,83 @@ +import React from 'react' +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { screen, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { useForm } from 'react-hook-form' +import { renderWithProviders } from '../../test-utils' +import { CippWizardAutopilotImport } from '../../../src/components/CippWizard/CippWizardAutopilotImport' + +// jsdom has no width-based matchMedia, so the mobile branch is driven by mocking the hook +const layoutState = vi.hoisted(() => ({ isMobile: false })) +vi.mock('../../../src/hooks/use-breakpoint', () => ({ + useIsMobileLayout: () => layoutState.isMobile, + useIsTabletLayout: () => false, + useTableViewMode: () => 'table', +})) + +vi.mock('../../../src/api/ApiCall', () => ({ + ApiGetCall: vi.fn(() => ({ data: undefined, isFetching: false, isSuccess: false })), + ApiPostCall: vi.fn(() => ({ mutate: vi.fn(), isPending: false })), + ApiGetCallWithPagination: vi.fn(() => ({ data: undefined, isFetching: false })), +})) + +// The six the real autopilot wizard passes — the count is the point. +const fields = [ + { friendlyName: 'Serialnumber', propertyName: 'SerialNumber' }, + { friendlyName: 'Manufacturer', propertyName: 'oemManufacturerName' }, + { friendlyName: 'Model', propertyName: 'modelName' }, + { friendlyName: 'Product ID', propertyName: 'productKey' }, + { friendlyName: 'Hardware hash', propertyName: 'hardwareHash' }, + { friendlyName: 'Group Tag', propertyName: 'groupTag' }, +] + +const Harness = () => { + const formControl = useForm({ mode: 'onChange', defaultValues: { autopilotData: [] } }) + return ( + {}} + onPreviousStep={() => {}} + /> + ) +} + +const openManualImport = async () => { + const user = userEvent.setup() + renderWithProviders() + await user.click(await screen.findByRole('button', { name: /manual import/i })) + return within(await screen.findByRole('dialog')) +} + +beforeEach(() => { + layoutState.isMobile = false +}) + +describe('CippWizardAutopilotImport manual entry', () => { + // Six 150px fields plus an index badge and a delete button come to ~1010px, which on a + // phone was reachable only by scrolling a nested container inside a full-screen dialog. + it('gives each device its own card on a phone', async () => { + layoutState.isMobile = true + const dialog = await openManualImport() + + expect(dialog.getByText('Device 1')).toBeInTheDocument() + expect(dialog.getByRole('button', { name: 'Remove device 1' })).toBeInTheDocument() + // every field still there, just stacked + fields.forEach((field) => { + expect(dialog.getByLabelText(field.friendlyName)).toBeInTheDocument() + }) + }) + + it('keeps the single scrolling row on desktop', async () => { + const dialog = await openManualImport() + + expect(dialog.queryByText('Device 1')).not.toBeInTheDocument() + expect(dialog.queryByRole('button', { name: 'Remove device 1' })).not.toBeInTheDocument() + fields.forEach((field) => { + expect(dialog.getByLabelText(field.friendlyName)).toBeInTheDocument() + }) + }) +}) diff --git a/frontend/tests/components/CippWizard/CippWizardPage.stories.jsx b/frontend/tests/components/CippWizard/CippWizardPage.stories.jsx new file mode 100644 index 0000000000..8395f2891e --- /dev/null +++ b/frontend/tests/components/CippWizard/CippWizardPage.stories.jsx @@ -0,0 +1,80 @@ +import React from 'react' +import { within, expect, userEvent, waitFor } from 'storybook/test' +import { Typography } from '@mui/material' +import CippWizardPage from '../../../src/components/CippWizard/CippWizardPage' +import { CippWizardStepButtons } from '../../../src/components/CippWizard/CippWizardStepButtons' +import { shrinkToPhoneViewport } from '../../viewport' + +// A step that renders nothing but the shared button row — the layout under test is the +// wizard shell, not any particular step's form. +const Step = (props) => ( + <> + Step content + + +) + +// Five steps with the real wizards' label lengths; vacation mode is exactly this shape. +const steps = [ + { title: 'tenant', description: 'Tenant Selection', component: Step }, + { title: 'user', description: 'User Selection', component: Step }, + { title: 'actions', description: 'Vacation Actions', component: Step }, + { title: 'schedule', description: 'Schedule', component: Step }, + { title: 'review', description: 'Review & Submit', component: Step }, +] + +export default { + title: 'Components/CippWizard/CippWizardPage', + component: CippWizardPage, + parameters: { msw: { handlers: [] } }, +} + +const args = { postUrl: '/api/AddVacationMode', wizardTitle: 'Vacation Mode', steps } + +// jsdom has no layout engine, so overflow and stacking order are invisible to the unit +// tests. This is the only place they can be measured. +export const PhoneWidth = { + render: () => , + play: async ({ canvasElement }) => { + const onAPhone = await shrinkToPhoneViewport() + const canvas = within(canvasElement) + await canvas.findByText('Step content') + if (!onAPhone) return + + // the stepper is replaced, not merely restyled + expect(canvas.getByText('Step 1 of 5')).toBeInTheDocument() + expect(canvasElement.querySelector('.MuiStepper-root')).toBeNull() + + // nothing in the card reaches past the screen + const card = canvasElement.querySelector('.MuiCard-root') + expect(card.scrollWidth).toBeLessThanOrEqual(card.clientWidth) + + // advancing moves the bar + await userEvent.click(canvas.getByRole('button', { name: /next step/i })) + await waitFor(() => expect(canvas.getByText('Step 2 of 5')).toBeInTheDocument()) + + // column-reverse: the primary action sits above Back, and both span the card + const next = canvas.getByRole('button', { name: /next step/i }) + const back = canvas.getByRole('button', { name: /^back$/i }) + expect(back.getBoundingClientRect().top).toBeGreaterThan(next.getBoundingClientRect().top) + expect(next.getBoundingClientRect().width).toBeGreaterThan( + card.getBoundingClientRect().width * 0.7 + ) + }, +} + +// The other half of the contract: none of this reaches desktop. +export const DesktopWidth = { + render: () => , + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + await canvas.findByText('Step content') + + expect(canvasElement.querySelector('.MuiStepper-root')).not.toBeNull() + expect(canvas.queryByRole('progressbar')).toBeNull() + expect(canvas.queryByText('Step 1 of 5')).toBeNull() + + const card = canvasElement.querySelector('.MuiCard-root') + expect(card.scrollWidth).toBeLessThanOrEqual(card.clientWidth) + }, +} diff --git a/frontend/tests/components/CippWizard/wizard-steps.test.jsx b/frontend/tests/components/CippWizard/wizard-steps.test.jsx new file mode 100644 index 0000000000..19db18ab9e --- /dev/null +++ b/frontend/tests/components/CippWizard/wizard-steps.test.jsx @@ -0,0 +1,100 @@ +import React from "react"; +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { screen } from "@testing-library/react"; +import { renderWithProviders } from "../../test-utils"; +import { WizardSteps } from "../../../src/components/CippWizard/wizard-steps"; + +// jsdom has no width-based matchMedia, so the mobile branch is driven by mocking the hook +const layoutState = vi.hoisted(() => ({ isMobile: false })); +vi.mock("../../../src/hooks/use-breakpoint", () => ({ + useIsMobileLayout: () => layoutState.isMobile, + useIsTabletLayout: () => false, + useTableViewMode: () => "table", +})); + +const steps = [ + { title: "tenant", description: "Tenant Selection" }, + { title: "user", description: "User Selection" }, + { title: "actions", description: "Vacation Actions" }, + { title: "schedule", description: "Schedule" }, + { title: "review", description: "Review & Submit" }, +]; + +beforeEach(() => { + layoutState.isMobile = false; +}); + +describe("WizardSteps", () => { + it("keeps the full stepper on desktop", () => { + renderWithProviders(); + + expect(screen.getByText("Tenant Selection")).toBeInTheDocument(); + expect(screen.getByText("Review & Submit")).toBeInTheDocument(); + expect(screen.queryByRole("progressbar")).not.toBeInTheDocument(); + }); + + it("collapses to a progress header on a phone", () => { + layoutState.isMobile = true; + renderWithProviders(); + + expect(screen.getByText("Step 3 of 5")).toBeInTheDocument(); + expect(screen.getByText("Vacation Actions")).toBeInTheDocument(); + // the other four steps are not competing for the same 326px + expect(screen.queryByText("Tenant Selection")).not.toBeInTheDocument(); + expect(screen.queryByText("Review & Submit")).not.toBeInTheDocument(); + + const bar = screen.getByRole("progressbar"); + expect(bar).toHaveAttribute("aria-valuenow", "60"); + }); + + // The vertical variant is not wizard navigation: GDAP onboarding feeds it server-side + // steps where each step's message and pass/fail state IS the content. + it("leaves the vertical status list alone on a phone", () => { + layoutState.isMobile = true; + const onboarding = [ + { title: "invite", description: "Invite accepted", error: false }, + { title: "roles", description: "Role assignment failed: insufficient privileges", error: true }, + ]; + renderWithProviders(); + + expect(screen.getByText("Invite accepted")).toBeInTheDocument(); + expect( + screen.getByText("Role assignment failed: insufficient privileges") + ).toBeInTheDocument(); + expect(screen.queryByRole("progressbar")).not.toBeInTheDocument(); + }); + + it("carries the current step's error and loading states into the bar", () => { + layoutState.isMobile = true; + const failing = [{ description: "Deploying" }, { description: "Failed", error: true }]; + const { unmount } = renderWithProviders( + + ); + expect(screen.getByRole("progressbar").className).toMatch(/colorError/); + unmount(); + + const running = [{ description: "Deploying", loading: true }]; + renderWithProviders(); + expect(screen.getByRole("progressbar").className).toMatch(/indeterminate/); + }); + + it("survives an activeStep past the end of the visible steps", () => { + layoutState.isMobile = true; + // handleNext counts against the unfiltered step list, so this really happens on wizards + // whose steps are conditionally hidden. + renderWithProviders( + + ); + + expect(screen.getByText("Step 3 of 3")).toBeInTheDocument(); + expect(screen.getByRole("progressbar")).toHaveAttribute("aria-valuenow", "100"); + }); + + it("renders nothing broken for an empty step list", () => { + layoutState.isMobile = true; + renderWithProviders(); + + expect(screen.getByText("No steps")).toBeInTheDocument(); + expect(screen.getByRole("progressbar")).toHaveAttribute("aria-valuenow", "0"); + }); +}); diff --git a/frontend/tests/viewport.js b/frontend/tests/viewport.js new file mode 100644 index 0000000000..198d552e5a --- /dev/null +++ b/frontend/tests/viewport.js @@ -0,0 +1,22 @@ +/** + * Shrinks the story iframe to a phone viewport, for stories that measure layout. + * + * Two things this exists to get right: + * - The VIEWPORT has to shrink, not a wrapper element. MUI breakpoints are media queries, + * so a 390px-wide Box inside a desktop-width iframe still renders every `md` branch. + * - The import has to be lazy. At module scope `@vitest/browser/context` throws + * "can be imported only inside the Browser Mode", which breaks the story for anyone who + * opens it in the Storybook app rather than the test runner. + * + * Returns false when there is no runner driving the iframe, so a play function can skip + * measurements that would otherwise assert against a desktop width. + */ +export const shrinkToPhoneViewport = async (width = 390, height = 844) => { + try { + const { page } = await import("@vitest/browser/context"); + await page.viewport(width, height); + return true; + } catch { + return false; + } +}; From c0d8ae9d4c7f79d3b339341297cad05a42fb60d7 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Mon, 10 Aug 2026 00:26:04 -0400 Subject: [PATCH 013/226] feat(bottom-sheet): replace Drawer with SwipeableDrawer Swaps the static Drawer for SwipeableDrawer to enable native drag-to-dismiss on touch devices. Adds disableSwipeToOpen and a noop onOpen since sheets are always opened programmatically. Also adds a Storybook story (DragHandleDismisses) that exercises the full touch gesture in a real browser viewport. --- .../CippComponents/CippBottomSheet.jsx | 23 ++++--- .../CippBottomSheet.stories.jsx | 60 +++++++++++++++++++ 2 files changed, 74 insertions(+), 9 deletions(-) diff --git a/frontend/src/components/CippComponents/CippBottomSheet.jsx b/frontend/src/components/CippComponents/CippBottomSheet.jsx index 03b611514f..6255abb67e 100644 --- a/frontend/src/components/CippComponents/CippBottomSheet.jsx +++ b/frontend/src/components/CippComponents/CippBottomSheet.jsx @@ -1,20 +1,25 @@ -import { Box, Drawer, Typography } from "@mui/material"; +import { Box, SwipeableDrawer, Typography } from "@mui/material"; + +// SwipeableDrawer requires onOpen; these sheets are only ever opened programmatically. +const noop = () => {}; // Mobile bottom sheet — the house rule for the mobile surface is that anything rendered // as a Menu on desktop becomes one of these: predictable position, 44px+ rows, thumb reach. export const CippBottomSheet = (props) => { - const { open, onClose, title, children, footer, onExited, SlideProps, ...other } = props; + const { open, onClose, title, children, footer, onExited, SlideProps, ModalProps, ...other } = + props; return ( - theme.zIndex.modal + 1 }} PaperProps={{ sx: { @@ -71,6 +76,6 @@ export const CippBottomSheet = (props) => { {footer} )} - + ); }; diff --git a/frontend/tests/components/CippComponents/CippBottomSheet.stories.jsx b/frontend/tests/components/CippComponents/CippBottomSheet.stories.jsx index 1ad911a011..5d7b532661 100644 --- a/frontend/tests/components/CippComponents/CippBottomSheet.stories.jsx +++ b/frontend/tests/components/CippComponents/CippBottomSheet.stories.jsx @@ -10,6 +10,7 @@ import { Typography, } from '@mui/material' import { CippBottomSheet } from '../../../src/components/CippComponents/CippBottomSheet' +import { shrinkToPhoneViewport } from '../../viewport' // The mobile stand-in for a desktop Menu: every place the app opens a Menu on a pointer // device opens one of these below md instead. @@ -130,3 +131,62 @@ export const OverADialog = { }) }, } + +// The grab handle used to be decoration — a 36x4 bar that promised a gesture nothing +// implemented. Only a real browser can settle whether the drag works: jsdom has no layout, +// so the paper's height is 0 and the swipe distance the gesture is measured against is +// meaningless there. +export const DragHandleDismisses = { + render: () => ( + + {actionRows} + + ), + play: async ({ canvasElement }) => { + const onAPhone = await shrinkToPhoneViewport() + const canvas = within(canvasElement) + const body = within(document.body) + + await userEvent.click(canvas.getByRole('button', { name: 'Open sheet' })) + await body.findByText('Reset password') + if (!onAPhone) return + + const paper = document.querySelector('.MuiDrawer-paper') + const handle = paper.firstElementChild + const start = handle.getBoundingClientRect() + + // A real touch drag down the screen, starting on the handle. + const at = (clientY) => + new Touch({ + identifier: 1, + target: handle, + clientX: start.x + start.width / 2, + clientY, + }) + // Dispatched ON the handle and left to bubble: MUI reads event.target to decide the + // gesture started inside the paper, so firing at the document would bail immediately. + const fire = (type, clientY) => + handle.dispatchEvent( + new TouchEvent(type, { + bubbles: true, + cancelable: true, + touches: type === 'touchend' ? [] : [at(clientY)], + changedTouches: [at(clientY)], + }) + ) + + // MUI flags "maybe swiping" in React state on touchstart and ignores moves until that + // has been applied, so the gesture has to be spread across ticks like a real one. + const tick = () => new Promise((resolve) => setTimeout(resolve, 30)) + const from = start.y + start.height / 2 + fire('touchstart', from) + await tick() + for (const dy of [20, 60, 120, 200, 260]) { + fire('touchmove', from + dy) + await tick() + } + fire('touchend', from + 260) + + await waitFor(() => expect(body.queryByText('Reset password')).not.toBeInTheDocument()) + }, +} From abf6b0428f86e02d5da3371b9c64279aba3c3516 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Mon, 10 Aug 2026 12:11:30 -0400 Subject: [PATCH 014/226] fix: prevent duplicate action invocations and tooltip scroll bug - useActionsDispatch: noConfirm+customFunction actions now run-and-return before setting ready:true, preventing CippApiDialog from auto-submitting a second time on mount - CIPPTableToptoolbar: same dual-invocation fix for table row actions - HeaderedTabbedLayout: pass queryKeys through to useActionsDispatch so header actions correctly invalidate page queries; always render dialog (not gated on mdDown) to survive breakpoint changes mid-request - Theme: disable touch listeners on tooltips by default to prevent scroll-stuck tooltips; opt-out available via disableTouchListener={false} - CippJSONView: explicitly opt back in to touch tooltips for field descriptions - drift.js / history.js: hoist query keys so header actions invalidate the correct query - Add tests for useActionsDispatch and tooltip touch behaviour --- .../components/CippFormPages/CippJSONView.jsx | 3 + .../CippTable/CIPPTableToptoolbar.js | 19 +- frontend/src/hooks/use-actions-dispatch.jsx | 76 ++++++-- frontend/src/layouts/HeaderedTabbedLayout.jsx | 26 ++- frontend/src/pages/tenant/manage/drift.js | 5 +- frontend/src/pages/tenant/manage/history.js | 5 +- frontend/src/theme/base/create-components.js | 11 ++ .../tests/hooks/use-actions-dispatch.test.jsx | 172 ++++++++++++++++++ frontend/tests/theme/tooltip-touch.test.jsx | 56 ++++++ 9 files changed, 340 insertions(+), 33 deletions(-) create mode 100644 frontend/tests/hooks/use-actions-dispatch.test.jsx create mode 100644 frontend/tests/theme/tooltip-touch.test.jsx diff --git a/frontend/src/components/CippFormPages/CippJSONView.jsx b/frontend/src/components/CippFormPages/CippJSONView.jsx index e150dcadc6..1372d5ef91 100644 --- a/frontend/src/components/CippFormPages/CippJSONView.jsx +++ b/frontend/src/components/CippFormPages/CippJSONView.jsx @@ -385,6 +385,9 @@ function CippJsonView({ enterTouchDelay={0} leaveTouchDelay={8000} disableInteractive={false} + // Opts back out of the theme default: this one IS the description, and there is + // no other way to read it on a touch device. + disableTouchListener={false} > action.customFunction(row.original.original, action, {})) + // Deliberately no closeMenu() here — that matches the behaviour this branch had + // before; the only thing being fixed is the duplicate invocation. + return + } + setActionData({ data: selectedData, action: action, ready: true, }) - - if (action?.noConfirm && action.customFunction) { - eligibleRows.map((row) => action.customFunction(row.original.original, action, {})) - } else { - createDialog.handleOpen() - closeMenu() - } + createDialog.handleOpen() + closeMenu() } // Track if we've restored filters for this page to prevent infinite loops diff --git a/frontend/src/hooks/use-actions-dispatch.jsx b/frontend/src/hooks/use-actions-dispatch.jsx index b3b3697c38..bad8a7f879 100644 --- a/frontend/src/hooks/use-actions-dispatch.jsx +++ b/frontend/src/hooks/use-actions-dispatch.jsx @@ -1,6 +1,9 @@ -import { useState } from "react"; +import { useCallback, useState } from "react"; import { CippApiDialog } from "../components/CippComponents/CippApiDialog"; import { useDialog } from "./use-dialog"; +import { useSettings } from "./use-settings"; + +const IDLE = { data: {}, action: {}, ready: false }; /** * Shared dispatch for a page-level `actions` array. @@ -8,10 +11,15 @@ import { useDialog } from "./use-dialog"; * The desktop ActionsMenu and the mobile page-actions sheet present the same actions two * ways; keeping the confirm-vs-run decision and the dialog wiring here is what stops the * two presentations from drifting apart. + * + * Note the state is per-instance: two mounted consumers get two dispatchers, so this shares + * the decision, not an in-flight dialog. */ export const useActionsDispatch = ({ actions = [], data, queryKeys }) => { - const [actionData, setActionData] = useState({ data: {}, action: {}, ready: false }); + const [actionData, setActionData] = useState(IDLE); + const [customAction, setCustomAction] = useState(null); const createDialog = useDialog(); + const settings = useSettings(); // Nullsafety for data: it can be undefined (still loading) or null (no data) const isDisabled = (action) => { @@ -23,25 +31,61 @@ export const useActionsDispatch = ({ actions = [], data, queryKeys }) => { const visibleActions = actions?.filter((action) => !action.link || action.showInActionsMenu) ?? []; const dispatch = (action) => { - setActionData({ data, action, ready: true }); + // An AllTenants row carries its own tenant; posting under "AllTenants" would target the + // wrong one. Page-level data has no Tenant, so this is a no-op there. + if (settings?.currentTenant === "AllTenants" && data?.Tenant) { + settings.handleUpdate({ currentTenant: data.Tenant }); + } + + // Run-and-return paths must NOT set ready: doing so mounts CippApiDialog with + // api.noConfirm true, and its mount effect auto-submits into the very customFunction + // just called here — one tap, two invocations. if (action?.noConfirm && action.customFunction) { action.customFunction(data, action, {}); - } else { - createDialog.handleOpen(); + return; } + if (typeof action?.customComponent === "function") { + setCustomAction({ data, action }); + return; + } + + setActionData({ data, action, ready: true }); + createDialog.handleOpen(); }; - const dialog = actionData.ready ? ( - - ) : null; + // Dropped once the close transition finishes rather than on close, so the dialog keeps its + // exit animation. Leaving it mounted would hold a live mutation, an API subscription and a + // form instance for the life of the page — and HeaderedTabbedLayout never unmounts. + const handleExited = useCallback(() => setActionData(IDLE), []); + + const dialog = ( + <> + {actionData.ready && ( + + )} + {customAction?.action?.customComponent(customAction.data, { + drawerVisible: Boolean(customAction), + setDrawerVisible: (visible) => !visible && setCustomAction(null), + fromRowAction: false, + })} + + ); return { visibleActions, isDisabled, dispatch, dialog }; }; diff --git a/frontend/src/layouts/HeaderedTabbedLayout.jsx b/frontend/src/layouts/HeaderedTabbedLayout.jsx index 5e8558ec51..c016ca0652 100644 --- a/frontend/src/layouts/HeaderedTabbedLayout.jsx +++ b/frontend/src/layouts/HeaderedTabbedLayout.jsx @@ -5,7 +5,6 @@ import PropTypes from "prop-types"; import ArrowLeftIcon from "@heroicons/react/24/outline/ArrowLeftIcon"; import { Box, - Button, Container, Divider, Skeleton, @@ -30,6 +29,9 @@ export const HeaderedTabbedLayout = (props) => { subtitle, actions, actionsData, + // Without this the dispatch falls back to CippApiDialog's hardcoded title, so a header + // action mutates successfully and never invalidates the page query. + queryKeys, isFetching = false, backUrl, } = props; @@ -60,19 +62,24 @@ export const HeaderedTabbedLayout = (props) => { // Below md the tab row scrolls horizontally and still hides tabs off the right edge, so // navigation moves into the bottom sheet of whichever FAB owns the corner — and the // header's Actions menu goes with it, since it gets clipped at that width too. - const actionsDispatch = useActionsDispatch({ actions, data: actionsData }); + const actionsDispatch = useActionsDispatch({ actions, data: actionsData, queryKeys }); + // No isFetching term: the desktop menu's equivalent `disabled` prop is swallowed by + // ActionsMenu's unspread ...other, so including it here greyed out every action on mobile + // during a background refetch while desktop left them clickable. Actions operate on + // stale-but-present data quite happily; aligning down keeps the two surfaces identical + // without changing desktop. + const { visibleActions, isDisabled, dispatch } = actionsDispatch; const sheetActions = useMemo( () => mdDown - ? actionsDispatch.visibleActions.map((action) => ({ + ? visibleActions.map((action) => ({ label: action.label, icon: action.icon ? {action.icon} : null, - disabled: isFetching || actionsDispatch.isDisabled(action), - onClick: () => actionsDispatch.dispatch(action), + disabled: isDisabled(action), + onClick: () => dispatch(action), })) : [], - // eslint-disable-next-line react-hooks/exhaustive-deps - [mdDown, actions, actionsData, isFetching] + [mdDown, visibleActions, isDisabled, dispatch] ); const tabNavValue = useTabNavigationValue({ @@ -196,7 +203,10 @@ export const HeaderedTabbedLayout = (props) => { - {mdDown && actionsDispatch.dialog} + {/* Not gated on mdDown: crossing the breakpoint with a dialog open — a rotate, or a + tablet at 900px — would unmount it mid-request, taking CippApiResults with it. + The hook already renders nothing until an action is dispatched. */} + {actionsDispatch.dialog} {/* Only when no page FAB claimed the corner — otherwise the tabs ride in that sheet */} {mdDown && tabOptions.length > 0 && !tabNavValue.isClaimed && ( diff --git a/frontend/src/pages/tenant/manage/drift.js b/frontend/src/pages/tenant/manage/drift.js index f63a754bf0..74a19a7e12 100644 --- a/frontend/src/pages/tenant/manage/drift.js +++ b/frontend/src/pages/tenant/manage/drift.js @@ -75,12 +75,14 @@ const ManageDriftPage = () => { ] // API calls for drift data + // Hoisted so the header actions invalidate the same query this page reads. + const driftQueryKey = `TenantDrift-${tenantFilter}` const driftApi = ApiGetCall({ url: '/api/listTenantDrift', data: { tenantFilter: tenantFilter, }, - queryKey: `TenantDrift-${tenantFilter}`, + queryKey: driftQueryKey, }) // API call for available drift templates (for What If dropdown) @@ -1724,6 +1726,7 @@ const ManageDriftPage = () => { title={title} subtitle={subtitle} actions={actions} + queryKeys={driftQueryKey} actionsData={{}} isFetching={ driftApi.isFetching || diff --git a/frontend/src/pages/tenant/manage/history.js b/frontend/src/pages/tenant/manage/history.js index 129fcad963..0e040878bd 100644 --- a/frontend/src/pages/tenant/manage/history.js +++ b/frontend/src/pages/tenant/manage/history.js @@ -82,9 +82,11 @@ const Page = () => { const { startDate, endDate } = getDateRange(daysToLoad); + // Hoisted so the header actions invalidate the same query this page reads. + const logsQueryKey = `Listlogs-${tenant}-${startDate}-${endDate}`; const logsData = ApiGetCall({ url: `/api/Listlogs?tenant=${tenant}&StartDate=${startDate}&EndDate=${endDate}&Filter=true`, - queryKey: `Listlogs-${tenant}-${startDate}-${endDate}`, + queryKey: logsQueryKey, }); // Get severity icon and color @@ -149,6 +151,7 @@ const Page = () => { tabOptions={tabOptions} title={title} actions={actions} + queryKeys={logsQueryKey} actionsData={{}} isFetching={logsData.isLoading} > diff --git a/frontend/src/theme/base/create-components.js b/frontend/src/theme/base/create-components.js index 6d84b1f6db..ab5f2ab034 100644 --- a/frontend/src/theme/base/create-components.js +++ b/frontend/src/theme/base/create-components.js @@ -502,6 +502,17 @@ export const createComponents = () => { }, }, }, + MuiTooltip: { + defaultProps: { + // MUI's Tooltip attaches no touchmove and no scroll listener, so a press held + // through a scroll opens the tooltip after 700ms and nothing is scheduled to close + // it until the finger lifts — it rides the page as you drag. A tooltip is a hover + // affordance and touch has no hover, so the long-press variant is not worth the + // defect. Sites that genuinely want one opt back in with disableTouchListener={false} + // (CippJSONView's field descriptions are the only one). + disableTouchListener: true, + }, + }, MuiTextField: { defaultProps: { variant: "filled", diff --git a/frontend/tests/hooks/use-actions-dispatch.test.jsx b/frontend/tests/hooks/use-actions-dispatch.test.jsx new file mode 100644 index 0000000000..41226846eb --- /dev/null +++ b/frontend/tests/hooks/use-actions-dispatch.test.jsx @@ -0,0 +1,172 @@ +import React from "react"; +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { renderWithProviders } from "../test-utils"; +import { useActionsDispatch } from "../../src/hooks/use-actions-dispatch"; +import { CippApiDialog } from "../../src/components/CippComponents/CippApiDialog"; + +// Stable identities: a fresh object per call changes on every render and spins a loop. +// CippApiDialog calls reset() on open, so the post result needs the full shape. +const idlePost = vi.hoisted(() => ({ + mutate: vi.fn(), + reset: vi.fn(), + isPending: false, + isSuccess: false, + isError: false, + data: undefined, + error: null, +})); +const idleGet = vi.hoisted(() => ({ + data: undefined, + isFetching: false, + isLoading: false, + isSuccess: false, + isError: false, + refetch: vi.fn(), +})); +const idlePaginated = vi.hoisted(() => ({ + data: undefined, + isFetching: false, + isSuccess: false, + isError: false, + fetchNextPage: vi.fn(), + refetch: vi.fn(), +})); +const postOptions = vi.hoisted(() => []); +vi.mock("../../src/api/ApiCall", () => ({ + ApiPostCall: (options) => { + postOptions.push(options); + return idlePost; + }, + ApiGetCall: () => idleGet, + ApiGetCallWithPagination: () => idlePaginated, +})); + +// `dialog` is a fragment holding whichever surface the action needs, so reach past it. +const dialogPropsOf = (dialog) => + React.Children.toArray(dialog?.props?.children).find((child) => child?.type === CippApiDialog) + ?.props; + +const Harness = ({ actions, data = { id: "1" }, queryKeys, onDialogProps }) => { + const { visibleActions, dispatch, dialog } = useActionsDispatch({ actions, data, queryKeys }); + onDialogProps?.(dialogPropsOf(dialog)); + return ( + <> + {visibleActions.map((action) => ( + + ))} + {dialog} + + ); +}; + +beforeEach(() => { + idlePost.mutate.mockClear(); + postOptions.length = 0; +}); + +describe("useActionsDispatch", () => { + // The hook set ready:true before branching, which mounted CippApiDialog with + // api.noConfirm true; the dialog's mount effect then auto-submitted into the same + // customFunction the hook had just called directly. + it("runs a noConfirm customFunction exactly once per tap", async () => { + const user = userEvent.setup(); + const customFunction = vi.fn(); + renderWithProviders( + + ); + + await user.click(screen.getByRole("button", { name: "Refresh Data" })); + + await waitFor(() => expect(customFunction).toHaveBeenCalledTimes(1)); + // and it stays at one — the auto-submit effect must not fire on a later commit + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(customFunction).toHaveBeenCalledTimes(1); + }); + + // The dialog instance was reused and its auto-submit effect keys on + // [api.noConfirm, api.link], so a repeat of the same action left the deps unchanged and + // silently did nothing. + it("runs again when the same action is dispatched twice", async () => { + const user = userEvent.setup(); + const customFunction = vi.fn(); + renderWithProviders( + + ); + + await user.click(screen.getByRole("button", { name: "Refresh Data" })); + await waitFor(() => expect(customFunction).toHaveBeenCalledTimes(1)); + await user.click(screen.getByRole("button", { name: "Refresh Data" })); + + await waitFor(() => expect(customFunction).toHaveBeenCalledTimes(2)); + }); + + it("passes the caller's queryKeys through to the dialog", async () => { + const user = userEvent.setup(); + renderWithProviders( + + ); + + await user.click(screen.getByRole("button", { name: "Edit" })); + + // The dialog builds its mutation from relatedQueryKeys; without it the invalidation + // falls back to the hardcoded "Confirmation" title and the page never refreshes. + await waitFor(() => { + expect(postOptions.at(-1)?.relatedQueryKeys).toBe("Tenant History"); + }); + }); + + // The action was spread last, so any key it happened to carry silently beat the explicit + // prop — and every unknown key was forwarded onto the DOM by CippApiDialog. + it("does not let the action object override explicit dialog props", async () => { + const user = userEvent.setup(); + const props = vi.fn(); + renderWithProviders( + + ); + + await user.click(screen.getByRole("button", { name: "Edit" })); + + await waitFor(() => { + const last = props.mock.calls.at(-1)?.[0]; + expect(last?.row).toEqual({ id: "42" }); + }); + }); + + it("drops the dialog again once it closes", async () => { + const user = userEvent.setup(); + const props = vi.fn(); + renderWithProviders( + + ); + + await user.click(screen.getByRole("button", { name: "Edit" })); + await waitFor(() => expect(props.mock.calls.at(-1)?.[0]).toBeTruthy()); + + await user.keyboard("{Escape}"); + + // Left mounted, it holds a live mutation, an API subscription and a form instance for + // as long as the page lives — and on HeaderedTabbedLayout the page never unmounts. + await waitFor(() => expect(props.mock.calls.at(-1)?.[0]).toBeUndefined()); + }); + + it("hands a customComponent action to that component instead of a confirm dialog", async () => { + const user = userEvent.setup(); + const customComponent = vi.fn(() =>
    custom surface
    ); + renderWithProviders(); + + await user.click(screen.getByRole("button", { name: "Open" })); + + expect(await screen.findByTestId("custom")).toBeInTheDocument(); + }); +}); diff --git a/frontend/tests/theme/tooltip-touch.test.jsx b/frontend/tests/theme/tooltip-touch.test.jsx new file mode 100644 index 0000000000..d7b306af56 --- /dev/null +++ b/frontend/tests/theme/tooltip-touch.test.jsx @@ -0,0 +1,56 @@ +import React from "react"; +import { describe, it, expect } from "vitest"; +import { screen, fireEvent, waitFor } from "@testing-library/react"; +import { Tooltip, Button } from "@mui/material"; +import { createTheme } from "../../src/theme"; +import { renderWithTheme } from "../test-utils"; + +// MUI's Tooltip attaches no touchmove and no scroll listener: handleTouchStart arms a 700ms +// timer that opens the tooltip, and only handleTouchEnd schedules the close. A press held +// through a scroll therefore opens one and nothing closes it while the finger is down. +describe("tooltips on touch", () => { + it("is disabled by default across the app", () => { + const theme = createTheme({ colorPreset: "orange", contrast: "high", paletteMode: "light" }); + expect(theme.components.MuiTooltip.defaultProps.disableTouchListener).toBe(true); + }); + + // Real timers: MUI arms enterDelay inside the enterTouchDelay callback, and the nested + // pair does not advance reliably under fake ones — a faked version of this test passed + // with the fix removed, which is worse than no test. + it("does not open from a long press", async () => { + renderWithTheme( + + + + ); + + fireEvent.touchStart(screen.getByRole("button")); + await new Promise((resolve) => setTimeout(resolve, 400)); + + expect(screen.queryByRole("tooltip")).not.toBeInTheDocument(); + }); + + it("still opens on hover, where a tooltip belongs", async () => { + renderWithTheme( + + + + ); + + fireEvent.mouseOver(screen.getByRole("button")); + + expect(await screen.findByRole("tooltip")).toHaveTextContent("Users in this tenant"); + }); + + it("lets a site opt back in", async () => { + renderWithTheme( + + + + ); + + fireEvent.touchStart(screen.getByRole("button")); + + await waitFor(() => expect(screen.getByRole("tooltip")).toBeInTheDocument()); + }); +}); From 40966b26a15e1f9fa689d20a5b7fd51370230ccc Mon Sep 17 00:00:00 2001 From: John Duprey Date: Mon, 10 Aug 2026 14:43:42 -0400 Subject: [PATCH 015/226] feat(mobile): split tab navigation into CippTabPicker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Navigation on mobile tabbed layouts previously lived inside the page-actions FAB. This meant destinations disappeared when the card list entered select mode (its bulk bar claimed the corner, hiding the FAB entirely). Replace that approach with CippTabPicker: a ButtonBase in the content flow that opens tabs as a bottom sheet. Two variants — chip (beside a heading) and heading (the heading is the trigger) — cover all existing call sites. The FAB slot and the title-row slot are now tracked independently in TabNavigationContext (ACTION_SLOT / TAB_SLOT), replacing the single isClaimed flag. CippDataTable turns its heading into the picker when inside a tabbed layout, so the picker is always visible regardless of FAB state. --- .../CippComponents/CippPageActionsFab.jsx | 36 ++-- .../CippComponents/CippTabPicker.jsx | 124 +++++++++++++ .../src/components/CippTable/CippDataTable.js | 23 ++- .../CippTable/CippMobileCardList.jsx | 8 +- frontend/src/layouts/HeaderedTabbedLayout.jsx | 34 ++-- frontend/src/layouts/TabbedLayout.jsx | 15 +- .../src/layouts/tab-navigation-context.js | 62 ++++--- .../CippPageActionsFab.stories.jsx | 50 +++--- .../CippComponents/CippTabPicker.stories.jsx | 144 +++++++++++++++ .../layouts/HeaderedTabbedLayout.test.jsx | 48 ++++- frontend/tests/layouts/TabbedLayout.test.jsx | 168 ++++++++++++------ 11 files changed, 557 insertions(+), 155 deletions(-) create mode 100644 frontend/src/components/CippComponents/CippTabPicker.jsx create mode 100644 frontend/tests/components/CippComponents/CippTabPicker.stories.jsx diff --git a/frontend/src/components/CippComponents/CippPageActionsFab.jsx b/frontend/src/components/CippComponents/CippPageActionsFab.jsx index a4d9550ed3..22ac5cb3d0 100644 --- a/frontend/src/components/CippComponents/CippPageActionsFab.jsx +++ b/frontend/src/components/CippComponents/CippPageActionsFab.jsx @@ -12,9 +12,9 @@ import { } from '@mui/material' import { MoreHoriz } from '@mui/icons-material' import { CippBottomSheet } from './CippBottomSheet' -import { CippTabNavigationSection } from './CippTabNavigationSection' import { - useTabFabClaim, + ACTION_SLOT, + useSlotClaim, useTabNavigation, } from '../../layouts/tab-navigation-context' @@ -24,40 +24,37 @@ import { // CardHeader are restacked vertically at full width; purpose-built sheet content (list // rows) should pass restackButtons={false}. // -// Under a tabbed layout the sheet also carries that layout's tabs, and claims the corner -// so the layout doesn't add a second FAB of its own. +// Actions only — a tabbed layout's destinations live in CippTabPicker, in the content +// flow. This FAB does claim the corner so a headered layout hands its page actions here +// rather than adding a second FAB of its own. export const CippPageActionsFab = (props) => { const { title, // One glyph for every page-actions FAB. A "+" only ever told the truth on pages whose - // sheet creates things — on a report page the single action is a sync, and under a - // tabbed layout the sheet also holds views. MoreVert is the row kebab, so the FAB - // takes the horizontal variant. + // sheet creates things — on a report page the single action is a sync. MoreVert is the + // row kebab, so the FAB takes the horizontal variant. icon = , ariaLabel = 'Page actions', restackButtons = true, sheetProps, // The tabbed layout's own fallback FAB must not claim the corner it is filling — - // claiming would flip isClaimed, unmount it, release, and loop. - claimTabCorner = true, + // claiming would flip isActionSlotClaimed, unmount it, release, and loop. + claimActionCorner = true, children, } = props const [open, setOpen] = useState(false) const sheet = useSheetHandoff(() => setOpen(false)) const tabNav = useTabNavigation() - const showTabs = Boolean(tabNav?.enabled && tabNav.tabs?.length) // A tabbed layout may own page-level actions too (HeaderedTabbedLayout's ActionsMenu); // they belong in this sheet rather than in a cramped header menu. const layoutActions = (tabNav?.enabled && tabNav.actions) || [] - useTabFabClaim(claimTabCorner) - - const hasOwnActions = Boolean(children) || layoutActions.length > 0 + useSlotClaim(ACTION_SLOT, claimActionCorner) // With both kinds of content the sections label themselves, so a sheet title would only // repeat one of them; a single-purpose sheet takes the heading instead of a subheader. - const sectioned = hasOwnActions && showTabs - const resolvedTitle = title ?? (sectioned ? undefined : showTabs ? 'Views' : 'Actions') + const sectioned = Boolean(children) && layoutActions.length > 0 + const resolvedTitle = title ?? (sectioned ? undefined : 'Actions') return ( <> @@ -126,15 +123,6 @@ export const CippPageActionsFab = (props) => { > {children}
    - {showTabs && ( - <> - {children ? : null} - setOpen(false)} - /> - - )} {layoutActions.length > 0 && ( <> {sectioned ? : null} diff --git a/frontend/src/components/CippComponents/CippTabPicker.jsx b/frontend/src/components/CippComponents/CippTabPicker.jsx new file mode 100644 index 0000000000..6bb65be66a --- /dev/null +++ b/frontend/src/components/CippComponents/CippTabPicker.jsx @@ -0,0 +1,124 @@ +import { useState } from 'react' +import { Box, ButtonBase, Typography } from '@mui/material' +import { visuallyHidden } from '@mui/utils' +import { KeyboardArrowDown } from '@mui/icons-material' +import { CippBottomSheet } from './CippBottomSheet' +import { CippTabNavigationSection } from './CippTabNavigationSection' +import { getIconByName } from '../../utils/icon-registry' +import { + TAB_SLOT, + useSlotClaim, + useTabNavigation, +} from '../../layouts/tab-navigation-context' + +/** + * The mobile replacement for a tabbed layout's tab bar: a collapsed trigger that opens the tab + * list as a bottom sheet. + * + * Navigation deliberately lives in the content flow rather than in the page FAB — a FAB is for a + * screen's primary action, and putting destinations there also made them unreachable whenever + * something else owned the corner (a card list in select mode draws no FAB at all). + * + * Two presentations, one behaviour: + * chip a control beside a heading — HeaderedTabbedLayout's title row, or the row + * TabbedLayout supplies when nothing claimed the slot. + * heading the heading *is* the trigger. Used where the page already draws a title that says + * the same thing as the current tab, so a separate chip would print it twice. + */ +/** + * Whether a picker would render anything here — for hosts that need to choose between the + * picker and their own heading before mounting either. `CippTabPicker` applies the same test + * internally, so rendering it unconditionally is always safe. + */ +export const useTabPickerAvailable = () => { + const tabNav = useTabNavigation() + return Boolean(tabNav?.enabled) && (tabNav?.tabs?.length ?? 0) > 1 +} + +export const CippTabPicker = (props) => { + const { + label, + variant = 'chip', + // The claimant renders the picker; a layout's fallback must not claim the slot it is + // filling, or claiming would flip isTabSlotClaimed, unmount it, release, and loop. + claimSlot = true, + sx, + } = props + + const [open, setOpen] = useState(false) + const tabNav = useTabNavigation() + const tabs = tabNav?.tabs ?? [] + // One destination is not navigation. Two pages (View Group, View Device) have a single tab and + // used to get a FAB whose sheet offered the page you were already on. + const active = useTabPickerAvailable() + useSlotClaim(TAB_SLOT, active && claimSlot) + + if (!active) return null + + const current = tabs.find((tab) => tab.path === tabNav.currentPath) + const text = label ?? current?.label ?? 'Views' + const isHeading = variant === 'heading' + + return ( + <> + setOpen(true)} + aria-haspopup="dialog" + sx={{ + minWidth: 0, + display: 'flex', + alignItems: 'center', + textAlign: 'left', + ...(isHeading + ? { gap: 0.5, borderRadius: 1 } + : { + flexShrink: 0, + // Long labels ("Policies and Settings Deployed" is 30 characters) must not push + // the heading beside them off the row. + maxWidth: '50%', + height: 40, + gap: 0.75, + px: 1.25, + borderRadius: 1, + bgcolor: 'action.hover', + }), + ...sx, + }} + > + {!isHeading && + getIconByName(current?.icon, { + fontSize: 'small', + sx: { flexShrink: 0, color: 'text.secondary' }, + })} + + {text} + + {/* Not an aria-label: overriding the name would leave the visible text out of it, and + a voice-control user saying "Relationships" could no longer activate this. The + hidden suffix extends the name instead of replacing it. */} + + {current && text !== current.label + ? `switch view, currently ${current.label}` + : 'switch view'} + + {/* Pinned to the control's edge so it reads as the affordance rather than punctuation + trailing whatever the current view happens to be called. */} + + + setOpen(false)} title="Views"> + setOpen(false)} /> + + + ) +} diff --git a/frontend/src/components/CippTable/CippDataTable.js b/frontend/src/components/CippTable/CippDataTable.js index 821eaabff3..693ef0bd23 100644 --- a/frontend/src/components/CippTable/CippDataTable.js +++ b/frontend/src/components/CippTable/CippDataTable.js @@ -33,6 +33,10 @@ import { isEqual } from 'lodash' // Import lodash for deep comparison import { useLicenseBackfill } from '../../hooks/use-license-backfill' import { useTableViewMode } from '../../hooks/use-breakpoint' import { CippMobileCardList } from './CippMobileCardList' +import { + CippTabPicker, + useTabPickerAvailable, +} from '../CippComponents/CippTabPicker' // Resolve dot-delimited property paths against arbitrary data objects. const getNestedValue = (source, path) => { @@ -1105,6 +1109,15 @@ export const CippDataTable = (props) => { const selectModeActive = hasOnChange ? true : mobileSelectMode + // Under a tabbed layout the card view's heading and the current tab are usually the same + // word, so the heading becomes the tab picker rather than sitting under a second copy of + // itself. Claiming the slot is what tells the layout not to supply a row of its own. + // A dialog's table is not the page, so it never claims. Unlike the FAB this replaced, the + // heading is drawn in select mode too — that is where navigation used to disappear. + const tabPickerAvailable = useTabPickerAvailable() + const headingIsTabPicker = + isCardView && !hideTitle && !isInDialog && tabPickerAvailable + return ( <> {isCardView ? ( @@ -1121,9 +1134,13 @@ export const CippDataTable = (props) => { minWidth: 0, }} > - - {title} - + {headingIsTabPicker ? ( + + ) : ( + + {title} + + )} {Array.isArray(usedData) && !showSkeletons && ( { const rowSheet = useSheetHandoff(() => setActionSheetRow(null)); // Select mode's bulk bar owns the bottom of the screen, so the page FAB steps aside. Hold - // the claim through it anyway: a tabbed layout would otherwise drop its own FAB in behind - // the bulk bar. Tabs come back with the FAB when selection ends. - useTabFabClaim(fixedChrome && selectMode); + // the corner through it anyway: a headered layout would otherwise drop its actions FAB in + // behind the bulk bar. Navigation is unaffected — the tab picker is in the title row. + useSlotClaim(ACTION_SLOT, fixedChrome && selectMode); // A desktop tablePageSize above the cap would render that many unvirtualized cards. useEffect(() => { diff --git a/frontend/src/layouts/HeaderedTabbedLayout.jsx b/frontend/src/layouts/HeaderedTabbedLayout.jsx index c016ca0652..270053bd9a 100644 --- a/frontend/src/layouts/HeaderedTabbedLayout.jsx +++ b/frontend/src/layouts/HeaderedTabbedLayout.jsx @@ -15,11 +15,12 @@ import { Typography, } from "@mui/material"; import { ActionsMenu } from "../components/actions-menu"; -import { useMediaQuery } from "@mui/material"; import { getIconByName } from "../utils/icon-registry"; +import { useIsMobileLayout } from "../hooks/use-breakpoint"; import { useActionsDispatch } from "../hooks/use-actions-dispatch"; import { TabNavigationContext, useTabNavigationValue } from "./tab-navigation-context"; import { CippPageActionsFab } from "../components/CippComponents/CippPageActionsFab"; +import { CippTabPicker } from "../components/CippComponents/CippTabPicker"; export const HeaderedTabbedLayout = (props) => { const { @@ -36,7 +37,9 @@ export const HeaderedTabbedLayout = (props) => { backUrl, } = props; - const mdDown = useMediaQuery((theme) => theme.breakpoints.down("md")); + // The shared hook rather than an inline useMediaQuery: same threshold, but only this one is + // mockable, and jsdom has no width-based matchMedia to drive the mobile branch with. + const mdDown = useIsMobileLayout(); const router = useRouter(); const pathname = usePathname(); const queryParams = router.query; @@ -60,8 +63,8 @@ export const HeaderedTabbedLayout = (props) => { const currentTab = tabOptions.find((option) => option.path === pathname); // Below md the tab row scrolls horizontally and still hides tabs off the right edge, so - // navigation moves into the bottom sheet of whichever FAB owns the corner — and the - // header's Actions menu goes with it, since it gets clipped at that width too. + // navigation collapses to a picker in the title row — the one part of that row that is + // empty at this width, since the Actions menu gets clipped here and moves to the FAB. const actionsDispatch = useActionsDispatch({ actions, data: actionsData, queryKeys }); // No isFetching term: the desktop menu's equivalent `disabled` prop is swallowed by // ActionsMenu's unspread ...other, so including it here greyed out every action on mobile @@ -111,7 +114,10 @@ export const HeaderedTabbedLayout = (props) => { justifyContent="space-between" spacing={1} > - + {/* minWidth: 0 so a long tenant/entity name wraps in the space the picker + leaves rather than pushing it off the right edge of the row. Scoped to + the picker's own breakpoint — above md this row is unchanged. */} + { ) )} - {!mdDown && actions && actions.length > 0 && ( - + {/* The right half of this row is free below md, which is where the tab picker + goes. Above md it belongs to the Actions menu, as it always did. */} + {mdDown ? ( + + ) : ( + actions && + actions.length > 0 && ( + + ) )} {!mdDown && ( @@ -207,9 +220,10 @@ export const HeaderedTabbedLayout = (props) => { tablet at 900px — would unmount it mid-request, taking CippApiResults with it. The hook already renders nothing until an action is dispatched. */} {actionsDispatch.dialog} - {/* Only when no page FAB claimed the corner — otherwise the tabs ride in that sheet */} - {mdDown && tabOptions.length > 0 && !tabNavValue.isClaimed && ( - + {/* Actions only, and only when no page FAB claimed the corner — otherwise they ride in + that sheet. Tabs are in the title row and never come down here. */} + {mdDown && sheetActions.length > 0 && !tabNavValue.isActionSlotClaimed && ( + )} ); diff --git a/frontend/src/layouts/TabbedLayout.jsx b/frontend/src/layouts/TabbedLayout.jsx index 5fd98d06e3..ea8373d67f 100644 --- a/frontend/src/layouts/TabbedLayout.jsx +++ b/frontend/src/layouts/TabbedLayout.jsx @@ -7,7 +7,7 @@ import { getIconByName } from '../utils/icon-registry' import { useSettings } from '../hooks/use-settings' import { useIsMobileLayout } from '../hooks/use-breakpoint' import { TabNavigationContext, useTabNavigationValue } from './tab-navigation-context' -import { CippPageActionsFab } from '../components/CippComponents/CippPageActionsFab' +import { CippTabPicker } from '../components/CippComponents/CippTabPicker' export const TabbedLayout = (props) => { const { tabOptions, children } = props @@ -56,7 +56,9 @@ export const TabbedLayout = (props) => { const currentTab = visibleTabs.find((option) => option.path === pathname) // Below md the tab row scrolls horizontally and still hides tabs off the right edge, so - // navigation moves into the bottom sheet of whichever FAB owns the corner. + // navigation collapses to a picker. Where the page already draws a heading — a card + // list's "Relationships · 1,284 results" — that heading becomes the picker instead; + // this layout supplies a row of its own only when nothing claimed the slot. const isMobile = useIsMobileLayout() const tabNavValue = useTabNavigationValue({ tabs: visibleTabs, @@ -75,6 +77,11 @@ export const TabbedLayout = (props) => { }} > + {isMobile && !tabNavValue.isTabSlotClaimed && ( + + + + )} {!isMobile && ( { {children} - {/* Only when no page FAB claimed the corner — otherwise the tabs ride in that sheet */} - {isMobile && visibleTabs.length > 0 && !tabNavValue.isClaimed && ( - - )} ) } diff --git a/frontend/src/layouts/tab-navigation-context.js b/frontend/src/layouts/tab-navigation-context.js index 9ea5d1d61b..3efb174ee0 100644 --- a/frontend/src/layouts/tab-navigation-context.js +++ b/frontend/src/layouts/tab-navigation-context.js @@ -9,24 +9,37 @@ import { } from 'react' /** - * Lets a page's mobile FAB adopt the tab bar owned by a tabbed layout. + * Lets a tabbed layout hand its tab list — and, on the headered variant, its page actions — to + * whichever surface is better placed to render them on mobile. * - * Below md the scrollable tab row costs a band of vertical space and still hides tabs off - * the right edge, so navigation moves into the bottom-right sheet instead. The corner only - * fits one FAB, and about half the tabbed pages already grow one from a table's - * `cardButton` — hence the claim registry: whichever FAB is already on screen renders the - * tab list, and the layout supplies its own only when nothing else has claimed the corner. + * Below md the scrollable tab row costs a band of vertical space and still hides tabs off the + * right edge, so navigation collapses to a picker instead. Two different bits of screen are + * contested, and they are contested independently: + * + * TAB_SLOT the mobile title row. A page that already draws a heading (a card list's + * "Users · 1,284 results") turns that heading into the picker rather than + * stacking a second copy of the same word above it. + * ACTION_SLOT the bottom-right FAB corner, which fits exactly one FAB. About a quarter of + * tabbed pages already grow one from a table's `cardButton`, so a headered + * layout hands its actions to that FAB instead of adding another. + * + * A layout fills either slot itself only when nothing else has claimed it. */ export const TabNavigationContext = createContext(null) +export const TAB_SLOT = 'tabs' +export const ACTION_SLOT = 'actions' + export const useTabNavigation = () => useContext(TabNavigationContext) +const EMPTY_CLAIMS = {} + /** - * Claims the bottom-right corner while `active`. A claimant takes responsibility for - * making the tabs reachable — or for deliberately withholding them, as the card list does - * while its select-mode bulk bar owns the bottom of the screen. + * Claims `slot` while `active`. A claimant takes responsibility for making that slot's content + * reachable — the card list, for instance, holds the action corner through select mode, when its + * bulk bar owns the bottom of the screen and no FAB may be drawn there. */ -export const useTabFabClaim = (active) => { +export const useSlotClaim = (slot, active) => { const context = useContext(TabNavigationContext) const claimId = useId() const claim = context?.claim @@ -34,9 +47,9 @@ export const useTabFabClaim = (active) => { useEffect(() => { if (!active || !claim || !release) return undefined - claim(claimId) - return () => release(claimId) - }, [active, claim, release, claimId]) + claim(slot, claimId) + return () => release(slot, claimId) + }, [slot, active, claim, release, claimId]) } /** @@ -53,14 +66,22 @@ export const useTabNavigationValue = ({ // that renders its own Container (CippFormPage) reads this so the two don't double up. providesGutters = false, }) => { - const [claims, setClaims] = useState([]) + const [claims, setClaims] = useState(EMPTY_CLAIMS) - const claim = useCallback((id) => { - setClaims((prev) => (prev.includes(id) ? prev : [...prev, id])) + const claim = useCallback((slot, id) => { + setClaims((prev) => { + const ids = prev[slot] ?? [] + if (ids.includes(id)) return prev + return { ...prev, [slot]: [...ids, id] } + }) }, []) - const release = useCallback((id) => { - setClaims((prev) => prev.filter((claimId) => claimId !== id)) + const release = useCallback((slot, id) => { + setClaims((prev) => { + const ids = prev[slot] + if (!ids?.includes(id)) return prev + return { ...prev, [slot]: ids.filter((claimId) => claimId !== id) } + }) }, []) return useMemo( @@ -73,8 +94,9 @@ export const useTabNavigationValue = ({ providesGutters, claim, release, - isClaimed: claims.length > 0, + isTabSlotClaimed: (claims[TAB_SLOT]?.length ?? 0) > 0, + isActionSlotClaimed: (claims[ACTION_SLOT]?.length ?? 0) > 0, }), - [enabled, tabs, currentPath, onNavigate, actions, providesGutters, claim, release, claims.length] + [enabled, tabs, currentPath, onNavigate, actions, providesGutters, claim, release, claims] ) } diff --git a/frontend/tests/components/CippComponents/CippPageActionsFab.stories.jsx b/frontend/tests/components/CippComponents/CippPageActionsFab.stories.jsx index 963f7e0126..44f6b41c1b 100644 --- a/frontend/tests/components/CippComponents/CippPageActionsFab.stories.jsx +++ b/frontend/tests/components/CippComponents/CippPageActionsFab.stories.jsx @@ -22,18 +22,23 @@ const TABS = [ { label: 'Configuration Backup', path: '/tenant/manage/backup', icon: 'Backup' }, ] -// Stands in for a tabbed layout: below md those layouts hand their tabs to whichever FAB -// owns the corner rather than rendering a scrollable tab bar or a second FAB. -const withTabs = (Story) => ( +const LAYOUT_ACTIONS = [{ label: 'Reset Password', onClick: () => {} }] + +// Stands in for a headered tabbed layout: below md its header Actions menu is clipped, so +// those actions ride in whichever FAB owns the corner. Its tabs do not — those live in the +// title row (CippTabPicker), which is why this sheet never shows a "Views" section. +const withLayoutActions = (Story) => ( {}, + actions: LAYOUT_ACTIONS, claim: () => {}, release: () => {}, - isClaimed: false, + isTabSlotClaimed: false, + isActionSlotClaimed: false, }} > @@ -173,11 +178,11 @@ export const DashboardSections = { }, } -// Under a tabbed layout the sheet carries both the page's own action and the layout's -// views. Every page-actions FAB uses the same neutral glyph — a "+" only ever told the -// truth on pages whose sheet creates things. -export const MixedActionsAndViews = { - decorators: [withTabs], +// Under a headered tabbed layout the sheet carries the page's own action and the layout's +// header actions, labelled as two sections. Every page-actions FAB uses the same neutral +// glyph — a "+" only ever told the truth on pages whose sheet creates things. +export const PageAndLayoutActions = { + decorators: [withLayoutActions], render: () => ( - + +
    page content
    ); - await waitFor(() => - expect(screen.queryByRole("button", { name: "Views" })).not.toBeInTheDocument() + expect(queryPickers()).toHaveLength(0); + }); + + it("counts visible tabs, not configured ones, when deciding to render", async () => { + layoutState.isMobile = true; + const user = userEvent.setup(); + const gated = [tabOptions[0], { label: "Diagnostics", path: "/x", advanced: true }]; + + // one real tab plus one the user's advanced setting hides — nothing to switch between + const { unmount } = renderWithProviders( + +
    page content
    +
    ); - const fabs = screen.getAllByRole("button", { name: /Page actions|Views/ }); - expect(fabs).toHaveLength(1); + expect(queryPickers()).toHaveLength(0); + unmount(); - await user.click(fabs[0]); - expect(await screen.findByRole("button", { name: "Add Variable" })).toBeInTheDocument(); - expect(screen.getByText("Identity")).toBeInTheDocument(); + renderWithProviders( + +
    page content
    +
    + ); + const sheet = await openPicker(user); + expect(sheet.getByText("Overview")).toBeInTheDocument(); + expect(sheet.queryByText("Diagnostics")).not.toBeInTheDocument(); }); - // The sheet heading and the section subheader were both saying "Views" - it("names the views once when the sheet holds nothing else", async () => { + // On a table page the current tab and the page heading say the same word, so the heading + // becomes the picker rather than sitting under a second copy of itself. + it("stands aside when the page claims the title slot", async () => { layoutState.isMobile = true; - const user = userEvent.setup(); renderWithProviders( -
    page content
    +
    ); - await openFab(user); - await screen.findByText("Overview"); - expect(screen.getAllByText("Views")).toHaveLength(1); + await waitFor(() => expect(queryPickers()).toHaveLength(1)); + expect(picker()).toHaveTextContent("Relationships"); }); - it("labels both sections when a page action shares the sheet", async () => { + // Destinations used to ride in this sheet. A FAB is for a screen's primary action. + it("no longer puts destinations in the page FAB", async () => { layoutState.isMobile = true; const user = userEvent.setup(); renderWithProviders( @@ -142,24 +178,54 @@ describe("TabbedLayout", () => { ); - await user.click(screen.getByRole("button", { name: "Page actions" })); - await screen.findByText("Overview"); - // one "Views" subheader, and no sheet title repeating it - expect(screen.getAllByText("Views")).toHaveLength(1); - expect(screen.queryByText("Actions")).not.toBeInTheDocument(); + // the layout adds no FAB of its own any more — this one is the page's, and navigation + // sits in the content flow beside it + const fabs = screen.getAllByRole("button", { name: /Page actions/ }); + expect(fabs).toHaveLength(1); + expect(picker()).toBeInTheDocument(); + + // the sheet is a modal, so it aria-hides the page behind it — assert on its contents only + await user.click(fabs[0]); + expect(await screen.findByRole("button", { name: "Add Variable" })).toBeInTheDocument(); + expect(screen.queryByText("Identity")).not.toBeInTheDocument(); + expect(screen.queryByText("Devices")).not.toBeInTheDocument(); + expect(screen.queryByText("Views")).not.toBeInTheDocument(); }); - it("hides tabs that the user's advanced setting gates off", async () => { + // The defect the FAB placement caused: the card list claimed the corner during select mode + // but drew no FAB there, and the layout stood down because the corner was claimed — leaving + // no way at all to reach the other views until selection ended. + it("keeps navigation reachable while a card list is in select mode", async () => { layoutState.isMobile = true; + layoutState.viewMode = "cards"; const user = userEvent.setup(); renderWithProviders( - -
    page content
    + + ); - await openFab(user); - await screen.findByText("Overview"); - expect(screen.queryByText("Diagnostics")).not.toBeInTheDocument(); + // the table's heading is the picker, so the layout supplies none + await waitFor(() => expect(queryPickers()).toHaveLength(1)); + expect(picker()).toHaveTextContent("Relationships"); + + await user.click(screen.getByRole("button", { name: /^Select$/ })); + await waitFor(() => + expect(screen.getByRole("button", { name: /^Cancel$/ })).toBeInTheDocument() + ); + + // this is the assertion the FAB placement could not satisfy + expect(queryPickers()).toHaveLength(1); + const sheet = await openPicker(user); + expect(sheet.getByText("Devices")).toBeInTheDocument(); }); }); From 3c5bab8377dd37581656836b8107ae8471d318fe Mon Sep 17 00:00:00 2001 From: John Duprey Date: Mon, 10 Aug 2026 16:03:52 -0400 Subject: [PATCH 016/226] refactor(mobile-ux): simplify tab navigation slot model Replace the two-slot (TAB_SLOT / ACTION_SLOT) claim registry with a single action-corner claim. The tab picker is now always rendered by the layout in its own full-width row, eliminating the heading-annexation pattern where the picker would sometimes appear inside a card list's title. Adds a compact variant for HeaderedTabbedLayout's title row, fixes subtitle overflow for long guest UPNs on mobile, and adds viewport resize helpers and a new header-overflow story. --- .../CippComponents/CippPageActionsFab.jsx | 7 +- .../CippComponents/CippTabPicker.jsx | 86 ++++------- .../src/components/CippTable/CippDataTable.js | 23 +-- .../CippTable/CippMobileCardList.jsx | 4 +- frontend/src/layouts/HeaderedTabbedLayout.jsx | 140 +++++++++++------- frontend/src/layouts/TabbedLayout.jsx | 12 +- .../src/layouts/tab-navigation-context.js | 70 ++++----- .../CippPageActionsFab.stories.jsx | 3 +- .../CippComponents/CippTabPicker.stories.jsx | 113 ++++++++------ .../CippWizard/CippWizardPage.stories.jsx | 11 +- frontend/tests/layouts/TabbedLayout.test.jsx | 23 +-- .../tests/layouts/header-overflow.stories.jsx | 90 +++++++++++ frontend/tests/viewport.js | 27 +++- 13 files changed, 358 insertions(+), 251 deletions(-) create mode 100644 frontend/tests/layouts/header-overflow.stories.jsx diff --git a/frontend/src/components/CippComponents/CippPageActionsFab.jsx b/frontend/src/components/CippComponents/CippPageActionsFab.jsx index 22ac5cb3d0..12b0f75666 100644 --- a/frontend/src/components/CippComponents/CippPageActionsFab.jsx +++ b/frontend/src/components/CippComponents/CippPageActionsFab.jsx @@ -13,8 +13,7 @@ import { import { MoreHoriz } from '@mui/icons-material' import { CippBottomSheet } from './CippBottomSheet' import { - ACTION_SLOT, - useSlotClaim, + useActionCornerClaim, useTabNavigation, } from '../../layouts/tab-navigation-context' @@ -38,7 +37,7 @@ export const CippPageActionsFab = (props) => { restackButtons = true, sheetProps, // The tabbed layout's own fallback FAB must not claim the corner it is filling — - // claiming would flip isActionSlotClaimed, unmount it, release, and loop. + // claiming would flip isActionCornerClaimed, unmount it, release, and loop. claimActionCorner = true, children, } = props @@ -49,7 +48,7 @@ export const CippPageActionsFab = (props) => { // A tabbed layout may own page-level actions too (HeaderedTabbedLayout's ActionsMenu); // they belong in this sheet rather than in a cramped header menu. const layoutActions = (tabNav?.enabled && tabNav.actions) || [] - useSlotClaim(ACTION_SLOT, claimActionCorner) + useActionCornerClaim(claimActionCorner) // With both kinds of content the sections label themselves, so a sheet title would only // repeat one of them; a single-purpose sheet takes the heading instead of a subheader. diff --git a/frontend/src/components/CippComponents/CippTabPicker.jsx b/frontend/src/components/CippComponents/CippTabPicker.jsx index 6bb65be66a..c15e46843b 100644 --- a/frontend/src/components/CippComponents/CippTabPicker.jsx +++ b/frontend/src/components/CippComponents/CippTabPicker.jsx @@ -5,11 +5,7 @@ import { KeyboardArrowDown } from '@mui/icons-material' import { CippBottomSheet } from './CippBottomSheet' import { CippTabNavigationSection } from './CippTabNavigationSection' import { getIconByName } from '../../utils/icon-registry' -import { - TAB_SLOT, - useSlotClaim, - useTabNavigation, -} from '../../layouts/tab-navigation-context' +import { useTabNavigation } from '../../layouts/tab-navigation-context' /** * The mobile replacement for a tabbed layout's tab bar: a collapsed trigger that opens the tab @@ -20,44 +16,24 @@ import { * something else owned the corner (a card list in select mode draws no FAB at all). * * Two presentations, one behaviour: - * chip a control beside a heading — HeaderedTabbedLayout's title row, or the row - * TabbedLayout supplies when nothing claimed the slot. - * heading the heading *is* the trigger. Used where the page already draws a title that says - * the same thing as the current tab, so a separate chip would print it twice. + * block the default, and what every page gets — a full-width row in the slot the desktop + * tab bar occupies. Same control in the same place on every tabbed page. + * compact a chip beside a heading. Only HeaderedTabbedLayout, whose title row has an empty + * right half below md, so navigation there costs no vertical space at all. */ -/** - * Whether a picker would render anything here — for hosts that need to choose between the - * picker and their own heading before mounting either. `CippTabPicker` applies the same test - * internally, so rendering it unconditionally is always safe. - */ -export const useTabPickerAvailable = () => { - const tabNav = useTabNavigation() - return Boolean(tabNav?.enabled) && (tabNav?.tabs?.length ?? 0) > 1 -} - export const CippTabPicker = (props) => { - const { - label, - variant = 'chip', - // The claimant renders the picker; a layout's fallback must not claim the slot it is - // filling, or claiming would flip isTabSlotClaimed, unmount it, release, and loop. - claimSlot = true, - sx, - } = props + const { variant = 'block', sx } = props const [open, setOpen] = useState(false) const tabNav = useTabNavigation() const tabs = tabNav?.tabs ?? [] // One destination is not navigation. Two pages (View Group, View Device) have a single tab and // used to get a FAB whose sheet offered the page you were already on. - const active = useTabPickerAvailable() - useSlotClaim(TAB_SLOT, active && claimSlot) - - if (!active) return null + if (!tabNav?.enabled || tabs.length < 2) return null const current = tabs.find((tab) => tab.path === tabNav.currentPath) - const text = label ?? current?.label ?? 'Views' - const isHeading = variant === 'heading' + const label = current?.label ?? 'Views' + const isCompact = variant === 'compact' return ( <> @@ -69,51 +45,51 @@ export const CippTabPicker = (props) => { display: 'flex', alignItems: 'center', textAlign: 'left', - ...(isHeading - ? { gap: 0.5, borderRadius: 1 } - : { + gap: 0.75, + borderRadius: 1, + ...(isCompact + ? { flexShrink: 0, // Long labels ("Policies and Settings Deployed" is 30 characters) must not push // the heading beside them off the row. maxWidth: '50%', height: 40, - gap: 0.75, px: 1.25, - borderRadius: 1, bgcolor: 'action.hover', + } + : { + width: '100%', + // Matches the mobile table controls' search field rather than the filled chip: + // a full-width filled block reads as a banner, an outlined one as a control. + height: 44, + px: 1.5, + border: 1, + borderColor: 'divider', + bgcolor: 'background.paper', }), ...sx, }} > - {!isHeading && + {/* No leading icon in the compact chip: it shares a row with a heading that can be a + tenant or user name, and the ~28px it costs comes straight out of that heading. */} + {!isCompact && getIconByName(current?.icon, { fontSize: 'small', sx: { flexShrink: 0, color: 'text.secondary' }, })} - - {text} + + {label} {/* Not an aria-label: overriding the name would leave the visible text out of it, and - a voice-control user saying "Relationships" could no longer activate this. The + a voice-control user saying "Manage Drift" could no longer activate this. The hidden suffix extends the name instead of replacing it. */} - {current && text !== current.label - ? `switch view, currently ${current.label}` - : 'switch view'} + switch view {/* Pinned to the control's edge so it reads as the affordance rather than punctuation trailing whatever the current view happens to be called. */} setOpen(false)} title="Views"> diff --git a/frontend/src/components/CippTable/CippDataTable.js b/frontend/src/components/CippTable/CippDataTable.js index 693ef0bd23..821eaabff3 100644 --- a/frontend/src/components/CippTable/CippDataTable.js +++ b/frontend/src/components/CippTable/CippDataTable.js @@ -33,10 +33,6 @@ import { isEqual } from 'lodash' // Import lodash for deep comparison import { useLicenseBackfill } from '../../hooks/use-license-backfill' import { useTableViewMode } from '../../hooks/use-breakpoint' import { CippMobileCardList } from './CippMobileCardList' -import { - CippTabPicker, - useTabPickerAvailable, -} from '../CippComponents/CippTabPicker' // Resolve dot-delimited property paths against arbitrary data objects. const getNestedValue = (source, path) => { @@ -1109,15 +1105,6 @@ export const CippDataTable = (props) => { const selectModeActive = hasOnChange ? true : mobileSelectMode - // Under a tabbed layout the card view's heading and the current tab are usually the same - // word, so the heading becomes the tab picker rather than sitting under a second copy of - // itself. Claiming the slot is what tells the layout not to supply a row of its own. - // A dialog's table is not the page, so it never claims. Unlike the FAB this replaced, the - // heading is drawn in select mode too — that is where navigation used to disappear. - const tabPickerAvailable = useTabPickerAvailable() - const headingIsTabPicker = - isCardView && !hideTitle && !isInDialog && tabPickerAvailable - return ( <> {isCardView ? ( @@ -1134,13 +1121,9 @@ export const CippDataTable = (props) => { minWidth: 0, }} > - {headingIsTabPicker ? ( - - ) : ( - - {title} - - )} + + {title} + {Array.isArray(usedData) && !showSkeletons && ( { // Select mode's bulk bar owns the bottom of the screen, so the page FAB steps aside. Hold // the corner through it anyway: a headered layout would otherwise drop its actions FAB in // behind the bulk bar. Navigation is unaffected — the tab picker is in the title row. - useSlotClaim(ACTION_SLOT, fixedChrome && selectMode); + useActionCornerClaim(fixedChrome && selectMode); // A desktop tablePageSize above the cap would render that many unvirtualized cards. useEffect(() => { diff --git a/frontend/src/layouts/HeaderedTabbedLayout.jsx b/frontend/src/layouts/HeaderedTabbedLayout.jsx index 270053bd9a..1da392a2e6 100644 --- a/frontend/src/layouts/HeaderedTabbedLayout.jsx +++ b/frontend/src/layouts/HeaderedTabbedLayout.jsx @@ -94,6 +94,56 @@ export const HeaderedTabbedLayout = (props) => { providesGutters: true, }); + const subtitleBlock = isFetching ? ( + + ) : ( + subtitle && ( + // useFlexGap: Stack's default spacing is a margin-left between children, which every + // wrapped row inherits — that margin is why the icon/chip pairs sat indented from the + // title above them. Gap applies to both axes, so the row gap is set separately or the + // stacked pairs end up as far apart vertically as they are horizontally. + + {/* minWidth: 0 down the whole chain, and flexShrink: 0 on the icon. A copy-chip + already carries MUI's ellipsis and maxWidth: 100%, but flex items default to + min-width: auto, so every ancestor grew to fit instead of letting it truncate — + which is how a guest UPN (user_domain.onmicrosoft.com#EXT#@tenant...) ran off the + right edge of the screen. */} + {subtitle.map((item, index) => + item.component ? ( + + {item.component} + + ) : ( + + + {item.icon} + + + {item.text} + + + ) + )} + + ) + ); + return ( { - - {/* minWidth: 0 so a long tenant/entity name wraps in the space the picker - leaves rather than pushing it off the right edge of the row. Scoped to - the picker's own breakpoint — above md this row is unchanged. */} - - - {title} + + + {/* minWidth: 0 so a long tenant/entity name truncates in the space the + picker leaves rather than pushing it off the right edge of the row. + Scoped to the picker's own breakpoint — above md this is unchanged. */} + + + + {title} + + + {!mdDown && subtitleBlock} - {isFetching ? ( - + {/* The right half of this row is free below md, which is where the tab + picker goes. Above md it belongs to the Actions menu, as it always did. */} + {mdDown ? ( + ) : ( - subtitle && ( - // useFlexGap: Stack's default spacing is a margin-left between - // children, which every wrapped row inherits — that margin is why the - // icon/chip pairs sat indented from the title above them. Gap applies - // to both axes, so the row gap is set separately or the stacked pairs - // end up as far apart vertically as they are horizontally. - - {subtitle.map((item, index) => - item.component ? ( - {item.component} - ) : ( - - {item.icon} - - {item.text} - - - ) - )} - + actions && + actions.length > 0 && ( + ) )} - {/* The right half of this row is free below md, which is where the tab picker - goes. Above md it belongs to the Actions menu, as it always did. */} - {mdDown ? ( - - ) : ( - actions && - actions.length > 0 && ( - - ) - )} + {/* Below md the subtitle gets the full width instead of sharing the title's + row: a UPN copy-chip squeezed beside a half-width picker has nowhere to go + and runs off the right edge of the screen. */} + {mdDown && subtitleBlock} {!mdDown && (
    @@ -222,7 +250,7 @@ export const HeaderedTabbedLayout = (props) => { {actionsDispatch.dialog} {/* Actions only, and only when no page FAB claimed the corner — otherwise they ride in that sheet. Tabs are in the title row and never come down here. */} - {mdDown && sheetActions.length > 0 && !tabNavValue.isActionSlotClaimed && ( + {mdDown && sheetActions.length > 0 && !tabNavValue.isActionCornerClaimed && ( )} diff --git a/frontend/src/layouts/TabbedLayout.jsx b/frontend/src/layouts/TabbedLayout.jsx index ea8373d67f..36f2aa8055 100644 --- a/frontend/src/layouts/TabbedLayout.jsx +++ b/frontend/src/layouts/TabbedLayout.jsx @@ -56,9 +56,9 @@ export const TabbedLayout = (props) => { const currentTab = visibleTabs.find((option) => option.path === pathname) // Below md the tab row scrolls horizontally and still hides tabs off the right edge, so - // navigation collapses to a picker. Where the page already draws a heading — a card - // list's "Relationships · 1,284 results" — that heading becomes the picker instead; - // this layout supplies a row of its own only when nothing claimed the slot. + // navigation collapses to a full-width picker in the slot the tab bar occupied. Always the + // layout's own row: a picker that sometimes annexes a heading somewhere on the page and + // sometimes doesn't is a control you have to go looking for. const isMobile = useIsMobileLayout() const tabNavValue = useTabNavigationValue({ tabs: visibleTabs, @@ -77,9 +77,9 @@ export const TabbedLayout = (props) => { }} > - {isMobile && !tabNavValue.isTabSlotClaimed && ( - - + {isMobile && ( + + )} {!isMobile && ( diff --git a/frontend/src/layouts/tab-navigation-context.js b/frontend/src/layouts/tab-navigation-context.js index 3efb174ee0..185998e033 100644 --- a/frontend/src/layouts/tab-navigation-context.js +++ b/frontend/src/layouts/tab-navigation-context.js @@ -9,37 +9,26 @@ import { } from 'react' /** - * Lets a tabbed layout hand its tab list — and, on the headered variant, its page actions — to - * whichever surface is better placed to render them on mobile. + * Lets a tabbed layout publish its tab list — and, on the headered variant, its page actions. * * Below md the scrollable tab row costs a band of vertical space and still hides tabs off the - * right edge, so navigation collapses to a picker instead. Two different bits of screen are - * contested, and they are contested independently: + * right edge, so navigation collapses to a picker in the content flow (CippTabPicker). That + * picker is always drawn by the layout, so there is nothing to negotiate over it. * - * TAB_SLOT the mobile title row. A page that already draws a heading (a card list's - * "Users · 1,284 results") turns that heading into the picker rather than - * stacking a second copy of the same word above it. - * ACTION_SLOT the bottom-right FAB corner, which fits exactly one FAB. About a quarter of - * tabbed pages already grow one from a table's `cardButton`, so a headered - * layout hands its actions to that FAB instead of adding another. - * - * A layout fills either slot itself only when nothing else has claimed it. + * The FAB corner is different: it fits exactly one FAB, and about a quarter of tabbed pages + * already grow one from a table's `cardButton`. A headered layout therefore hands its actions + * to that FAB rather than adding a second one — hence the claim registry below. */ export const TabNavigationContext = createContext(null) -export const TAB_SLOT = 'tabs' -export const ACTION_SLOT = 'actions' - export const useTabNavigation = () => useContext(TabNavigationContext) -const EMPTY_CLAIMS = {} - /** - * Claims `slot` while `active`. A claimant takes responsibility for making that slot's content - * reachable — the card list, for instance, holds the action corner through select mode, when its - * bulk bar owns the bottom of the screen and no FAB may be drawn there. + * Claims the bottom-right corner while `active`. A claimant takes responsibility for making + * the layout's actions reachable — or for deliberately withholding them, as the card list does + * while its select-mode bulk bar owns the bottom of the screen. */ -export const useSlotClaim = (slot, active) => { +export const useActionCornerClaim = (active) => { const context = useContext(TabNavigationContext) const claimId = useId() const claim = context?.claim @@ -47,9 +36,9 @@ export const useSlotClaim = (slot, active) => { useEffect(() => { if (!active || !claim || !release) return undefined - claim(slot, claimId) - return () => release(slot, claimId) - }, [slot, active, claim, release, claimId]) + claim(claimId) + return () => release(claimId) + }, [active, claim, release, claimId]) } /** @@ -66,22 +55,14 @@ export const useTabNavigationValue = ({ // that renders its own Container (CippFormPage) reads this so the two don't double up. providesGutters = false, }) => { - const [claims, setClaims] = useState(EMPTY_CLAIMS) + const [claims, setClaims] = useState([]) - const claim = useCallback((slot, id) => { - setClaims((prev) => { - const ids = prev[slot] ?? [] - if (ids.includes(id)) return prev - return { ...prev, [slot]: [...ids, id] } - }) + const claim = useCallback((id) => { + setClaims((prev) => (prev.includes(id) ? prev : [...prev, id])) }, []) - const release = useCallback((slot, id) => { - setClaims((prev) => { - const ids = prev[slot] - if (!ids?.includes(id)) return prev - return { ...prev, [slot]: ids.filter((claimId) => claimId !== id) } - }) + const release = useCallback((id) => { + setClaims((prev) => prev.filter((claimId) => claimId !== id)) }, []) return useMemo( @@ -94,9 +75,18 @@ export const useTabNavigationValue = ({ providesGutters, claim, release, - isTabSlotClaimed: (claims[TAB_SLOT]?.length ?? 0) > 0, - isActionSlotClaimed: (claims[ACTION_SLOT]?.length ?? 0) > 0, + isActionCornerClaimed: claims.length > 0, }), - [enabled, tabs, currentPath, onNavigate, actions, providesGutters, claim, release, claims] + [ + enabled, + tabs, + currentPath, + onNavigate, + actions, + providesGutters, + claim, + release, + claims.length, + ] ) } diff --git a/frontend/tests/components/CippComponents/CippPageActionsFab.stories.jsx b/frontend/tests/components/CippComponents/CippPageActionsFab.stories.jsx index 44f6b41c1b..5609ef91e9 100644 --- a/frontend/tests/components/CippComponents/CippPageActionsFab.stories.jsx +++ b/frontend/tests/components/CippComponents/CippPageActionsFab.stories.jsx @@ -37,8 +37,7 @@ const withLayoutActions = (Story) => ( actions: LAYOUT_ACTIONS, claim: () => {}, release: () => {}, - isTabSlotClaimed: false, - isActionSlotClaimed: false, + isActionCornerClaimed: false, }} > diff --git a/frontend/tests/components/CippComponents/CippTabPicker.stories.jsx b/frontend/tests/components/CippComponents/CippTabPicker.stories.jsx index 91b9c58ccd..ee0d05e299 100644 --- a/frontend/tests/components/CippComponents/CippTabPicker.stories.jsx +++ b/frontend/tests/components/CippComponents/CippTabPicker.stories.jsx @@ -30,8 +30,7 @@ const withTabs = actions: [], claim: () => {}, release: () => {}, - isTabSlotClaimed: false, - isActionSlotClaimed: false, + isActionCornerClaimed: false, }} > @@ -44,26 +43,13 @@ export default { tags: ['autodocs'], } -// The HeaderedTabbedLayout title row, reproduced: heading left, picker right. jsdom cannot -// answer this one — it has no layout engine, so scrollWidth is always 0 there. -export const TitleRowAtPhoneWidth = { +// The default, and what every tabbed page gets: one full-width control in the slot the +// desktop tab bar occupied. Same control, same place, every page. +export const BlockAtPhoneWidth = { decorators: [withTabs()], render: () => ( - - - - Contoso Manufacturing Holdings GmbH - - 4,182 users · M365 E5 - - - - + + ), play: async ({ canvasElement, step }) => { @@ -72,26 +58,58 @@ export const TitleRowAtPhoneWidth = { const picker = canvas.getByRole('button', { name: /switch view/i }) await step('the trigger names the current view', async () => { - await expect(picker).toHaveAccessibleName( - 'Policies and Settings Deployed switch view' - ) + await expect(picker).toHaveAccessibleName('Policies and Settings Deployed switch view') }) if (!onAPhone) return - await step('the longest label in the app does not widen the row', async () => { - const host = canvasElement.querySelector('[data-testid="title-row-host"]') + await step('the longest label in the app fits without widening the page', async () => { + const host = canvasElement.querySelector('[data-testid="block-host"]') await waitFor(() => expect(host.scrollWidth).toBeLessThanOrEqual(host.clientWidth)) - // and it stays a control rather than eating the heading's half of the row - await expect(picker.getBoundingClientRect().width).toBeLessThanOrEqual( - host.clientWidth / 2 + 1 - ) + // full width of the gutter box, so the control is unmistakably a control + const style = getComputedStyle(host) + const content = + host.clientWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight) + await expect(picker.getBoundingClientRect().width).toBeGreaterThan(content - 1) }) await step('the chevron stays pinned to the right edge', async () => { const chevron = picker.querySelector('svg:last-of-type') const gap = picker.getBoundingClientRect().right - chevron.getBoundingClientRect().right - await expect(gap).toBeLessThan(16) + await expect(gap).toBeLessThan(20) + }) + }, +} + +// The one exception: HeaderedTabbedLayout's title row is empty on its right half below md, +// so the picker rides there and navigation costs no vertical space at all. +export const CompactInTitleRow = { + decorators: [withTabs()], + render: () => ( + + + + + Contoso Manufacturing Holdings GmbH + + + + + + ), + play: async ({ canvasElement, step }) => { + const onAPhone = await shrinkToPhoneViewport() + if (!onAPhone) return + const canvas = within(canvasElement) + const picker = canvas.getByRole('button', { name: /switch view/i }) + + await step('a 30-char label beside a long title does not widen the row', async () => { + const host = canvasElement.querySelector('[data-testid="title-row-host"]') + await waitFor(() => expect(host.scrollWidth).toBeLessThanOrEqual(host.clientWidth)) + // and it stays a control rather than eating the heading's half of the row + await expect(picker.getBoundingClientRect().width).toBeLessThanOrEqual( + host.clientWidth / 2 + 1 + ) }) }, } @@ -110,35 +128,32 @@ export const SingleTabRendersNothing = { }, } -// On table pages the current tab and the page heading are the same word, so the heading is -// the trigger rather than sitting under a second copy of itself. -export const HeadingVariant = { +export const OpensTheSheet = { decorators: [withTabs('/tenant/manage/edit')], render: () => ( - - - - 1,284 results - + + ), play: async ({ canvasElement, step }) => { const canvas = within(canvasElement) - await step('the heading is the trigger', async () => { - const picker = canvas.getByRole('button', { name: /switch view/i }) - await expect(picker).toHaveTextContent('Relationships') - // named for where you'd go, labelled for where you are - await expect(picker).toHaveAccessibleName( - 'Relationships switch view, currently Edit Tenant' - ) - }) + // The trigger names the current view and so does its row in the sheet — scope to the + // sheet, or every current-tab query matches twice. + let sheet - await step('it opens the same sheet', async () => { + await step('every destination is a full-width row, none scrolled off an edge', async () => { await userEvent.click(canvas.getByRole('button', { name: /switch view/i })) const body = within(document.body) - await waitFor(() => expect(body.getByText('Configuration Backup')).toBeInTheDocument()) - await expect(body.getByText('Views')).toBeInTheDocument() + await waitFor(() => expect(body.getByText('Views')).toBeInTheDocument()) + sheet = within(body.getByText('Views').closest('.MuiDrawer-paper')) + await expect(sheet.getByText('Configuration Backup')).toBeInTheDocument() + await expect(sheet.getByText('Policies and Settings Deployed')).toBeInTheDocument() + }) + + await step('the current view is checked', async () => { + const current = sheet.getByText('Edit Tenant').closest('[role="button"]') + await expect(current).toHaveClass('Mui-selected') }) }, } diff --git a/frontend/tests/components/CippWizard/CippWizardPage.stories.jsx b/frontend/tests/components/CippWizard/CippWizardPage.stories.jsx index 8395f2891e..5fd81bb92a 100644 --- a/frontend/tests/components/CippWizard/CippWizardPage.stories.jsx +++ b/frontend/tests/components/CippWizard/CippWizardPage.stories.jsx @@ -3,7 +3,7 @@ import { within, expect, userEvent, waitFor } from 'storybook/test' import { Typography } from '@mui/material' import CippWizardPage from '../../../src/components/CippWizard/CippWizardPage' import { CippWizardStepButtons } from '../../../src/components/CippWizard/CippWizardStepButtons' -import { shrinkToPhoneViewport } from '../../viewport' +import { shrinkToPhoneViewport, growToDesktopViewport } from '../../viewport' // A step that renders nothing but the shared button row — the layout under test is the // wizard shell, not any particular step's form. @@ -41,8 +41,9 @@ export const PhoneWidth = { await canvas.findByText('Step content') if (!onAPhone) return - // the stepper is replaced, not merely restyled - expect(canvas.getByText('Step 1 of 5')).toBeInTheDocument() + // the stepper is replaced, not merely restyled. findBy, not getBy: useMediaQuery reacts to + // the resize on a later tick, and "Step content" is in both branches so it settles nothing + await canvas.findByText('Step 1 of 5') expect(canvasElement.querySelector('.MuiStepper-root')).toBeNull() // nothing in the card reaches past the screen @@ -67,10 +68,12 @@ export const PhoneWidth = { export const DesktopWidth = { render: () => , play: async ({ canvasElement }) => { + // Claim the width rather than inherit it — PhoneWidth shares this page and shrinks it. + await growToDesktopViewport() const canvas = within(canvasElement) await canvas.findByText('Step content') - expect(canvasElement.querySelector('.MuiStepper-root')).not.toBeNull() + await waitFor(() => expect(canvasElement.querySelector('.MuiStepper-root')).not.toBeNull()) expect(canvas.queryByRole('progressbar')).toBeNull() expect(canvas.queryByText('Step 1 of 5')).toBeNull() diff --git a/frontend/tests/layouts/TabbedLayout.test.jsx b/frontend/tests/layouts/TabbedLayout.test.jsx index 4c7ae3b0d6..dfbbb0f214 100644 --- a/frontend/tests/layouts/TabbedLayout.test.jsx +++ b/frontend/tests/layouts/TabbedLayout.test.jsx @@ -39,7 +39,6 @@ vi.mock("../../src/api/ApiCall", () => ({ import { TabbedLayout } from "../../src/layouts/TabbedLayout"; import { CippPageActionsFab } from "../../src/components/CippComponents/CippPageActionsFab"; -import { CippTabPicker } from "../../src/components/CippComponents/CippTabPicker"; import { CippDataTable } from "../../src/components/CippTable/CippDataTable"; const tabOptions = [ @@ -152,18 +151,26 @@ describe("TabbedLayout", () => { expect(sheet.queryByText("Diagnostics")).not.toBeInTheDocument(); }); - // On a table page the current tab and the page heading say the same word, so the heading - // becomes the picker rather than sitting under a second copy of itself. - it("stands aside when the page claims the title slot", async () => { + // One control, one place, on every tabbed page — never annexing a heading that happens to + // be nearby on some page types and not others. + it("draws exactly one picker, in its own row, whatever the page renders", async () => { layoutState.isMobile = true; + layoutState.viewMode = "cards"; renderWithProviders( - + ); - await waitFor(() => expect(queryPickers()).toHaveLength(1)); - expect(picker()).toHaveTextContent("Relationships"); + await waitFor(() => expect(screen.getByText("Relationships")).toBeInTheDocument()); + expect(queryPickers()).toHaveLength(1); + // the page's own heading is still a heading, not a control + expect(picker()).not.toHaveTextContent("Relationships"); }); // Destinations used to ride in this sheet. A FAB is for a screen's primary action. @@ -214,9 +221,7 @@ describe("TabbedLayout", () => { ); - // the table's heading is the picker, so the layout supplies none await waitFor(() => expect(queryPickers()).toHaveLength(1)); - expect(picker()).toHaveTextContent("Relationships"); await user.click(screen.getByRole("button", { name: /^Select$/ })); await waitFor(() => diff --git a/frontend/tests/layouts/header-overflow.stories.jsx b/frontend/tests/layouts/header-overflow.stories.jsx new file mode 100644 index 0000000000..3ee8d20320 --- /dev/null +++ b/frontend/tests/layouts/header-overflow.stories.jsx @@ -0,0 +1,90 @@ +import React from 'react' +import { within, waitFor, expect } from 'storybook/test' +import { Box, Stack, SvgIcon, Typography } from '@mui/material' +import { Mail, Fingerprint, CalendarToday } from '@mui/icons-material' +import { CippCopyToClipBoard } from '../../src/components/CippComponents/CippCopyToClipboard' +import { shrinkToPhoneViewport } from '../viewport' + +/** + * Reproduces HeaderedTabbedLayout's mobile header markup — it cannot render the layout + * itself, which needs next/router and this Storybook runs on @storybook/react-vite. Keep the + * two in step: this exists to hold the CSS contract that lets a copy-chip truncate. + * + * A guest UPN is the worst case in the app: `user_domain.onmicrosoft.com#EXT#@tenant...` is + * one unbreakable token, roughly 60 characters, and it ran off the right edge of the screen. + */ +const GUEST_UPN = 'jduprey_7ngn50.onmicrosoft.com#EXT#@1h81wz.onmicrosoft.com' + +const SubtitleItem = ({ icon, children }) => ( + + + {icon} + + + {children} + + +) + +export default { + title: 'Layouts/HeaderedTabbedLayout/MobileHeader', + tags: ['autodocs'], +} + +export const GuestUpnDoesNotSpill = { + render: () => ( + + + + + + jduprey + + + + + }> + + + }> + + + }>Created: 1 month ago + + + + ), + play: async ({ canvasElement, step }) => { + const onAPhone = await shrinkToPhoneViewport() + if (!onAPhone) return + const canvas = within(canvasElement) + + await step('the guest UPN chip truncates instead of widening the page', async () => { + const host = canvasElement.querySelector('[data-testid="header-host"]') + await waitFor(() => expect(host.scrollWidth).toBeLessThanOrEqual(host.clientWidth)) + await expect(document.documentElement.scrollWidth).toBeLessThanOrEqual( + document.documentElement.clientWidth + ) + }) + + await step('and it is still the full value on the clipboard, not a truncated one', async () => { + // the label is elided in CSS only — the text node keeps the whole UPN + await expect(canvas.getByText(GUEST_UPN)).toBeInTheDocument() + }) + }, +} diff --git a/frontend/tests/viewport.js b/frontend/tests/viewport.js index 198d552e5a..4a6dff2ce5 100644 --- a/frontend/tests/viewport.js +++ b/frontend/tests/viewport.js @@ -1,22 +1,41 @@ /** - * Shrinks the story iframe to a phone viewport, for stories that measure layout. + * Resizes the story iframe, for stories that measure layout or drive a breakpoint. * - * Two things this exists to get right: + * Three things this exists to get right: * - The VIEWPORT has to shrink, not a wrapper element. MUI breakpoints are media queries, * so a 390px-wide Box inside a desktop-width iframe still renders every `md` branch. * - The import has to be lazy. At module scope `@vitest/browser/context` throws * "can be imported only inside the Browser Mode", which breaks the story for anyone who * opens it in the Storybook app rather than the test runner. + * - Every story shares one page. A story that shrinks the viewport and never restores it + * leaves the next story running at phone width — which is an ordering-dependent failure, + * so a desktop story must claim its width rather than assume it. * * Returns false when there is no runner driving the iframe, so a play function can skip - * measurements that would otherwise assert against a desktop width. + * measurements that would otherwise assert against whatever width Storybook happens to use. + * + * NOTE: resizing does not synchronously re-render. `useMediaQuery` updates from a matchMedia + * change listener, i.e. a tick later — so the first assertion that depends on the new + * breakpoint must be a `findBy*` or wrapped in `waitFor`, never a bare `getBy*`. Verified by + * probe: right after this resolves, the mobile branch is not in the DOM yet. A preceding + * `await` on something present in BOTH branches does not settle it — it only makes the race + * usually go your way, which is how CippWizardPage passed locally and failed in CI. */ -export const shrinkToPhoneViewport = async (width = 390, height = 844) => { +const resize = async (width, height) => { try { const { page } = await import("@vitest/browser/context"); await page.viewport(width, height); + // Measured: window.innerWidth is ALREADY the new value when this resolves — the width is + // not what lags. What lags is React: matchMedia listeners fire, useMediaQuery setStates, + // and the breakpoint branch renders a tick later. A frame here covers the common case; it + // is not a guarantee, which is why callers must still findBy/waitFor (see below). + await new Promise((resolve) => requestAnimationFrame(resolve)); return true; } catch { return false; } }; + +export const shrinkToPhoneViewport = async (width = 390, height = 844) => resize(width, height); + +export const growToDesktopViewport = async (width = 1280, height = 900) => resize(width, height); From e86df477a10765a07e3e6aa164990ff79d15378e Mon Sep 17 00:00:00 2001 From: John Duprey Date: Mon, 10 Aug 2026 17:43:00 -0400 Subject: [PATCH 017/226] feat(pdf): add mobile-friendly PDF preview component Replace direct PDFViewer usage with CippPdfPreview, a drop-in wrapper that renders the embedded iframe on desktop but falls back to platform-native open/download links on mobile (iOS Safari cannot scroll PDFs in iframes). Includes unit tests covering both branches. --- .../components/BECRemediationReportButton.js | 12 +- .../CippBaselineWhatIfReport.jsx | 10 +- .../CippPdf/CippBrandingReportPreview.jsx | 11 +- .../src/components/CippPdf/CippPdfPreview.jsx | 132 ++++++++++++++++++ .../CippPdf/PermissionsReportButton.jsx | 12 +- .../CippPdf/SharingReportButton.jsx | 12 +- .../src/components/ExecutiveReportButton.js | 11 +- .../ReportBuilder/ReportBuilderPDF.js | 12 +- .../src/components/ShadowAIReportButton.js | 10 +- .../CippPdf/CippPdfPreview.test.jsx | 118 ++++++++++++++++ 10 files changed, 313 insertions(+), 27 deletions(-) create mode 100644 frontend/src/components/CippPdf/CippPdfPreview.jsx create mode 100644 frontend/tests/components/CippPdf/CippPdfPreview.test.jsx diff --git a/frontend/src/components/BECRemediationReportButton.js b/frontend/src/components/BECRemediationReportButton.js index bd0255fdb1..47009919bd 100644 --- a/frontend/src/components/BECRemediationReportButton.js +++ b/frontend/src/components/BECRemediationReportButton.js @@ -12,7 +12,8 @@ import { CircularProgress, } from '@mui/material' import { PictureAsPdf, Download, Close } from '@mui/icons-material' -import { PDFViewer, PDFDownloadLink } from '@react-pdf/renderer' +import { PDFDownloadLink } from '@react-pdf/renderer' +import { CippPdfPreview } from './CippPdf/CippPdfPreview' import { useReportVariables } from './CippPdf/useReportVariables' import { useBrandingSettings } from './CippPdf/useBrandingSettings' import { @@ -859,7 +860,12 @@ export const BECRemediationReportButton = ({ userData, becData, tenantName }) => {hasData && ( - + tenantName={tenantName} variables={variables} /> - + )} diff --git a/frontend/src/components/CippBaselines/CippBaselineWhatIfReport.jsx b/frontend/src/components/CippBaselines/CippBaselineWhatIfReport.jsx index ca7f454029..af7f88192a 100644 --- a/frontend/src/components/CippBaselines/CippBaselineWhatIfReport.jsx +++ b/frontend/src/components/CippBaselines/CippBaselineWhatIfReport.jsx @@ -15,11 +15,11 @@ import { Download, PictureAsPdf } from '@mui/icons-material' import { Document, Page, - PDFViewer, StyleSheet, Text, View, } from '@react-pdf/renderer' +import { CippPdfPreview } from '../CippPdf/CippPdfPreview' import { parseCippDate } from '../../utils/parse-cipp-date' const operatorLabels = { @@ -439,14 +439,16 @@ export const CippBaselineWhatIfReport = ({ /> {open && ( - {reportDocument} - + )} + {document} - + ) } diff --git a/frontend/src/components/CippPdf/CippPdfPreview.jsx b/frontend/src/components/CippPdf/CippPdfPreview.jsx new file mode 100644 index 0000000000..062003fc54 --- /dev/null +++ b/frontend/src/components/CippPdf/CippPdfPreview.jsx @@ -0,0 +1,132 @@ +import { Box, Button, CircularProgress, Stack, Typography } from '@mui/material' +import { Download, OpenInNew, PictureAsPdf } from '@mui/icons-material' +import { PDFViewer, usePDF } from '@react-pdf/renderer' +import { useIsMobileLayout } from '../../hooks/use-breakpoint' + +const formatSize = (bytes) => { + if (!bytes && bytes !== 0) return null + if (bytes < 1024 * 1024) return `${Math.max(1, Math.round(bytes / 1024))} KB` + return `${(bytes / (1024 * 1024)).toFixed(1)} MB` +} + +/** + * The mobile half. `PDFViewer` is an iframe pointed at a blob URL, and iOS Safari renders a + * PDF in an iframe as a fixed first-page preview: it does not scroll, at any iframe height. + * No amount of CSS fixes that, so below md we stop pretending to embed the document and hand + * it to the platform viewer, which scrolls, pinch-zooms, shares and prints. + * + * Both actions are real anchors rather than window.open in a click handler — a programmatic + * open from an async callback is what mobile popup blockers exist to stop. + */ +const MobileHandoff = ({ document, fileName, title }) => { + const [instance] = usePDF({ document }) + + if (instance.loading) { + return ( + + + + Building report… + + + ) + } + + if (instance.error || !instance.url) { + return ( + + Report could not be generated + + {instance.error ? String(instance.error) : 'No document was produced.'} + + + ) + } + + const size = formatSize(instance.blob?.size) + + return ( + + + + + + + + {title ?? 'Report'} + + {size && ( + + PDF · {size} + + )} + + + + + + + + + Opens in your phone's PDF viewer — a PDF embedded in a page can't be scrolled on iOS. + + + ) +} + +/** + * Drop-in for ``: identical on desktop, a platform handoff below md. + * + * `fileName` names the download and `title` labels the card; both are mobile-only. `viewerKey` + * is applied to the desktop iframe alone — one caller remounts it per render to dodge a + * react-pdf error, and doing that on mobile would rebuild the blob on every render. + */ +export const CippPdfPreview = (props) => { + const { children, fileName, title, viewerKey, ...viewerProps } = props + const isMobile = useIsMobileLayout() + + if (isMobile) { + return + } + + return ( + + {children} + + ) +} + +export default CippPdfPreview diff --git a/frontend/src/components/CippPdf/PermissionsReportButton.jsx b/frontend/src/components/CippPdf/PermissionsReportButton.jsx index 6be6907658..92031cb5e7 100644 --- a/frontend/src/components/CippPdf/PermissionsReportButton.jsx +++ b/frontend/src/components/CippPdf/PermissionsReportButton.jsx @@ -12,7 +12,8 @@ import { Typography, } from '@mui/material' import { Close, Download, PictureAsPdf } from '@mui/icons-material' -import { PDFViewer, PDFDownloadLink } from '@react-pdf/renderer' +import { PDFDownloadLink } from '@react-pdf/renderer' +import { CippPdfPreview } from './CippPdfPreview' import { AlertBox, Bold, @@ -476,9 +477,14 @@ export const PermissionsReportButton = ({ permissionsData, tenantName }) => { {dialogOpen && ( - + {documentNode} - + )} diff --git a/frontend/src/components/CippPdf/SharingReportButton.jsx b/frontend/src/components/CippPdf/SharingReportButton.jsx index 3ff51601b3..e2bb9eecf8 100644 --- a/frontend/src/components/CippPdf/SharingReportButton.jsx +++ b/frontend/src/components/CippPdf/SharingReportButton.jsx @@ -12,7 +12,8 @@ import { Typography, } from '@mui/material' import { Close, Download, PictureAsPdf } from '@mui/icons-material' -import { PDFViewer, PDFDownloadLink } from '@react-pdf/renderer' +import { PDFDownloadLink } from '@react-pdf/renderer' +import { CippPdfPreview } from './CippPdfPreview' import { AlertBox, Bold, @@ -467,9 +468,14 @@ export const SharingReportButton = ({ sharingData, tenantName }) => { {dialogOpen && ( - + {documentNode} - + )} diff --git a/frontend/src/components/ExecutiveReportButton.js b/frontend/src/components/ExecutiveReportButton.js index e76126114e..0dabaf0df3 100644 --- a/frontend/src/components/ExecutiveReportButton.js +++ b/frontend/src/components/ExecutiveReportButton.js @@ -20,7 +20,8 @@ import { import { PictureAsPdf, Download, Close, Settings } from '@mui/icons-material' import { CippAutoComplete } from './CippComponents/CippAutocomplete' import { CippOffCanvas } from './CippComponents/CippOffCanvas' -import { Document, Page, Text, View, PDFViewer, Image } from '@react-pdf/renderer' +import { Document, Page, Text, View, Image } from '@react-pdf/renderer' +import { CippPdfPreview } from './CippPdf/CippPdfPreview' import { useSettings } from '../hooks/use-settings' import { useSecureScore } from '../hooks/use-securescore' import { ApiGetCall } from '../api/ApiCall' @@ -1915,8 +1916,10 @@ export const ExecutiveReportButton = (props) => { ) : reportDocument ? ( - { showToolbar={true} > {reportDocument} - + ) : ( + {document} - + ) } return null diff --git a/frontend/src/components/ShadowAIReportButton.js b/frontend/src/components/ShadowAIReportButton.js index 2464eb788a..82b13955ca 100644 --- a/frontend/src/components/ShadowAIReportButton.js +++ b/frontend/src/components/ShadowAIReportButton.js @@ -15,7 +15,7 @@ import { Typography, } from '@mui/material' import { Close, Download, PictureAsPdf, Settings } from '@mui/icons-material' -import { PDFViewer } from '@react-pdf/renderer' +import { CippPdfPreview } from './CippPdf/CippPdfPreview' import { CippOffCanvas } from './CippComponents/CippOffCanvas' import { useReportVariables } from './CippPdf/useReportVariables' import { useBrandingSettings } from './CippPdf/useBrandingSettings' @@ -774,13 +774,15 @@ export const ShadowAIReportButton = ({ data, tenantName, disabled }) => { {/* Right Panel - PDF Preview */} {reportDocument && ( - {reportDocument} - + )} diff --git a/frontend/tests/components/CippPdf/CippPdfPreview.test.jsx b/frontend/tests/components/CippPdf/CippPdfPreview.test.jsx new file mode 100644 index 0000000000..b475ec59d1 --- /dev/null +++ b/frontend/tests/components/CippPdf/CippPdfPreview.test.jsx @@ -0,0 +1,118 @@ +import React from 'react' +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { screen } from '@testing-library/react' +import { renderWithProviders } from '../../test-utils' + +// jsdom has no width-based matchMedia, so the mobile branch is driven by mocking the hook +const layoutState = vi.hoisted(() => ({ isMobile: false })) +vi.mock('../../../src/hooks/use-breakpoint', () => ({ + useIsMobileLayout: () => layoutState.isMobile, + useIsTabletLayout: () => false, + useTableViewMode: () => 'table', +})) + +// Building a real PDF in jsdom is neither possible nor the point: what is under test is which +// branch renders and what it hands the user. Stable identities — a fresh object per call +// re-renders forever. +const pdfState = vi.hoisted(() => ({ + instance: { loading: false, error: null, url: 'blob:http://localhost/report-1', blob: { size: 1_572_864 } }, + viewerProps: null, +})) +vi.mock('@react-pdf/renderer', () => ({ + PDFViewer: (props) => { + pdfState.viewerProps = props + return
    {props.children}
    + }, + usePDF: () => [pdfState.instance], +})) + +import { CippPdfPreview } from '../../../src/components/CippPdf/CippPdfPreview' + +const doc =
    document
    + +const render = (props = {}) => + renderWithProviders( + + {doc} + + ) + +describe('CippPdfPreview', () => { + beforeEach(() => { + layoutState.isMobile = false + pdfState.viewerProps = null + pdfState.instance = { + loading: false, + error: null, + url: 'blob:http://localhost/report-1', + blob: { size: 1_572_864 }, + } + }) + + it('renders the embedded viewer on desktop', () => { + render() + expect(screen.getByTestId('pdf-viewer')).toBeInTheDocument() + expect(screen.getByTestId('report-doc')).toBeInTheDocument() + expect(screen.queryByRole('link', { name: /open report/i })).not.toBeInTheDocument() + }) + + // title/fileName/viewerKey are ours, not react-pdf's — forwarding them would land unknown + // attributes on the iframe and warn. + it('does not leak its own props onto the desktop viewer', () => { + render({ style: { border: 'none' }, showToolbar: true }) + expect(pdfState.viewerProps).not.toHaveProperty('title') + expect(pdfState.viewerProps).not.toHaveProperty('fileName') + expect(pdfState.viewerProps).not.toHaveProperty('viewerKey') + expect(pdfState.viewerProps.showToolbar).toBe(true) + }) + + // iOS renders a PDF in an iframe as a fixed first-page preview that cannot be scrolled, so + // below md the document goes to the platform viewer instead of being embedded. + it('hands off to the platform viewer on mobile instead of embedding', () => { + layoutState.isMobile = true + render() + + expect(screen.queryByTestId('pdf-viewer')).not.toBeInTheDocument() + + const open = screen.getByRole('link', { name: /open report/i }) + expect(open).toHaveAttribute('href', 'blob:http://localhost/report-1') + expect(open).toHaveAttribute('target', '_blank') + // a real anchor, not window.open in a handler — that is what popup blockers stop + expect(open.tagName).toBe('A') + }) + + it('offers a download named after the report', () => { + layoutState.isMobile = true + render() + + const download = screen.getByRole('link', { name: /download/i }) + expect(download).toHaveAttribute('download', 'Executive_Report.pdf') + expect(download).toHaveAttribute('href', 'blob:http://localhost/report-1') + }) + + it('names the report and its size', () => { + layoutState.isMobile = true + render() + + expect(screen.getByText('Executive Report - Contoso')).toBeInTheDocument() + expect(screen.getByText(/1\.5 MB/)).toBeInTheDocument() + }) + + it('shows progress while the document is still building', () => { + layoutState.isMobile = true + pdfState.instance = { loading: true, error: null, url: null, blob: null } + render() + + expect(screen.getByRole('progressbar')).toBeInTheDocument() + expect(screen.queryByRole('link', { name: /open report/i })).not.toBeInTheDocument() + }) + + it('surfaces a generation failure rather than an empty frame', () => { + layoutState.isMobile = true + pdfState.instance = { loading: false, error: 'boom', url: null, blob: null } + render() + + expect(screen.getByText(/could not be generated/i)).toBeInTheDocument() + expect(screen.queryByRole('link', { name: /open report/i })).not.toBeInTheDocument() + }) +}) From 7846faa857abf9969eb3c7393069b17ffc6c4d75 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Mon, 10 Aug 2026 22:16:51 -0400 Subject: [PATCH 018/226] feat(pdf): make mobile download button opt-in Add `showDownload` prop to `CippPdfPreview` (default `false`) so the mobile handoff card only shows its own Download button when the host has no download action of its own. Enables it for branding preview and report builder, which have no separate download control. Updates tests accordingly. --- .../CippPdf/CippBrandingReportPreview.jsx | 1 + .../src/components/CippPdf/CippPdfPreview.jsx | 44 ++++++++++++------- .../ReportBuilder/ReportBuilderPDF.js | 1 + .../CippPdf/CippPdfPreview.test.jsx | 14 +++++- 4 files changed, 42 insertions(+), 18 deletions(-) diff --git a/frontend/src/components/CippPdf/CippBrandingReportPreview.jsx b/frontend/src/components/CippPdf/CippBrandingReportPreview.jsx index e74f97c2c1..c22c489426 100644 --- a/frontend/src/components/CippPdf/CippBrandingReportPreview.jsx +++ b/frontend/src/components/CippPdf/CippBrandingReportPreview.jsx @@ -110,6 +110,7 @@ const CippBrandingReportPreview = ({ reportType = 'executive', brandingSettings fileName="Branding_Preview.pdf" style={{ width: '100%', height: '100%', border: 'none' }} showToolbar={true} + showDownload > {document} diff --git a/frontend/src/components/CippPdf/CippPdfPreview.jsx b/frontend/src/components/CippPdf/CippPdfPreview.jsx index 062003fc54..e7c1ecc9ae 100644 --- a/frontend/src/components/CippPdf/CippPdfPreview.jsx +++ b/frontend/src/components/CippPdf/CippPdfPreview.jsx @@ -18,7 +18,7 @@ const formatSize = (bytes) => { * Both actions are real anchors rather than window.open in a click handler — a programmatic * open from an async callback is what mobile popup blockers exist to stop. */ -const MobileHandoff = ({ document, fileName, title }) => { +const MobileHandoff = ({ document, fileName, title, showDownload }) => { const [instance] = usePDF({ document }) if (instance.loading) { @@ -88,16 +88,20 @@ const MobileHandoff = ({ document, fileName, title }) => { > Open report - + {/* Off by default: six of the eight hosts already put a Download in their dialog + actions, and two of them side by side is what this looked like on a phone. */} + {showDownload && ( + + )}
    @@ -110,16 +114,24 @@ const MobileHandoff = ({ document, fileName, title }) => { /** * Drop-in for ``: identical on desktop, a platform handoff below md. * - * `fileName` names the download and `title` labels the card; both are mobile-only. `viewerKey` - * is applied to the desktop iframe alone — one caller remounts it per render to dodge a - * react-pdf error, and doing that on mobile would rebuild the blob on every render. + * `title` labels the card and `fileName` names the download; both are mobile-only, as is + * `showDownload` — pass it only where the host has no download action of its own. `viewerKey` + * is applied to the desktop iframe alone: one caller remounts it per render to dodge a + * react-pdf error, and doing that on mobile would rebuild the blob every render. */ export const CippPdfPreview = (props) => { - const { children, fileName, title, viewerKey, ...viewerProps } = props + const { children, fileName, title, viewerKey, showDownload = false, ...viewerProps } = props const isMobile = useIsMobileLayout() if (isMobile) { - return + return ( + + ) } return ( diff --git a/frontend/src/components/ReportBuilder/ReportBuilderPDF.js b/frontend/src/components/ReportBuilder/ReportBuilderPDF.js index d17ed4a94d..d0f0a85235 100644 --- a/frontend/src/components/ReportBuilder/ReportBuilderPDF.js +++ b/frontend/src/components/ReportBuilder/ReportBuilderPDF.js @@ -656,6 +656,7 @@ export const ReportBuilderPDF = ({ fileName="Report.pdf" style={{ width: '100%', height: '100%', border: 'none' }} showToolbar={true} + showDownload > {document} diff --git a/frontend/tests/components/CippPdf/CippPdfPreview.test.jsx b/frontend/tests/components/CippPdf/CippPdfPreview.test.jsx index b475ec59d1..575499cace 100644 --- a/frontend/tests/components/CippPdf/CippPdfPreview.test.jsx +++ b/frontend/tests/components/CippPdf/CippPdfPreview.test.jsx @@ -59,10 +59,11 @@ describe('CippPdfPreview', () => { // title/fileName/viewerKey are ours, not react-pdf's — forwarding them would land unknown // attributes on the iframe and warn. it('does not leak its own props onto the desktop viewer', () => { - render({ style: { border: 'none' }, showToolbar: true }) + render({ style: { border: 'none' }, showToolbar: true, showDownload: true }) expect(pdfState.viewerProps).not.toHaveProperty('title') expect(pdfState.viewerProps).not.toHaveProperty('fileName') expect(pdfState.viewerProps).not.toHaveProperty('viewerKey') + expect(pdfState.viewerProps).not.toHaveProperty('showDownload') expect(pdfState.viewerProps.showToolbar).toBe(true) }) @@ -81,10 +82,19 @@ describe('CippPdfPreview', () => { expect(open.tagName).toBe('A') }) - it('offers a download named after the report', () => { + // Six of the eight hosts already put a Download in their dialog actions; showing one here + // as well is exactly the duplicate that appeared on a phone. + it('offers no download of its own by default', () => { layoutState.isMobile = true render() + expect(screen.queryByRole('link', { name: /download/i })).not.toBeInTheDocument() + }) + + it('offers a download named after the report where the host has none', () => { + layoutState.isMobile = true + render({ showDownload: true }) + const download = screen.getByRole('link', { name: /download/i }) expect(download).toHaveAttribute('download', 'Executive_Report.pdf') expect(download).toHaveAttribute('href', 'blob:http://localhost/report-1') From 46c86379414eb061a815f762edd62f97a3441ade Mon Sep 17 00:00:00 2001 From: John Duprey Date: Mon, 10 Aug 2026 22:39:12 -0400 Subject: [PATCH 019/226] fix(mobile): fix dialog action button alignment on phones On small screens the actions row stacks vertically, but `:first-of-type` margin logic left buttons at different widths and left edges. Switched to `gap` and zeroed the inherited `margin-left` at the mobile breakpoint. Also added horizontal padding and text centering to the report loading pane. Includes a Storybook story and a Vitest assertion to hold the contract. --- .../src/components/ExecutiveReportButton.js | 6 +- frontend/src/theme/base/create-components.js | 15 ++++ .../CippPdf/ReportDialogActions.stories.jsx | 68 +++++++++++++++++++ frontend/tests/theme/mobile-gutters.test.js | 11 +++ 4 files changed, 99 insertions(+), 1 deletion(-) create mode 100644 frontend/tests/components/CippPdf/ReportDialogActions.stories.jsx diff --git a/frontend/src/components/ExecutiveReportButton.js b/frontend/src/components/ExecutiveReportButton.js index 0dabaf0df3..85dbe61f12 100644 --- a/frontend/src/components/ExecutiveReportButton.js +++ b/frontend/src/components/ExecutiveReportButton.js @@ -1908,10 +1908,14 @@ export const ExecutiveReportButton = (props) => { justifyContent: 'center', height: '100%', gap: 2, + // Gutters and a measure: this pane is the full width of the screen below md, + // where the second line is long enough to run edge to edge and break badly. + px: 3, + textAlign: 'center', }} > Loading Report Data... - + Fetching additional data for comprehensive report generation diff --git a/frontend/src/theme/base/create-components.js b/frontend/src/theme/base/create-components.js index ab5f2ab034..1cdcaa76b8 100644 --- a/frontend/src/theme/base/create-components.js +++ b/frontend/src/theme/base/create-components.js @@ -243,6 +243,21 @@ export const createComponents = () => { "&>:not(:first-of-type)": { marginLeft: 16, }, + "@media (max-width: 899.95px)": { + // 32px of side padding is a lot of a 390px screen. + paddingBottom: 16, + paddingLeft: 16, + paddingRight: 16, + paddingTop: 16, + // Spacing as gap, not margin-left. `:first-of-type` counts per element type, so an + // actions row of [caption div, button, button] gave the FIRST button no margin and + // the second 16px — invisible in a row, but once the row stacks on a phone the two + // buttons sit at different left edges and different widths. gap works either way. + gap: 8, + "&>:not(:first-of-type)": { + marginLeft: 0, + }, + }, }, }, }, diff --git a/frontend/tests/components/CippPdf/ReportDialogActions.stories.jsx b/frontend/tests/components/CippPdf/ReportDialogActions.stories.jsx new file mode 100644 index 0000000000..ec80d2f4fe --- /dev/null +++ b/frontend/tests/components/CippPdf/ReportDialogActions.stories.jsx @@ -0,0 +1,68 @@ +import React from 'react' +import { within, waitFor, expect } from 'storybook/test' +import { Box, Button, DialogActions, Typography } from '@mui/material' +import { Download } from '@mui/icons-material' +import { shrinkToPhoneViewport } from '../../viewport' + +/** + * The report dialogs' action row, reproduced — the dialogs themselves need too much data to + * mount. Below md the caption and two buttons cannot share a line at 390px, so the row stacks; + * this holds the contract that the buttons then span the same width as each other. + */ +const ActionsRow = () => ( + + :not(style) ~ :not(style)': { ml: { xs: 0, md: 1 } }, + }} + > + + + Sections enabled: 7 of 9 + + + + + + +) + +export default { + title: 'Components/CippPdf/ReportDialogActions', + tags: ['autodocs'], +} + +export const StackedAtPhoneWidth = { + render: () => , + play: async ({ canvasElement, step }) => { + const onAPhone = await shrinkToPhoneViewport() + if (!onAPhone) return + const canvas = within(canvasElement) + const host = canvasElement.querySelector('[data-testid="actions-host"]') + + const primary = canvas.getByRole('button', { name: /download pdf/i }) + const secondary = canvas.getByRole('button', { name: /^close$/i }) + + await step('the two buttons share one width and one left edge', async () => { + await waitFor(() => { + const a = primary.getBoundingClientRect() + const b = secondary.getBoundingClientRect() + expect(Math.abs(a.width - b.width)).toBeLessThanOrEqual(1) + expect(Math.abs(a.left - b.left)).toBeLessThanOrEqual(1) + expect(Math.abs(a.right - b.right)).toBeLessThanOrEqual(1) + }) + }) + + await step('and nothing pushes the row wider than the screen', async () => { + await expect(host.scrollWidth).toBeLessThanOrEqual(host.clientWidth) + }) + }, +} diff --git a/frontend/tests/theme/mobile-gutters.test.js b/frontend/tests/theme/mobile-gutters.test.js index 3b77d52bef..bf17a4fd4c 100644 --- a/frontend/tests/theme/mobile-gutters.test.js +++ b/frontend/tests/theme/mobile-gutters.test.js @@ -27,6 +27,17 @@ describe("horizontal gutters on small screens", () => { } ); + // `:first-of-type` counts per element type, so an actions row of [caption div, button, + // button] gave the first button no margin and the second 16px. Invisible in a row; once the + // row stacks on a phone the two buttons sit at different left edges and different widths. + it("spaces dialog actions with gap on a phone, not a margin the stack inherits", () => { + const actions = root("MuiDialogActions"); + expect(actions["&>:not(:first-of-type)"].marginLeft).toBe(16); + expect(actions[MOBILE]?.["&>:not(:first-of-type)"]?.marginLeft).toBe(0); + expect(actions[MOBILE]?.gap).toBe(8); + expect(actions[MOBILE]?.paddingLeft).toBe(16); + }); + it("leaves vertical rhythm alone — width is what runs out, not height", () => { const content = root("MuiCardContent"); expect(content.paddingTop).toBe(20); From 4dbd0899d20f556f18f57629c1ea7b7f7ba54c6a Mon Sep 17 00:00:00 2001 From: John Duprey Date: Mon, 10 Aug 2026 22:46:43 -0400 Subject: [PATCH 020/226] fix: remove iOS PDF viewer caption from MobileHandoff --- frontend/src/components/CippPdf/CippPdfPreview.jsx | 4 ---- 1 file changed, 4 deletions(-) diff --git a/frontend/src/components/CippPdf/CippPdfPreview.jsx b/frontend/src/components/CippPdf/CippPdfPreview.jsx index e7c1ecc9ae..85615bdcc8 100644 --- a/frontend/src/components/CippPdf/CippPdfPreview.jsx +++ b/frontend/src/components/CippPdf/CippPdfPreview.jsx @@ -103,10 +103,6 @@ const MobileHandoff = ({ document, fileName, title, showDownload }) => { )} - - - Opens in your phone's PDF viewer — a PDF embedded in a page can't be scrolled on iOS. - ) } From 1cedb366755079f2b1e378c88908fa795ce6f8f8 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Mon, 10 Aug 2026 23:10:22 -0400 Subject: [PATCH 021/226] fix(dashboard): improve mobile layout for charts - CippSankey: hide labels on mobile and render a tappable legend below the chart instead, avoiding unreadable label collisions on small nodes - SecureScoreCard: let recharts drop overlapping x-axis ticks on narrow screens; narrow the y-axis gutter - Dashboard: use responsive height (`{ xs: 'auto', lg: 450 }`) for card wrappers so single-column mobile layout doesn't clip card content - Add lint rule to catch pixel-pinned dashboard card heights - Add tests covering all three changes --- .../components/CippComponents/CippSankey.jsx | 180 +++++++++++++----- .../CippComponents/SecureScoreCard.jsx | 40 +++- frontend/src/pages/dashboardv2/index.js | 11 +- .../CippComponents/CippSankey.test.jsx | 53 +++++- .../CippComponents/SecureScoreCard.test.jsx | 44 ++++- .../tests/lint/mobile-layout-patterns.test.js | 44 +++++ 6 files changed, 307 insertions(+), 65 deletions(-) diff --git a/frontend/src/components/CippComponents/CippSankey.jsx b/frontend/src/components/CippComponents/CippSankey.jsx index 59357c6308..035d0d9952 100644 --- a/frontend/src/components/CippComponents/CippSankey.jsx +++ b/frontend/src/components/CippComponents/CippSankey.jsx @@ -1,15 +1,34 @@ +import { useMemo } from "react"; import { ResponsiveSankey } from "@nivo/sankey"; +import { Box, ButtonBase, Typography } from "@mui/material"; import { useSettings } from "../../hooks/use-settings"; import { useIsMobileLayout } from "../../hooks/use-breakpoint"; +// A node's weight: what flows in, or out if nothing flows in (the leftmost column). +const nodeTotals = (data) => { + const incoming = new Map(); + const outgoing = new Map(); + (data?.links ?? []).forEach((link) => { + incoming.set(link.target, (incoming.get(link.target) ?? 0) + (link.value ?? 0)); + outgoing.set(link.source, (outgoing.get(link.source) ?? 0) + (link.value ?? 0)); + }); + return (data?.nodes ?? []).map((node) => ({ + ...node, + total: incoming.get(node.id) ?? outgoing.get(node.id) ?? 0, + })); +}; + export const CippSankey = ({ data, onNodeClick, onLinkClick }) => { const settings = useSettings(); const isDark = settings.currentTheme?.value === "dark"; // A sankey is three columns of nodes plus their labels. At desktop widths the labels sit - // horizontally inside an 18px-thick node and still read; on a ~350px card they overrun the - // node and collide with the links. Narrow screens get thinner nodes, tighter spacing and - // labels rotated to run along the node instead of across the chart. + // horizontally inside an 18px-thick node and still read. On a ~350px card they cannot: a + // node carrying a handful of users is a couple of pixels tall, and its label — rotated or + // not — is longer than the node it belongs to, so the small ones pile on top of each other + // into an unreadable smear. Below md the chart drops its labels and names the nodes in a + // legend underneath, where there is room to read them and a real tap target per node. const isMobile = useIsMobileLayout(); + const legend = useMemo(() => (isMobile ? nodeTotals(data) : []), [isMobile, data]); const theme = { tooltip: { @@ -36,57 +55,118 @@ export const CippSankey = ({ data, onNodeClick, onLinkClick }) => { style={{ height: "100%", width: "100%", + display: "flex", + flexDirection: "column", + minHeight: 0, cursor: onNodeClick || onLinkClick ? "pointer" : "default", }} > - node.nodeColor} - label={(node) => node.label ?? node.id} - nodeOpacity={1} - nodeHoverOthersOpacity={0.35} - nodeThickness={isMobile ? 10 : 18} - nodeSpacing={isMobile ? 12 : 24} - nodeBorderWidth={0} - nodeBorderColor={{ - from: "color", - modifiers: [["darker", 0.8]], - }} - nodeBorderRadius={3} - linkOpacity={isMobile ? 0.75 : 0.5} - linkHoverOthersOpacity={0.1} - // Contracting each end eats into the gap between node columns; on a narrow chart - // that gap is small enough that 3px a side visibly thins the ribbons. - linkContract={isMobile ? 0 : 3} - // mix-blend-mode on SVG is unreliable in mobile WebKit — combined with a gradient - // fill it can composite the ribbons to nothing, which shows as bare node bars with - // no links between them. Blend is decoration here, so mobile renders them plainly - // and leans on opacity instead. - linkBlendMode={isMobile ? "normal" : isDark ? "lighten" : "multiply"} - enableLinkGradient={!isMobile} - labelPosition="inside" - labelOrientation={isMobile ? "vertical" : "horizontal"} - labelPadding={isMobile ? 6 : 16} - labelTextColor={isDark ? "#ffffff" : "#000000"} - sort="input" - legends={[]} - valueFormat={(value) => `${value}`} - isInteractive={true} - onClick={(node, event) => { - if (onNodeClick && node.id) { - onNodeClick(node); - } else if (onLinkClick && node.source) { - onLinkClick(node); +
    + + align="justify" + colors={(node) => node.nodeColor} + label={(node) => node.label ?? node.id} + nodeOpacity={1} + nodeHoverOthersOpacity={0.35} + nodeThickness={isMobile ? 10 : 18} + nodeSpacing={isMobile ? 12 : 24} + nodeBorderWidth={0} + nodeBorderColor={{ + from: "color", + modifiers: [["darker", 0.8]], + }} + nodeBorderRadius={3} + linkOpacity={isMobile ? 0.75 : 0.5} + linkHoverOthersOpacity={0.1} + // Contracting each end eats into the gap between node columns; on a narrow chart + // that gap is small enough that 3px a side visibly thins the ribbons. + linkContract={isMobile ? 0 : 3} + // mix-blend-mode on SVG is unreliable in mobile WebKit — combined with a gradient + // fill it can composite the ribbons to nothing, which shows as bare node bars with + // no links between them. Blend is decoration here, so mobile renders them plainly + // and leans on opacity instead. + linkBlendMode={isMobile ? "normal" : isDark ? "lighten" : "multiply"} + enableLinkGradient={!isMobile} + enableLabels={!isMobile} + labelPosition="inside" + labelOrientation={isMobile ? "vertical" : "horizontal"} + labelPadding={isMobile ? 6 : 16} + labelTextColor={isDark ? "#ffffff" : "#000000"} + sort="input" + legends={[]} + valueFormat={(value) => `${value}`} + isInteractive={true} + onClick={(node, event) => { + if (onNodeClick && node.id) { + onNodeClick(node); + } else if (onLinkClick && node.source) { + onLinkClick(node); + } + }} + /> +
    + {isMobile && legend.length > 0 && ( + + {legend.map((node) => ( + + onNodeClick?.(node)} + disabled={!onNodeClick} + sx={{ + width: "100%", + minHeight: 28, + px: 0.5, + borderRadius: 0.5, + display: "flex", + alignItems: "center", + gap: 0.75, + textAlign: "left", + justifyContent: "flex-start", + }} + > + + + {node.label ?? node.id} + + + {node.total} + + + + ))} + + )}
    ); }; diff --git a/frontend/src/components/CippComponents/SecureScoreCard.jsx b/frontend/src/components/CippComponents/SecureScoreCard.jsx index e20920a01e..6117faa2ed 100644 --- a/frontend/src/components/CippComponents/SecureScoreCard.jsx +++ b/frontend/src/components/CippComponents/SecureScoreCard.jsx @@ -11,9 +11,37 @@ import { Tooltip as RechartsTooltip, ReferenceLine, } from 'recharts' +import { useIsMobileLayout } from '../../hooks/use-breakpoint' + +/** + * Axis configuration for the score trend. + * + * Exported because it is the whole of the narrow-screen fix and there is nothing rendered to + * assert against: recharts reads its axis children's props directly rather than mounting them, + * so an XAxis cannot be captured by wrapping it. + * + * `interval: 0` draws a label for every point. Thirteen dates fit across a desktop card and + * overlap into one smear at 390px — "Jul 27Jul 28Jul 29". A narrow chart hands spacing back to + * recharts and lets it drop whatever will not fit. + */ +export const secureScoreAxisProps = ({ isMobile, ticks }) => ({ + x: { + tick: { fontSize: isMobile ? 10 : 12 }, + tickMargin: 8, + ticks: isMobile ? undefined : ticks, + interval: isMobile ? 'preserveStartEnd' : 0, + minTickGap: isMobile ? 28 : 5, + }, + y: { + tick: { fontSize: isMobile ? 10 : 12 }, + tickMargin: 8, + width: isMobile ? 34 : undefined, + }, +}) export const SecureScoreCard = ({ data, isLoading }) => { const router = useRouter() + const isMobile = useIsMobileLayout() return ( { percentage: Math.round((score.currentScore / score.maxScore) * 100), })) const ticks = chartData.map((d) => d.date) + const axis = secureScoreAxisProps({ isMobile, ticks }) return ( - + Math.round(value)} /> diff --git a/frontend/src/pages/dashboardv2/index.js b/frontend/src/pages/dashboardv2/index.js index 46598d920e..9b5c199555 100644 --- a/frontend/src/pages/dashboardv2/index.js +++ b/frontend/src/pages/dashboardv2/index.js @@ -375,14 +375,17 @@ const Page = () => { height: '100%', }} > - + {/* The fixed height exists to keep the two lg columns level. Below lg this is + a single column, so it buys nothing and clips instead: the description wraps + to more lines on a narrow card and the stats row falls off the bottom edge. */} + - + { height: '100%', }} > - + - + { beforeEach(() => { layoutState.isMobile = false; @@ -69,4 +85,39 @@ describe("CippSankey", () => { expect(sankeyProps.last.margin.left).toBeLessThan(10); expect(sankeyProps.last.theme.labels.text.fontSize).toBeLessThan(12); }); + + // A node worth 2 of 476 users is a couple of pixels tall; its label, rotated or not, is + // longer than the node it belongs to, so the small ones stack into an unreadable smear. + // Below md the chart stops drawing labels and the legend names the nodes instead. + it("moves node names out of the chart and into a legend on narrow screens", () => { + layoutState.isMobile = true; + render(); + + expect(sankeyProps.last.enableLabels).toBe(false); + + const legend = screen.getByRole("list"); + const rows = within(legend).getAllByRole("listitem"); + expect(rows).toHaveLength(4); + expect(legend).toHaveTextContent("Phishing-resistant"); + // weight comes from the links, not the nodes: incoming, or outgoing for the first column + expect(within(legend).getByText("476")).toBeInTheDocument(); + expect(within(legend).getByText("471")).toBeInTheDocument(); + }); + + it("keeps the chart labelled and adds no legend on desktop", () => { + render(); + expect(sankeyProps.last.enableLabels).toBe(true); + expect(screen.queryByRole("list")).not.toBeInTheDocument(); + }); + + it("makes each legend row a tap target that selects its node", async () => { + const onNodeClick = vi.fn(); + layoutState.isMobile = true; + const { default: userEvent } = await import("@testing-library/user-event"); + const user = userEvent.setup(); + render(); + + await user.click(screen.getByText("Single factor")); + expect(onNodeClick).toHaveBeenCalledWith(expect.objectContaining({ id: "single" })); + }); }); diff --git a/frontend/tests/components/CippComponents/SecureScoreCard.test.jsx b/frontend/tests/components/CippComponents/SecureScoreCard.test.jsx index bd47877ebd..9184157d51 100644 --- a/frontend/tests/components/CippComponents/SecureScoreCard.test.jsx +++ b/frontend/tests/components/CippComponents/SecureScoreCard.test.jsx @@ -1,7 +1,19 @@ import React from 'react' import { screen } from '@testing-library/react' import { renderWithTheme } from '../../test-utils' -import { SecureScoreCard } from '../../../src/components/CippComponents/SecureScoreCard' + +// jsdom has no width-based matchMedia, so the mobile branch is driven by mocking the hook +const layoutState = vi.hoisted(() => ({ isMobile: false })) +vi.mock('../../../src/hooks/use-breakpoint', () => ({ + useIsMobileLayout: () => layoutState.isMobile, + useIsTabletLayout: () => false, + useTableViewMode: () => 'table', +})) + +import { + SecureScoreCard, + secureScoreAxisProps, +} from '../../../src/components/CippComponents/SecureScoreCard' const scoreData = [ { createdDateTime: '2026-07-01T00:00:00Z', currentScore: 40, maxScore: 100 }, @@ -10,6 +22,36 @@ const scoreData = [ ] describe('SecureScoreCard', () => { + beforeEach(() => { + layoutState.isMobile = false + }) + + // recharts reads its axis children's props without mounting them, so there is no element to + // assert against — the config is exported and tested directly. + const ticks = ['Jul 1', 'Jul 15', 'Jul 29'] + + // interval 0 draws a label for every point. Thirteen dates fit across a desktop card and + // overlap into one smear at 390px, which is what "Jul 27Jul 28Jul 29" looks like. + it('labels every point on desktop', () => { + const axis = secureScoreAxisProps({ isMobile: false, ticks }) + + expect(axis.x.interval).toBe(0) + expect(axis.x.ticks).toBe(ticks) + expect(axis.x.tick.fontSize).toBe(12) + expect(axis.y.width).toBeUndefined() + }) + + it('hands x-axis spacing back to recharts on a narrow chart', () => { + const axis = secureScoreAxisProps({ isMobile: true, ticks }) + + expect(axis.x.interval).toBe('preserveStartEnd') + expect(axis.x.ticks).toBeUndefined() + expect(axis.x.minTickGap).toBeGreaterThan(5) + expect(axis.x.tick.fontSize).toBeLessThan(12) + // and the y-axis gutter narrows so the plot keeps the width it has + expect(axis.y.width).toBeLessThan(40) + }) + it('does not trigger the recharts zero-size warning on first render', () => { const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) diff --git a/frontend/tests/lint/mobile-layout-patterns.test.js b/frontend/tests/lint/mobile-layout-patterns.test.js index 33cb2aab1b..372d50326b 100644 --- a/frontend/tests/lint/mobile-layout-patterns.test.js +++ b/frontend/tests/lint/mobile-layout-patterns.test.js @@ -9,6 +9,9 @@ import path from "node:path"; // 1. / size={{ xs: N }} with N < 12 holds a desktop column split at 390px. // 2. A Stack with flexWrap but no useFlexGap: MUI's `spacing` is a margin-left between // children, and every wrapped row inherits it, so each new line starts indented. +// 3. A dashboard card pinned to a pixel height. That height exists to level two columns of +// a desktop grid; below lg the grid is a single column, so it levels nothing and clips +// instead — the Secure Score card lost its whole stats row off the bottom edge. const SRC = path.resolve(__dirname, "../../src"); @@ -97,7 +100,27 @@ export const gridOffenders = (rawSource) => { return offenders; }; +/** Dashboard card wrappers pinned to a pixel height, as `line reason` strings. */ +export const pinnedHeightOffenders = (rawSource) => { + const source = stripComments(rawSource); + const marked = new Set(); + rawSource.split("\n").forEach((text, index) => { + if (text.includes(MARKER)) marked.add(index + 1); + }); + + const offenders = []; + for (const tag of openingTags(source, "Box")) { + if (isExempt(marked, tag)) continue; + // `height: 450` — a bare number. `height: { xs: 'auto', lg: 450 }` is the fix, and + // minHeight/maxHeight are constraints rather than a pin, so both are left alone. + const pinned = tag.text.match(/[^a-zA-Z]height:\s*(\d+)\s*[,}]/); + if (pinned) offenders.push(`${tag.line} height: ${pinned[1]}`); + } + return offenders; +}; + const files = walk(SRC); +const dashboardFiles = files.filter((file) => rel(file).startsWith(path.join("pages", "dashboardv2"))); describe("mobile layout patterns", () => { it("has files to check", () => { @@ -126,6 +149,27 @@ describe("mobile layout patterns", () => { expect(gridOffenders(` // ${MARKER}\n\n\n\n\n${split}`)).toEqual(["6 xs: 6"]); }); + it("pins no dashboard card to a pixel height", () => { + expect(dashboardFiles.length).toBeGreaterThan(0); + const offenders = dashboardFiles.flatMap((file) => + pinnedHeightOffenders(fs.readFileSync(file, "utf8")).map( + (offender) => `${rel(file)}:${offender}` + ) + ); + expect( + offenders, + `Below lg the dashboard is one column, so a fixed height only clips. Use height: { xs: 'auto', lg: N }:\n${offenders.join("\n")}` + ).toEqual([]); + }); + + it("reads a pinned height only as a bare number", () => { + expect(pinnedHeightOffenders(` \n`)).toEqual(["1 height: 450"]); + expect(pinnedHeightOffenders(` \n`)).toEqual([]); + expect(pinnedHeightOffenders(` \n`)).toEqual([]); + expect(pinnedHeightOffenders(` \n`)).toEqual([]); + expect(pinnedHeightOffenders(` // ${MARKER}\n \n`)).toEqual([]); + }); + it("gives every wrapping Stack useFlexGap", () => { const offenders = []; for (const file of files) { From d709b6d0131dd51c452607c8508a84ff02ae2239 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Mon, 10 Aug 2026 23:36:12 -0400 Subject: [PATCH 022/226] fix(mobile): notification dot and avatar contrast fixes - Tuck notification badge dot inside the bell button on mobile (xs) so it doesn't visually attach to the adjacent account avatar - Increase top-nav right cluster spacing from 0.5 to 1 on mobile to give the dot room - Fix All Tenants avatar glyph colour using getContrastText so it meets 3:1 contrast on the primary accent - Add contrast assertion to CippMobileTenantPicker story - Add new notification-badge story covering dot positioning on phone and desktop --- .../CippComponents/CippMobileTenantPicker.jsx | 13 ++- frontend/src/layouts/notifications-popover.js | 17 +++- frontend/src/layouts/top-nav.js | 3 +- .../CippMobileTenantPicker.stories.jsx | 24 +++++ .../layouts/notification-badge.stories.jsx | 92 +++++++++++++++++++ 5 files changed, 146 insertions(+), 3 deletions(-) create mode 100644 frontend/tests/layouts/notification-badge.stories.jsx diff --git a/frontend/src/components/CippComponents/CippMobileTenantPicker.jsx b/frontend/src/components/CippComponents/CippMobileTenantPicker.jsx index 2679413034..706d93b632 100644 --- a/frontend/src/components/CippComponents/CippMobileTenantPicker.jsx +++ b/frontend/src/components/CippComponents/CippMobileTenantPicker.jsx @@ -224,7 +224,18 @@ export const CippMobileTenantPicker = () => { onClick={() => selectTenant("AllTenants")} sx={{ minHeight: 52, gap: 1.5 }} > - + {/* Avatar's default colour is background.default, so setting only bgcolor + left the glyph a dark grey sitting on the accent. getContrastText rather + than contrastText: the accent is a mid orange, and white on it measures + 2.6:1 — below the 3:1 a 24px glyph needs. This picks the dark ink. */} + theme.palette.getContrastText(theme.palette.primary.main), + }} + > { return ( <> - + diff --git a/frontend/src/layouts/top-nav.js b/frontend/src/layouts/top-nav.js index 7b9da31973..e0541e24e4 100644 --- a/frontend/src/layouts/top-nav.js +++ b/frontend/src/layouts/top-nav.js @@ -328,7 +328,8 @@ export const TopNav = (props) => { )}
    - + {/* 0.5 left the notification dot and the account avatar sharing the same few pixels */} + {!mdDown && ( { + const avatar = body + .getByText('All Tenants') + .closest('[role="button"]') + .querySelector('.MuiAvatar-root') + const style = getComputedStyle(avatar) + expect(style.backgroundColor).not.toBe(style.color) + + const luminance = (rgb) => { + const [r, g, b] = rgb.match(/\d+/g).map(Number) + const channel = (c) => { + const v = c / 255 + return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4 + } + return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b) + } + const a = luminance(style.color) + const b = luminance(style.backgroundColor) + const contrast = (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05) + expect(contrast).toBeGreaterThan(3) + }) }, } diff --git a/frontend/tests/layouts/notification-badge.stories.jsx b/frontend/tests/layouts/notification-badge.stories.jsx new file mode 100644 index 0000000000..5287412947 --- /dev/null +++ b/frontend/tests/layouts/notification-badge.stories.jsx @@ -0,0 +1,92 @@ +import React from 'react' +import { within, waitFor, expect } from 'storybook/test' +import { Avatar, Badge, IconButton, Stack, SvgIcon } from '@mui/material' +import BellIcon from '@heroicons/react/24/outline/BellIcon' +import { shrinkToPhoneViewport, growToDesktopViewport } from '../viewport' + +/** + * The top bar's right-hand cluster, reproduced — `TopNav` itself pulls in the router, the + * tenant list and half a dozen API hooks. Keep this in step with `notifications-popover.js` + * and `top-nav.js`; it exists to hold one thing, which is that the notification dot belongs + * to the bell and not to the avatar beside it. + */ +const Cluster = ({ mobile }) => ( + + + + + + + + + + J + + +) + +export default { + title: 'Layouts/TopNav/NotificationBadge', + tags: ['autodocs'], +} + +const dotAndAvatar = (canvasElement) => ({ + bell: canvasElement.querySelector('.MuiBadge-root'), + dot: canvasElement.querySelector('.MuiBadge-badge'), + avatar: canvasElement.querySelector('[data-testid="account-avatar"]'), +}) + +export const DotStaysWithTheBellOnAPhone = { + render: () => , + play: async ({ canvasElement, step }) => { + const onAPhone = await shrinkToPhoneViewport() + if (!onAPhone) return + const { bell, dot, avatar } = dotAndAvatar(canvasElement) + + await step('the dot sits inside the bell, not over the gap to the avatar', async () => { + await waitFor(() => { + const d = dot.getBoundingClientRect() + const b = bell.getBoundingClientRect() + const a = avatar.getBoundingClientRect() + expect(d.right).toBeLessThanOrEqual(b.right + 0.5) + expect(d.top).toBeGreaterThanOrEqual(b.top - 0.5) + // and there is real space left between it and the avatar + expect(a.left - d.right).toBeGreaterThan(4) + }) + }) + }, +} + +// The md values are MUI's own, so the badge keeps hanging off the corner above the breakpoint. +export const DotKeepsItsCornerOnDesktop = { + render: () => , + play: async ({ canvasElement, step }) => { + const onDesktop = await growToDesktopViewport() + if (!onDesktop) return + const { bell, dot } = dotAndAvatar(canvasElement) + + await step('the dot still overhangs the button', async () => { + await waitFor(() => { + const d = dot.getBoundingClientRect() + const b = bell.getBoundingClientRect() + expect(d.right).toBeGreaterThan(b.right) + }) + }) + }, +} From 0ce7b12f8272cb6b877aaa4191cf2ad92e330f1f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 11 Aug 2026 04:14:45 +0000 Subject: [PATCH 023/226] chore(deps): bump github/codeql-action from 4.37.4 to 4.37.6 Bumps [github/codeql-action](https://github.com/github/codeql-action) from 4.37.4 to 4.37.6. - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/v4.37.4...v4.37.6) --- updated-dependencies: - dependency-name: github/codeql-action dependency-version: 4.37.6 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- .github/workflows/CodeQL_Analyser.yml | 6 +++--- .github/workflows/codeql.yml | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/CodeQL_Analyser.yml b/.github/workflows/CodeQL_Analyser.yml index cbbf5578c8..de9a3e2b79 100644 --- a/.github/workflows/CodeQL_Analyser.yml +++ b/.github/workflows/CodeQL_Analyser.yml @@ -26,11 +26,11 @@ jobs: - name: Checkout Repository uses: actions/checkout@v6 - name: Initialize CodeQL - uses: github/codeql-action/init@v4.37.4 + uses: github/codeql-action/init@v4.37.6 with: languages: ${{ matrix.language }} queries: security-extended - name: Autobuild - uses: github/codeql-action/autobuild@v4.37.4 + uses: github/codeql-action/autobuild@v4.37.6 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v4.37.4 + uses: github/codeql-action/analyze@v4.37.6 diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 631c95a1f3..4e65d6cdfb 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -24,11 +24,11 @@ jobs: - name: Checkout Repository uses: actions/checkout@v4 - name: Initialize CodeQL - uses: github/codeql-action/init@v4.37.4 + uses: github/codeql-action/init@v4.37.6 with: languages: ${{ matrix.language }} source-root: frontend - name: Autobuild - uses: github/codeql-action/autobuild@v4.37.4 + uses: github/codeql-action/autobuild@v4.37.6 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v4.37.4 + uses: github/codeql-action/analyze@v4.37.6 From ba59db7131342cc542fc28f02b75ecfc844b4495 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 11 Aug 2026 04:14:48 +0000 Subject: [PATCH 024/226] chore(deps): bump @tiptap/starter-kit from 3.20.5 to 3.29.2 in /frontend Bumps [@tiptap/starter-kit](https://github.com/ueberdosis/tiptap/tree/HEAD/packages/starter-kit) from 3.20.5 to 3.29.2. - [Release notes](https://github.com/ueberdosis/tiptap/releases) - [Changelog](https://github.com/ueberdosis/tiptap/blob/main/packages/starter-kit/CHANGELOG.md) - [Commits](https://github.com/ueberdosis/tiptap/commits/v3.29.2/packages/starter-kit) --- updated-dependencies: - dependency-name: "@tiptap/starter-kit" dependency-version: 3.29.2 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- frontend/package.json | 2 +- frontend/yarn.lock | 251 +++++++++++++++++++++--------------------- 2 files changed, 124 insertions(+), 129 deletions(-) diff --git a/frontend/package.json b/frontend/package.json index f5e3782ce8..b8633a6b34 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -60,7 +60,7 @@ "@tiptap/extension-table": "^3.20.5", "@tiptap/pm": "^3.29.2", "@tiptap/react": "^3.20.5", - "@tiptap/starter-kit": "^3.20.5", + "@tiptap/starter-kit": "^3.29.2", "@vvo/tzdb": "^6.198.0", "apexcharts": "6.6.1", "axios": "1.18.1", diff --git a/frontend/yarn.lock b/frontend/yarn.lock index cdccb617fe..498e6b89d5 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -3004,25 +3004,20 @@ resolved "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz#13e09a32d7a8b7060fe38304788ebf4197cd2149" integrity sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw== -"@tiptap/core@^3.20.5": - version "3.27.3" - resolved "https://registry.npmjs.org/@tiptap/core/-/core-3.27.3.tgz#001d05642579b8c4727fe9acd71ce5f4900a508c" - integrity sha512-TJj5929M96C1KlH796wS8MywfHDh49RhmakOyzyMMc9pFmRj9UXi1gj0TCXgsZtjEOG7B+m/DRvNOvnuvR9kmg== - "@tiptap/core@^3.29.2": version "3.29.2" resolved "https://registry.npmjs.org/@tiptap/core/-/core-3.29.2.tgz#90d24591a9e7fb450ffb95ed6a42f529348e3e9e" integrity sha512-oKUkiPUB7noilVYxI9lNzUD4rX17sHub+PYjMfHMWHG9A3nvIy+FdePIVIIhThKWF7ijhr3eIqHY51Bn+GAFtw== -"@tiptap/extension-blockquote@^3.20.5": - version "3.20.5" - resolved "https://registry.yarnpkg.com/@tiptap/extension-blockquote/-/extension-blockquote-3.20.5.tgz#c64341fce14154b8c2785ead168d395436f953e7" - integrity sha512-0wU6H/MWWes0rGzgSW6MMU6YDs/3ofUDkqmqCqmb+Siu1ZD0bpzOYpBtujgOYDY8moB9+zCE3G9HSYGcmZxHew== +"@tiptap/extension-blockquote@^3.29.2": + version "3.29.2" + resolved "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-3.29.2.tgz#e654719cee5b039a5b4af97226e2652f592d4196" + integrity sha512-ca4OzKDh0yaxg2+Z56bC2QnWsNsFp2YMRfVig1PDXyMVFMNJpLcnhxgq/9btn+xYAlYrj8RymOeCTYREOR6Zjg== -"@tiptap/extension-bold@^3.20.5": - version "3.20.5" - resolved "https://registry.yarnpkg.com/@tiptap/extension-bold/-/extension-bold-3.20.5.tgz#b40e8e43db3123c5dee9864931f7f9ad1b1e07dc" - integrity sha512-hraiiWkF58n8Jy0Wl3OGwjCTrGWwZZxez/IlexrzKQ/nMFdjDpensZucWwu59zhAM9fqZwGSLDtCFuak03WKnA== +"@tiptap/extension-bold@^3.29.2": + version "3.29.2" + resolved "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-3.29.2.tgz#cd03d51096caa9135ccbb31c2bae778b5fac50df" + integrity sha512-elYbGxJsYnBb4leqrcjdIJuiG380BcOgN+UUzvOv+qEjfGVzHodFOMBl3qnmD6urYHNu5/qQK2S0qSSRXKCLNQ== "@tiptap/extension-bubble-menu@^3.20.5": version "3.22.3" @@ -3031,119 +3026,119 @@ dependencies: "@floating-ui/dom" "^1.0.0" -"@tiptap/extension-bullet-list@^3.20.5": - version "3.20.5" - resolved "https://registry.yarnpkg.com/@tiptap/extension-bullet-list/-/extension-bullet-list-3.20.5.tgz#dc53ab798a48c3aaf175752899f04cad2abc8ef3" - integrity sha512-MT3321R6F8AoVUEMJ5RiI0PQMenwvtmrSXoO1ehPCWq5TrSJLyXeZMJvZU+1CgfXk4XQU70RN78ib5+Zg+/FCg== +"@tiptap/extension-bullet-list@^3.29.2": + version "3.29.2" + resolved "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-3.29.2.tgz#c25a6023c7ee13e35f76ffcc4fd436415e9fa0b7" + integrity sha512-3bWcCUPbCHv0XttlMdnAtXLNYWx2pblByMgxmGsaP9FU0QnslGXty6A6gHCqI33ygRg1vrA6U5Wtpwbi5aKu5g== -"@tiptap/extension-code-block@^3.20.5": - version "3.20.5" - resolved "https://registry.yarnpkg.com/@tiptap/extension-code-block/-/extension-code-block-3.20.5.tgz#96daefd431f37a87eac33095d020937dd438fe6c" - integrity sha512-0YZnqfqZ1IjzKBM4aezw8j3LZWJFEfs4+mbizHNlnZSYpKzpESYLeaLWGO5SpqF9Z8tmYmSoCaf0fqi5LwgdIA== +"@tiptap/extension-code-block@^3.29.2": + version "3.29.2" + resolved "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-3.29.2.tgz#fd91f2475d0c9e289ac98cc3210f86d869af4e16" + integrity sha512-w153ct8g6dLiPTdXQ6SOIMxX4SEo5Q50AmjdEEEcJ7ZcYUcde/ScSskLHfOYmyt5ZFAiyEwr121+pux+p3/oAQ== -"@tiptap/extension-code@^3.20.5": - version "3.20.5" - resolved "https://registry.yarnpkg.com/@tiptap/extension-code/-/extension-code-3.20.5.tgz#c6c93fcb553ddb9e185316a4876f79b7d5d21171" - integrity sha512-jBZK/CfdMvg1gkNK/zNAk02IExpBPwUfNLRPiJvGhReL2Q73naKxZGQGp+5Lej9VaeFB70UKuRma/iIzuZbgsA== +"@tiptap/extension-code@^3.29.2": + version "3.29.2" + resolved "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-3.29.2.tgz#89a1398ae5e0bebcd7833c0e73621f11ccaa4a44" + integrity sha512-c6W5UGuB7WNLpYocsgRzpO2OOTI4QjaI9jjHRMuty9z+s9DtaYM/HrRLNwVh6MopkHb+i/89Wkv8gCS34fftig== -"@tiptap/extension-document@^3.20.5": - version "3.20.5" - resolved "https://registry.yarnpkg.com/@tiptap/extension-document/-/extension-document-3.20.5.tgz#24a15654057872db469da6b91584875dcda070ea" - integrity sha512-BpNGHtOTAjjs/6QbkrafMTlaJqb0gsPngFzd5rB0csxx7rYRE9nIEY+oZ44qMw161+2YB4u20L17SX2mUJANBw== +"@tiptap/extension-document@^3.29.2": + version "3.29.2" + resolved "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-3.29.2.tgz#215d692d4b5b9d7bbc8db8f9b9d4221f2a57c9a1" + integrity sha512-YUamvefLnsqu6124GavVTI7nqcFlQJ12ROB0oSwG69eSBZYNjg1tIs05LFrBxkwf4Xgqd6YzfJ9+FeG428RvzQ== -"@tiptap/extension-dropcursor@^3.20.5": - version "3.20.5" - resolved "https://registry.yarnpkg.com/@tiptap/extension-dropcursor/-/extension-dropcursor-3.20.5.tgz#ea810297825b009c357559e66f5fd76e91e8c940" - integrity sha512-/lDG9OjvAv0ynmgFH17mt/GUeGT5bqu0iPW8JMgaRqlKawk+uUIv5SF5WkXS4SwxXih+hXdPEQD3PWZnxlQxAQ== +"@tiptap/extension-dropcursor@^3.29.2": + version "3.29.2" + resolved "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-3.29.2.tgz#faa11b6d0d9312e964fcf0087fdc985ca57bc344" + integrity sha512-KKno7cU9r1HdR48CRrsDu69/1UjZdoslq/UcE+Kx+tdhAv/aljXMkRSNzGMrBNOBDmHRgS1+58zm21WQWdQzwA== "@tiptap/extension-floating-menu@^3.20.5": version "3.22.3" resolved "https://registry.yarnpkg.com/@tiptap/extension-floating-menu/-/extension-floating-menu-3.22.3.tgz#c9a911b7784cb45d6f8e7260d77bf2015066e5a4" integrity sha512-0f8b4KZ3XKai8GXWseIYJGdOfQr3evtFbBo3U08zy2aYzMMXWG0zEF7qe5/oiYp2aZ95edjjITnEceviTsZkIg== -"@tiptap/extension-gapcursor@^3.20.5": - version "3.20.5" - resolved "https://registry.yarnpkg.com/@tiptap/extension-gapcursor/-/extension-gapcursor-3.20.5.tgz#0fe2ffb1d7669fc4f5541a0c66342da4107b08f8" - integrity sha512-H+bRr+mqU/DQq1vfoMlppK1o+RbfSKYBMIcAMHWOez+C96MWfj5bhooVU2HLtl4XGmQxKGr3oEOCKDPdtRNThg== +"@tiptap/extension-gapcursor@^3.29.2": + version "3.29.2" + resolved "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-3.29.2.tgz#c21fd638d9e4923f0260a7a5cfd4a38cf7c05216" + integrity sha512-8Q39UR4/Tit759IeW9xZIe3NMwN11GsuA3FLheDyyGn7RrW02HD3HhUDlazE54Ki4HoosjFmChPlN4Ik2ubdRQ== -"@tiptap/extension-hard-break@^3.20.5": - version "3.20.5" - resolved "https://registry.yarnpkg.com/@tiptap/extension-hard-break/-/extension-hard-break-3.20.5.tgz#79a4409e81a35c9f8b664616a9b2ecbd4cb81953" - integrity sha512-+aILNDO7BsXf0IJ4/0BYh570usFK3Q1t/ZQd8zhHuO2ATeWeDVu1x2F+ouFS4X8fmoCcioMzw15aoz93GET6kQ== +"@tiptap/extension-hard-break@^3.29.2": + version "3.29.2" + resolved "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-3.29.2.tgz#334ebaa9c0d2eed7df9037524eb0f6b15732fb1d" + integrity sha512-eUW3LN3fq8rXnjEUeI3D2QONYdLsU3yYQm4jxlErs2h4cfwrFjgf19VSUFVmm6LrFbbQ0OnDVPeVLL6iOwDw2w== -"@tiptap/extension-heading@^3.20.5", "@tiptap/extension-heading@^3.27.3": - version "3.27.3" - resolved "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-3.27.3.tgz#8f3b0f0f0afd172b6879fad265d43761bc8caa4b" - integrity sha512-QHXnsNic6iId8pnsFZ8z4PkX5L+HCHa/D7rAi3nNWtPlSIAOxo4nKrALcB5/tHmY+XL8kEXKH3nsNLNEDLCYPg== +"@tiptap/extension-heading@^3.27.3", "@tiptap/extension-heading@^3.29.2": + version "3.29.2" + resolved "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-3.29.2.tgz#a7b392d7dd2cd463d9eda7556aaf0bb297795ad9" + integrity sha512-6W4aIy70Mh7BNlbG9zZ5FBLhJhU2UUEzgZJ/jwYSCcB30o8McLxJSEjhtoHiX8R78Ah2/JzBGvIe5olZlbeE4A== -"@tiptap/extension-horizontal-rule@^3.20.5": - version "3.20.5" - resolved "https://registry.yarnpkg.com/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.20.5.tgz#c21b2c7405f4aad7b507e36cc3394aba51ea2253" - integrity sha512-4UtpUHg8cRzxWjJUGtni5VnXYbhsO7ygf1H1pr4Rv63XMBg9lfYDeSwByIuVy9biEFP7eGEFnezzb5Zlh1btmQ== +"@tiptap/extension-horizontal-rule@^3.29.2": + version "3.29.2" + resolved "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.29.2.tgz#a2afe200c29f9355fa1a79c96fc73bef016bbbd2" + integrity sha512-8/ZPzbB9X85Mc9/7xVLZupQKBr2UVcQTGr512xtqMW+XkCQRHCph46tRo828YE13IMWI5fWn/FaNCqXG9cULSw== -"@tiptap/extension-italic@^3.20.5": - version "3.20.5" - resolved "https://registry.yarnpkg.com/@tiptap/extension-italic/-/extension-italic-3.20.5.tgz#c53436f05968b16eda6b8e0efbaebaf3f4587e3b" - integrity sha512-7bZCgdJVTvhR5vSmNgFQbGvgRoC6m26KcUpHqWiKA95kLL5Wk4YlMCIqdiDpvJ1eakeFEvDcGZvFLg5+1NiQ+w== +"@tiptap/extension-italic@^3.29.2": + version "3.29.2" + resolved "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.29.2.tgz#3d169fdcc34a603304f14946c6f638ddaadc9af8" + integrity sha512-iH63V/5wsaMnY4Jz0+meaAGhaec4AiOzOduzl6ZZr5IyGhZ1kthyW84ELt0dyLI3hNceAUhaNWc+I7+vX0aoXA== -"@tiptap/extension-link@^3.20.5": - version "3.20.5" - resolved "https://registry.yarnpkg.com/@tiptap/extension-link/-/extension-link-3.20.5.tgz#fbed2a1b82b0e9a73a2628782408135fbe698575" - integrity sha512-0PukrSYnHX2CrGSThlKfQWxpPWmL7QAvdpDUraKknGvVNSH7tUjchTshy5JdLrn/SQAU92REowRCB6zzCNEFjA== +"@tiptap/extension-link@^3.29.2": + version "3.29.2" + resolved "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-3.29.2.tgz#a91ff8625cc609e9ba0bfb5d5052b4168436c4f5" + integrity sha512-DcVer5SqrexKCEP6Ip1UPxJUMvcRCCItSv0wxoGytanrimBh2smvcg6X0DWnjlsi5H0updhyl+atYCmmQXUIXA== dependencies: - linkifyjs "^4.3.2" + linkifyjs "^4.3.3" -"@tiptap/extension-list-item@^3.20.5": - version "3.20.5" - resolved "https://registry.yarnpkg.com/@tiptap/extension-list-item/-/extension-list-item-3.20.5.tgz#3bbe5c8cc2a5f6ad7900803338b41a29e33409ba" - integrity sha512-pFJCGLIDEin1Xn6B3ctbrZvtYyALARE56ya4SmaNfnl+Hww5MfkRR40obbwYD3byA1yOpr+bECy+I2clQqzTDw== +"@tiptap/extension-list-item@^3.29.2": + version "3.29.2" + resolved "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-3.29.2.tgz#febddb22badf0facebf3325b915f65f4a7c8f697" + integrity sha512-s8vBVHHFT0Qpu7CzAZ7S1kYmSiVaDvUNvMNcZUWnxj6VPfiwmx0eXd9FsjePrRCMoM5tFmPnhFTHDxY3D/eZeQ== -"@tiptap/extension-list-keymap@^3.20.5": - version "3.20.5" - resolved "https://registry.yarnpkg.com/@tiptap/extension-list-keymap/-/extension-list-keymap-3.20.5.tgz#272077f1e1f55b4306583fcfa81d0208f8814a71" - integrity sha512-rmrQgOrUb0jKtFzVUfT0UNEST2sGM2Ve4lOl+1luh66RW6TD+gvgMk/qo12/Kffl9PUiqz8oYfk2qXCwFb6Bug== +"@tiptap/extension-list-keymap@^3.29.2": + version "3.29.2" + resolved "https://registry.npmjs.org/@tiptap/extension-list-keymap/-/extension-list-keymap-3.29.2.tgz#cdb29a4ce7fdff9f986df713501272d9fc643930" + integrity sha512-R+3k8OLnxdCH7Xy9ieOwUt5m2Je74u8mikothGmsYVO2Zyq48fIbmZ+X6RBPCu7DBOI2FIUhHEFbKQeDWvDNmA== -"@tiptap/extension-list@^3.20.5": - version "3.20.5" - resolved "https://registry.yarnpkg.com/@tiptap/extension-list/-/extension-list-3.20.5.tgz#98acebb38d051790e97ebabcb93327ac8ecd6909" - integrity sha512-s+Y8Q7Orq+WQiwgFB/VPMYZe+6EAR2F69xCpvOynlzTInLO4cF6QpXomuGEYAZxLHe8ZBmeIaR7y8MH/OgjrDw== +"@tiptap/extension-list@^3.29.2": + version "3.29.2" + resolved "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.29.2.tgz#53324e55f1c89efcf450b0c92f84361ced15e2ab" + integrity sha512-WPZ9BHAPT6QeIm1vdVkuoOWvy9a8/EZeJwV2VhU8LXyTAttvzyj4rsbbHyJWvYWlUSTt/QF2AZ2zhKo7u1w3/A== -"@tiptap/extension-ordered-list@^3.20.5": - version "3.20.5" - resolved "https://registry.yarnpkg.com/@tiptap/extension-ordered-list/-/extension-ordered-list-3.20.5.tgz#c5b5abff89ec2b0bd82a8c62828dc317832a0e66" - integrity sha512-Y/RIE3AxUNYAFKGMM5FLlTVKxxBvOh4JlLp/qYsOCY2nJdH0Jopl2FpfBYc4xoJwFSk8BELJ4Ow0adcYb15ksg== +"@tiptap/extension-ordered-list@^3.29.2": + version "3.29.2" + resolved "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-3.29.2.tgz#2053ddaef243e455bb1267fbe81c337487fa4771" + integrity sha512-ndCunC+UsYOpkOtL7vGnDz21UNa45WUlcO9wMT1fbuYow2QnRhsuMlCWENXI52YPPARWuQ0RDgN7q6TaxPERBg== -"@tiptap/extension-paragraph@^3.20.5": - version "3.20.5" - resolved "https://registry.yarnpkg.com/@tiptap/extension-paragraph/-/extension-paragraph-3.20.5.tgz#4344d623213bbec5a025b8c5cb751979a1f3b293" - integrity sha512-mwuhwmff67IpGfOViyRvUC14IlkpsOnB+hSExVnq5+hCntjt/Cr2Z8GGOgzHeIM2FIS0UqX9Lv/b6ttUg4+Now== +"@tiptap/extension-paragraph@^3.29.2": + version "3.29.2" + resolved "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-3.29.2.tgz#da8de494530843891c399d18ab15ea2b2f5e2ffb" + integrity sha512-7qJj5YTr11vvjNgjDN1ypOfwTovc0QOCYcit/rskeuVgnmQZOZQzC/BbyKLLG7UGnpRLemU/mEGbW9pAqjAXkQ== -"@tiptap/extension-strike@^3.20.5": - version "3.20.5" - resolved "https://registry.yarnpkg.com/@tiptap/extension-strike/-/extension-strike-3.20.5.tgz#a3689fc17ad89a23c88f11b27c7f53896caa54f3" - integrity sha512-uwhvmfS4ciGYJRLUg0AHbWsprMCwyWVWd2RXOLRm0ZQeWkvzonPXZhJvzIhIgsFkPLj/dsN5t0+LdiK4UQMnyA== +"@tiptap/extension-strike@^3.29.2": + version "3.29.2" + resolved "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.29.2.tgz#fe17b6140955e8f45c7ad32c66539891be934441" + integrity sha512-aEvLAbddUQZ+FukCreV3q4G2HfNI+odE7E9U+wbq6XsSWKyo8/pDu1muz+TFKNre4blSMOQ3JQmw5UeHDKy+fg== "@tiptap/extension-table@^3.20.5": version "3.20.5" resolved "https://registry.yarnpkg.com/@tiptap/extension-table/-/extension-table-3.20.5.tgz#bac3d76e1c5fc8a4672f1495532a934651f50ce8" integrity sha512-YvTB5OfGqjqHqutkSyywplouFvJwlsDTpZAjtAh5TzKfOan42aiVepmHVpteoQP6LH0mSjw69RndFMIYhIGmSQ== -"@tiptap/extension-text@^3.20.5": - version "3.20.5" - resolved "https://registry.yarnpkg.com/@tiptap/extension-text/-/extension-text-3.20.5.tgz#48e1cb2ee149eef7857b6a3131a32c341f572f05" - integrity sha512-DMa9g5cH2d/Gx1KXtV7txTxaa6FBqgG8glmfug+N93VMb8sEZR1Yu1az++yAep4SGGq9GWIGZCUS3H6W66et6Q== +"@tiptap/extension-text@^3.29.2": + version "3.29.2" + resolved "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.29.2.tgz#7f590043b9044bd7cbc478c65e211bc3f2538335" + integrity sha512-Ubko45JWWHe8glBt2PiGNF8hcbys/JNalFhiR7Y1X4iOOtAxAKJJxh3+eq+//NTlGuBPdWGp7zw8EEUp7anjKA== -"@tiptap/extension-underline@^3.20.5": - version "3.20.5" - resolved "https://registry.yarnpkg.com/@tiptap/extension-underline/-/extension-underline-3.20.5.tgz#97321f4405b303f9d54d2716dec6ab5bf9bc493e" - integrity sha512-HMhr5KIAqZsEhlN8RxKHr/ql1a8OvBa9fLf69IwUVFolBcDExHWUtaEV/axYVRQJvvIy2oKGJxlJWDZ4hkotHQ== +"@tiptap/extension-underline@^3.29.2": + version "3.29.2" + resolved "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-3.29.2.tgz#e3545cc020608fcb7bdde5f1136d3ee8e330cb4e" + integrity sha512-K7XwH/xS/5AIREWQ00VTEf/W5U0olp7j6wwit7cdd/8nHv6h6AGr1+iEApHKoLXWQZLfGQKzJlT9W61LAl+fHA== -"@tiptap/extensions@^3.20.5": - version "3.20.5" - resolved "https://registry.yarnpkg.com/@tiptap/extensions/-/extensions-3.20.5.tgz#d2460b110deed4a71aca4c0d37816fc8845b22ad" - integrity sha512-c4am6SznqfMnbUNSh4MvufiD7cMLdqL1BArok22uBgSWkS1sB9RVBYe8+x0jrOkk0UPEVlzDHbQ+nU+WmIyS2Q== +"@tiptap/extensions@^3.29.2": + version "3.29.2" + resolved "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.29.2.tgz#207c6ed79db1a5baec13f3fd8653b437fca2c8b6" + integrity sha512-BCz+FCAChSYtUe4BFj97HEO+nSK+J7GxbJgZG4Hg7DT/gI+hRyeNndU8efiQAx3WGdzsFi3UxRpcF1tTQM7iMQ== -"@tiptap/pm@^3.20.5", "@tiptap/pm@^3.29.2": +"@tiptap/pm@^3.29.2": version "3.29.2" resolved "https://registry.npmjs.org/@tiptap/pm/-/pm-3.29.2.tgz#de461c6f8986ef807082f467cde2d8fdf1cce4bc" integrity sha512-GCOme7xHaS+DSoaA4CDcAD3l6JyBlvZhvCyfsy2Vp6j8tEoBkZWio7soYVosmlyn7zq8/64VeFZP5s47yfG7fQ== @@ -3174,35 +3169,35 @@ "@tiptap/extension-bubble-menu" "^3.20.5" "@tiptap/extension-floating-menu" "^3.20.5" -"@tiptap/starter-kit@^3.20.5": - version "3.20.5" - resolved "https://registry.yarnpkg.com/@tiptap/starter-kit/-/starter-kit-3.20.5.tgz#67a6c7ed20b81f5746fc0552f4efc02bc6fbf684" - integrity sha512-L5E2TCGK0EiwmGIlwMsiwNTW1TLbfPF1Dsji4bSKRJnPbccZIMCB6qdId8v/Z+QGm85NVcBHeruQrDlKDddXBA== - dependencies: - "@tiptap/core" "^3.20.5" - "@tiptap/extension-blockquote" "^3.20.5" - "@tiptap/extension-bold" "^3.20.5" - "@tiptap/extension-bullet-list" "^3.20.5" - "@tiptap/extension-code" "^3.20.5" - "@tiptap/extension-code-block" "^3.20.5" - "@tiptap/extension-document" "^3.20.5" - "@tiptap/extension-dropcursor" "^3.20.5" - "@tiptap/extension-gapcursor" "^3.20.5" - "@tiptap/extension-hard-break" "^3.20.5" - "@tiptap/extension-heading" "^3.20.5" - "@tiptap/extension-horizontal-rule" "^3.20.5" - "@tiptap/extension-italic" "^3.20.5" - "@tiptap/extension-link" "^3.20.5" - "@tiptap/extension-list" "^3.20.5" - "@tiptap/extension-list-item" "^3.20.5" - "@tiptap/extension-list-keymap" "^3.20.5" - "@tiptap/extension-ordered-list" "^3.20.5" - "@tiptap/extension-paragraph" "^3.20.5" - "@tiptap/extension-strike" "^3.20.5" - "@tiptap/extension-text" "^3.20.5" - "@tiptap/extension-underline" "^3.20.5" - "@tiptap/extensions" "^3.20.5" - "@tiptap/pm" "^3.20.5" +"@tiptap/starter-kit@^3.29.2": + version "3.29.2" + resolved "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-3.29.2.tgz#9e61bdfc628923e9c1cd5a6ed80a1d884ae1855c" + integrity sha512-oTu0tysiqk4zgjEtxRHjAQgxUKaAevZwueOWwSWubHdokqp7SpcbE5n9USJv89HKuTUDm3GjnQH6q8HNn/2DsA== + dependencies: + "@tiptap/core" "^3.29.2" + "@tiptap/extension-blockquote" "^3.29.2" + "@tiptap/extension-bold" "^3.29.2" + "@tiptap/extension-bullet-list" "^3.29.2" + "@tiptap/extension-code" "^3.29.2" + "@tiptap/extension-code-block" "^3.29.2" + "@tiptap/extension-document" "^3.29.2" + "@tiptap/extension-dropcursor" "^3.29.2" + "@tiptap/extension-gapcursor" "^3.29.2" + "@tiptap/extension-hard-break" "^3.29.2" + "@tiptap/extension-heading" "^3.29.2" + "@tiptap/extension-horizontal-rule" "^3.29.2" + "@tiptap/extension-italic" "^3.29.2" + "@tiptap/extension-link" "^3.29.2" + "@tiptap/extension-list" "^3.29.2" + "@tiptap/extension-list-item" "^3.29.2" + "@tiptap/extension-list-keymap" "^3.29.2" + "@tiptap/extension-ordered-list" "^3.29.2" + "@tiptap/extension-paragraph" "^3.29.2" + "@tiptap/extension-strike" "^3.29.2" + "@tiptap/extension-text" "^3.29.2" + "@tiptap/extension-underline" "^3.29.2" + "@tiptap/extensions" "^3.29.2" + "@tiptap/pm" "^3.29.2" "@tybys/wasm-util@^0.10.0": version "0.10.1" @@ -6734,10 +6729,10 @@ lines-and-columns@^1.1.6: resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz#eca284f75d2965079309dc0ad9255abb2ebc1632" integrity sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg== -linkifyjs@^4.3.2: - version "4.3.2" - resolved "https://registry.yarnpkg.com/linkifyjs/-/linkifyjs-4.3.2.tgz#d97eb45419aabf97ceb4b05a7adeb7b8c8ade2b1" - integrity sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA== +linkifyjs@^4.3.3: + version "4.3.3" + resolved "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.3.tgz#da08f0eeb4d89a24541d09591fbdcc211eb8fef0" + integrity sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg== locate-path@^6.0.0: version "6.0.0" From e2d19f38f8699651b345e26bd21627e39e0afcbf Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 11 Aug 2026 04:15:06 +0000 Subject: [PATCH 025/226] chore(deps-dev): bump storybook from 10.3.5 to 10.5.7 in /frontend Bumps [storybook](https://github.com/storybookjs/storybook/tree/HEAD/code/core) from 10.3.5 to 10.5.7. - [Release notes](https://github.com/storybookjs/storybook/releases) - [Changelog](https://github.com/storybookjs/storybook/blob/next/CHANGELOG.md) - [Commits](https://github.com/storybookjs/storybook/commits/v10.5.7/code/core) --- updated-dependencies: - dependency-name: storybook dependency-version: 10.5.7 dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- frontend/package.json | 2 +- frontend/yarn.lock | 855 +++++++++++++++++++++++++----------------- 2 files changed, 503 insertions(+), 354 deletions(-) diff --git a/frontend/package.json b/frontend/package.json index f5e3782ce8..9b3102e332 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -140,7 +140,7 @@ "msw-storybook-addon": "3.0.0", "playwright": "1.59.1", "prettier": "^3.9.6", - "storybook": "10.3.5", + "storybook": "10.5.7", "typescript": "5.9.3", "vite": "7.3.6", "vitest": "4.1.10" diff --git a/frontend/yarn.lock b/frontend/yarn.lock index cdccb617fe..7603ec47db 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -1102,6 +1102,22 @@ resolved "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz#798a33950d11226a0ebb6acafa60f5594424967f" integrity sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA== +"@emnapi/core@1.11.2": + version "1.11.2" + resolved "https://registry.npmjs.org/@emnapi/core/-/core-1.11.2.tgz#fab0a0f3c492d11f5a9ac9065d0d73955ee1c1c9" + integrity sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA== + dependencies: + "@emnapi/wasi-threads" "1.2.2" + tslib "^2.4.0" + +"@emnapi/core@1.9.2": + version "1.9.2" + resolved "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz#3870265ecffc7352d01ead62d8d83d8358a2d034" + integrity sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA== + dependencies: + "@emnapi/wasi-threads" "1.2.1" + tslib "^2.4.0" + "@emnapi/core@^1.4.3": version "1.9.1" resolved "https://registry.yarnpkg.com/@emnapi/core/-/core-1.9.1.tgz#2143069c744ca2442074f8078462e51edd63c7bd" @@ -1110,6 +1126,20 @@ "@emnapi/wasi-threads" "1.2.0" tslib "^2.4.0" +"@emnapi/runtime@1.11.2": + version "1.11.2" + resolved "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz#eb22f04d76febfdf4f87fdaff54c8a53f6bf0dbd" + integrity sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA== + dependencies: + tslib "^2.4.0" + +"@emnapi/runtime@1.9.2": + version "1.9.2" + resolved "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz#8b469a3db160817cadb1de9050211a9d1ea84fa2" + integrity sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw== + dependencies: + tslib "^2.4.0" + "@emnapi/runtime@^1.4.3", "@emnapi/runtime@^1.7.0": version "1.9.1" resolved "https://registry.yarnpkg.com/@emnapi/runtime/-/runtime-1.9.1.tgz#115ff2a0d589865be6bd8e9d701e499c473f2a8d" @@ -1124,6 +1154,20 @@ dependencies: tslib "^2.4.0" +"@emnapi/wasi-threads@1.2.1": + version "1.2.1" + resolved "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz#28fed21a1ba1ce797c44a070abc94d42f3ae8548" + integrity sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w== + dependencies: + tslib "^2.4.0" + +"@emnapi/wasi-threads@1.2.2": + version "1.2.2" + resolved "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz#4c93becf5bfa3b13d1bbdcc06aee38321ad8139a" + integrity sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA== + dependencies: + tslib "^2.4.0" + "@emotion/babel-plugin@^11.13.5": version "11.13.5" resolved "https://registry.yarnpkg.com/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz#eab8d65dbded74e0ecfd28dc218e75607c4e7bc0" @@ -1241,265 +1285,135 @@ resolved "https://registry.yarnpkg.com/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz#5e13fac887f08c44f76b0ccaf3370eb00fec9bb6" integrity sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg== -"@esbuild/aix-ppc64@0.27.7": - version "0.27.7" - resolved "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz#82b74f92aa78d720b714162939fb248c90addf53" - integrity sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg== - -"@esbuild/aix-ppc64@0.28.1": - version "0.28.1" - resolved "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz#7a01a8d2ec2fbb2dac78adad09b0fa781e4082be" - integrity sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ== - -"@esbuild/android-arm64@0.27.7": - version "0.27.7" - resolved "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz#f78cb8a3121fc205a53285adb24972db385d185d" - integrity sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ== - -"@esbuild/android-arm64@0.28.1": - version "0.28.1" - resolved "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz#b540a27d14e4afd058496a4dbec4d3f414db110a" - integrity sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg== - -"@esbuild/android-arm@0.27.7": - version "0.27.7" - resolved "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz#593e10a1450bbfcac6cb321f61f468453bac209d" - integrity sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ== - -"@esbuild/android-arm@0.28.1": - version "0.28.1" - resolved "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz#704bd297de6d762de54eabbeafbf55f6756abe2f" - integrity sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ== - -"@esbuild/android-x64@0.27.7": - version "0.27.7" - resolved "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz#453143d073326033d2d22caf9e48de4bae274b07" - integrity sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg== - -"@esbuild/android-x64@0.28.1": - version "0.28.1" - resolved "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz#d1cb166d34b0fbf0fe8ab460a5594f24a378701e" - integrity sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng== - -"@esbuild/darwin-arm64@0.27.7": - version "0.27.7" - resolved "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz#6f23000fb9b40b7e04b7d0606c0693bd0632f322" - integrity sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw== - -"@esbuild/darwin-arm64@0.28.1": - version "0.28.1" - resolved "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz#1034b26457fc886368fe61bbd09f653f6afa8e54" - integrity sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q== - -"@esbuild/darwin-x64@0.27.7": - version "0.27.7" - resolved "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz#27393dd18bb1263c663979c5f1576e00c2d024be" - integrity sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ== - -"@esbuild/darwin-x64@0.28.1": - version "0.28.1" - resolved "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz#65556a432a1e4d72032d8218c1932fcca1a49772" - integrity sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ== - -"@esbuild/freebsd-arm64@0.27.7": - version "0.27.7" - resolved "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz#22e4638fa502d1c0027077324c97640e3adf3a62" - integrity sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w== - -"@esbuild/freebsd-arm64@0.28.1": - version "0.28.1" - resolved "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz#2e61e0592f9030d7e3dae18ee25ebc535918aef6" - integrity sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw== - -"@esbuild/freebsd-x64@0.27.7": - version "0.27.7" - resolved "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz#9224b8e4fea924ce2194e3efc3e9aebf822192d6" - integrity sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ== - -"@esbuild/freebsd-x64@0.28.1": - version "0.28.1" - resolved "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz#c95ec289959ef8079c4dca817a1e2c4be66b9bd3" - integrity sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ== - -"@esbuild/linux-arm64@0.27.7": - version "0.27.7" - resolved "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz#4f5d1c27527d817b35684ae21419e57c2bda0966" - integrity sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A== - -"@esbuild/linux-arm64@0.28.1": - version "0.28.1" - resolved "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz#40b22175dda06182f3ee8141186c5ff304c4a717" - integrity sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g== - -"@esbuild/linux-arm@0.27.7": - version "0.27.7" - resolved "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz#b9e9d070c8c1c0449cf12b20eac37d70a4595921" - integrity sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA== - -"@esbuild/linux-arm@0.28.1": - version "0.28.1" - resolved "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz#c09a0f67917592ac0de892a9be4d3814debd2a6c" - integrity sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ== - -"@esbuild/linux-ia32@0.27.7": - version "0.27.7" - resolved "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz#3f80fb696aa96051a94047f35c85b08b21c36f9e" - integrity sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg== - -"@esbuild/linux-ia32@0.28.1": - version "0.28.1" - resolved "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz#a580f9c676797833891e519fc7a1337c8afd8db3" - integrity sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w== - -"@esbuild/linux-loong64@0.27.7": - version "0.27.7" - resolved "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz#9be1f2c28210b13ebb4156221bba356fe1675205" - integrity sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q== - -"@esbuild/linux-loong64@0.28.1": - version "0.28.1" - resolved "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz#46452cf321dc7f9e91c2fa780a56bb56e79cd68b" - integrity sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg== - -"@esbuild/linux-mips64el@0.27.7": - version "0.27.7" - resolved "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz#4ab5ee67a3dfcbcb5e8fd7883dae6e735b1163b8" - integrity sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw== - -"@esbuild/linux-mips64el@0.28.1": - version "0.28.1" - resolved "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz#4211b3184dd6608f53dcb22e39f5d34ee08852c8" - integrity sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ== - -"@esbuild/linux-ppc64@0.27.7": - version "0.27.7" - resolved "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz#dac78c689f6499459c4321e5c15032c12307e7ea" - integrity sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ== - -"@esbuild/linux-ppc64@0.28.1": - version "0.28.1" - resolved "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz#697857c2a61cb9b0b6bb6652e40c1dc5e1ca8e5d" - integrity sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ== - -"@esbuild/linux-riscv64@0.27.7": - version "0.27.7" - resolved "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz#050f7d3b355c3a98308e935bc4d6325da91b0027" - integrity sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ== - -"@esbuild/linux-riscv64@0.28.1": - version "0.28.1" - resolved "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz#d192943eb146a40ac4c6497d0cf7be35b986bf08" - integrity sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ== - -"@esbuild/linux-s390x@0.27.7": - version "0.27.7" - resolved "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz#d61f715ce61d43fe5844ad0d8f463f88cbe4fef6" - integrity sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw== - -"@esbuild/linux-s390x@0.28.1": - version "0.28.1" - resolved "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz#acea0356da0e0ebc08f97cf7b9c2e401e1e648dc" - integrity sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag== - -"@esbuild/linux-x64@0.27.7": - version "0.27.7" - resolved "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz#ca8e1aa478fc8209257bf3ac8f79c4dc2982f32a" - integrity sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA== - -"@esbuild/linux-x64@0.28.1": - version "0.28.1" - resolved "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz#6f0c3ce0cb64c534b70c4c45ecb2c16d34e35dfd" - integrity sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA== - -"@esbuild/netbsd-arm64@0.27.7": - version "0.27.7" - resolved "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz#1650f2c1b948deeb3ef948f2fc30614723c09690" - integrity sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w== - -"@esbuild/netbsd-arm64@0.28.1": - version "0.28.1" - resolved "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz#8bcd77077a0dce3378b574fedb26d2a253b73d36" - integrity sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw== - -"@esbuild/netbsd-x64@0.27.7": - version "0.27.7" - resolved "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz#65772ab342c4b3319bf0705a211050aac1b6e320" - integrity sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw== - -"@esbuild/netbsd-x64@0.28.1": - version "0.28.1" - resolved "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz#e7fb2a01e99c830c94e6623cd9fefb4c8fb58347" - integrity sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg== - -"@esbuild/openbsd-arm64@0.27.7": - version "0.27.7" - resolved "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz#37ed7cfa66549d7955852fce37d0c3de4e715ea1" - integrity sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A== - -"@esbuild/openbsd-arm64@0.28.1": - version "0.28.1" - resolved "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz#c52909372db8b86e2c55e05a8940033b5660a3b2" - integrity sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q== - -"@esbuild/openbsd-x64@0.27.7": - version "0.27.7" - resolved "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz#01bf3d385855ef50cb33db7c4b52f957c34cd179" - integrity sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg== - -"@esbuild/openbsd-x64@0.28.1": - version "0.28.1" - resolved "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz#c427b9be5a64c262ff9a7eb70b5fbbaadf446c6c" - integrity sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw== - -"@esbuild/openharmony-arm64@0.27.7": - version "0.27.7" - resolved "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz#6c1f94b34086599aabda4eac8f638294b9877410" - integrity sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw== - -"@esbuild/openharmony-arm64@0.28.1": - version "0.28.1" - resolved "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz#dc9b147baca2e6c4b3c85571741ef4860a489097" - integrity sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg== - -"@esbuild/sunos-x64@0.27.7": - version "0.27.7" - resolved "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz#4b0dd17ae0a6941d2d0fd35a906392517071a90d" - integrity sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA== - -"@esbuild/sunos-x64@0.28.1": - version "0.28.1" - resolved "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz#ce866d12df13c15e4c99f073a3d466f6e0649b3a" - integrity sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ== - -"@esbuild/win32-arm64@0.27.7": - version "0.27.7" - resolved "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz#34193ab5565d6ff68ca928ac04be75102ccb2e77" - integrity sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA== - -"@esbuild/win32-arm64@0.28.1": - version "0.28.1" - resolved "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz#7468e3692d01d629d5941e5d83817bb80f9e39b4" - integrity sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA== - -"@esbuild/win32-ia32@0.27.7": - version "0.27.7" - resolved "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz#eb67f0e4482515d8c1894ede631c327a4da9fc4d" - integrity sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw== - -"@esbuild/win32-ia32@0.28.1": - version "0.28.1" - resolved "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz#a5bc0063fb2bcab6d0ed63f2a1537958bc269ec6" - integrity sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg== - -"@esbuild/win32-x64@0.27.7": - version "0.27.7" - resolved "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz#8fe30b3088b89b4873c3a6cc87597ae3920c0a8b" - integrity sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg== - -"@esbuild/win32-x64@0.28.1": - version "0.28.1" - resolved "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz#10064ee44f4347b90c9a02b446bbf80a91632b12" - integrity sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A== +"@esbuild/aix-ppc64@0.28.2": + version "0.28.2" + resolved "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz#bf6e10303bcf2e7c686975fa52f937ec2728d8bc" + integrity sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ== + +"@esbuild/android-arm64@0.28.2": + version "0.28.2" + resolved "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz#0c6246bc8d2c4d172aac2db3fb1190d72bd65504" + integrity sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A== + +"@esbuild/android-arm@0.28.2": + version "0.28.2" + resolved "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz#2d84ece6a4e2684d92be26ee13d42757d831c381" + integrity sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg== + +"@esbuild/android-x64@0.28.2": + version "0.28.2" + resolved "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz#fc38d4d6358d8dc1cf53f09f7589fe436eb64801" + integrity sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q== + +"@esbuild/darwin-arm64@0.28.2": + version "0.28.2" + resolved "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz#f83afeeac1d7dac01c7a2fd012b3e451a0591fcc" + integrity sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw== + +"@esbuild/darwin-x64@0.28.2": + version "0.28.2" + resolved "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz#510147c055a795588dbbe14fd6b1b8ad0a2f30de" + integrity sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw== + +"@esbuild/freebsd-arm64@0.28.2": + version "0.28.2" + resolved "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz#093b9200ecf0b115ba4e5e248a7485c9c5f8bd5e" + integrity sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw== + +"@esbuild/freebsd-x64@0.28.2": + version "0.28.2" + resolved "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz#0be22b6df925d213e841ea87123af5df80b0faf7" + integrity sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg== + +"@esbuild/linux-arm64@0.28.2": + version "0.28.2" + resolved "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz#1bdbc651cda9ba9995c53ed9c71ceaa65094762d" + integrity sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug== + +"@esbuild/linux-arm@0.28.2": + version "0.28.2" + resolved "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz#beb12ad72b84f72d28488cc1b8ee9f7eb141d753" + integrity sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w== + +"@esbuild/linux-ia32@0.28.2": + version "0.28.2" + resolved "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz#b81f9d55529b45c206a46a138214b1aa6879696b" + integrity sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ== + +"@esbuild/linux-loong64@0.28.2": + version "0.28.2" + resolved "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz#598667241a04c99b76ed6ef940ac50038c419f98" + integrity sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ== + +"@esbuild/linux-mips64el@0.28.2": + version "0.28.2" + resolved "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz#1c51eb9cea903f53d97b5af3b1841db70f5596ca" + integrity sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA== + +"@esbuild/linux-ppc64@0.28.2": + version "0.28.2" + resolved "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz#63dd61f17ceb31a81227f413feac8a71bc2c51f2" + integrity sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ== + +"@esbuild/linux-riscv64@0.28.2": + version "0.28.2" + resolved "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz#3763b08fde5cf25ab1facb8e7752edfe45fbfc27" + integrity sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA== + +"@esbuild/linux-s390x@0.28.2": + version "0.28.2" + resolved "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz#1a137ff293a82906eb3176385bd7e8e0e5cfb7cb" + integrity sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg== + +"@esbuild/linux-x64@0.28.2": + version "0.28.2" + resolved "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz#268b36211c146ca54f8fe12c578a8d6ef8979485" + integrity sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ== + +"@esbuild/netbsd-arm64@0.28.2": + version "0.28.2" + resolved "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz#22571ad951d62bb6accc82d8d1fad5c8c1ac0ba1" + integrity sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw== + +"@esbuild/netbsd-x64@0.28.2": + version "0.28.2" + resolved "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz#42fcc57297eb0a0ca3f5fc475291f4c1a3f7c0de" + integrity sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw== + +"@esbuild/openbsd-arm64@0.28.2": + version "0.28.2" + resolved "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz#9eb32af104ac3dacf4edca01f596664aab0c73ef" + integrity sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ== + +"@esbuild/openbsd-x64@0.28.2": + version "0.28.2" + resolved "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz#febed2402d6088225e91f20fb4ce2522ad0a4efd" + integrity sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw== + +"@esbuild/openharmony-arm64@0.28.2": + version "0.28.2" + resolved "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz#85641c3d466428bfbccea5f21c26836663fef5ce" + integrity sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q== + +"@esbuild/sunos-x64@0.28.2": + version "0.28.2" + resolved "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz#a736f9d8962481045fc4c3e54f5479f22c870fb4" + integrity sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g== + +"@esbuild/win32-arm64@0.28.2": + version "0.28.2" + resolved "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz#ee5ab40fad186201b652a33f8a5eb149e9e42532" + integrity sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ== + +"@esbuild/win32-ia32@0.28.2": + version "0.28.2" + resolved "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz#c40d28a6d99a127da6711f2afd74b11cb63b06a7" + integrity sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA== + +"@esbuild/win32-x64@0.28.2": + version "0.28.2" + resolved "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz#b21affb804cc167c133d95f45b3a1dc1323b9a87" + integrity sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g== "@eslint-community/eslint-utils@^4.8.0", "@eslint-community/eslint-utils@^4.9.1": version "4.9.1" @@ -2074,6 +1988,13 @@ "@emnapi/runtime" "^1.4.3" "@tybys/wasm-util" "^0.10.0" +"@napi-rs/wasm-runtime@^1.1.4", "@napi-rs/wasm-runtime@^1.1.6": + version "1.2.2" + resolved "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.2.tgz#c70706532e5827c0932ca6bf43ee2c512f29c639" + integrity sha512-JfB4kuJQjaoHuCTseIINHtHWeJnvgEcxjwA5t/Y00ZgaOO1Crz3fjT/p8kT28zA/Caz7oiUMn3d6H2yOVCVwuw== + dependencies: + "@tybys/wasm-util" "^0.10.3" + "@next/env@16.2.11": version "16.2.11" resolved "https://registry.npmjs.org/@next/env/-/env-16.2.11.tgz#9dea1a225a99b1636e5a7166237db1f979b6c532" @@ -2270,6 +2191,214 @@ resolved "https://registry.npmjs.org/@open-draft/until/-/until-2.1.0.tgz#0acf32f470af2ceaf47f095cdecd40d68666efda" integrity sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg== +"@oxc-parser/binding-android-arm-eabi@0.127.0": + version "0.127.0" + resolved "https://registry.npmjs.org/@oxc-parser/binding-android-arm-eabi/-/binding-android-arm-eabi-0.127.0.tgz#b75e796249ee22f632e40e942746c4bf648cee92" + integrity sha512-0LC7ye4hvqbIKxAzThzvswgHLFu2AURKzYLeSVvLdu2TBOYWQDmHnTqPLeA597BcUCxiLqLsS4CJ5uoI5WYWCQ== + +"@oxc-parser/binding-android-arm64@0.127.0": + version "0.127.0" + resolved "https://registry.npmjs.org/@oxc-parser/binding-android-arm64/-/binding-android-arm64-0.127.0.tgz#e264467fe39f80018f62fa0dae82db0b80260444" + integrity sha512-b5jtVTH6AU5CJXHNdj7Jj9IEiR9yVjjnwHzPJhGyHGPdcsZSzBCkS9GBbV33niRMvKthDwQRFRJfI4a+k4PvYg== + +"@oxc-parser/binding-darwin-arm64@0.127.0": + version "0.127.0" + resolved "https://registry.npmjs.org/@oxc-parser/binding-darwin-arm64/-/binding-darwin-arm64-0.127.0.tgz#0576d35109c00dcc6277200ba2eca7b47e07f1b1" + integrity sha512-obCE8B7ISKkJidjlhv9xRGJPOSDG2Yu6PRga9Ruaz35uintHxbp1Ki/Yc71wx4rj3Edrm0a1kzG1TAwit0wFpg== + +"@oxc-parser/binding-darwin-x64@0.127.0": + version "0.127.0" + resolved "https://registry.npmjs.org/@oxc-parser/binding-darwin-x64/-/binding-darwin-x64-0.127.0.tgz#efa1ba49075aa318ff540a1c2f8a442017417206" + integrity sha512-JL6Xb5IwPQT8rUzlpsX7E+AgfcdNklXNPFp8pjCQQ5MQOQo5rtEB2ui+3Hgg9Sn7Y9Egj6YOLLiHhLpdAe12Aw== + +"@oxc-parser/binding-freebsd-x64@0.127.0": + version "0.127.0" + resolved "https://registry.npmjs.org/@oxc-parser/binding-freebsd-x64/-/binding-freebsd-x64-0.127.0.tgz#817ba3c508d751d94d6e6fd86af69ddaa27da531" + integrity sha512-SDQ/3MQFw58fqQz3Z1PhSKFF3JoCF4gmlNjziDm8X02tTahCw0qJbd7FGPDKw1i4VTBZene9JPyC3mHtSvi+wA== + +"@oxc-parser/binding-linux-arm-gnueabihf@0.127.0": + version "0.127.0" + resolved "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.127.0.tgz#b1c3096c654771998480316ef10d1e5d29edc79b" + integrity sha512-Av+D1MIqzV0YMGPT9we2SIZaMKD7Cxs4CvXSx/yxaWHewZjYEjScpOf5igc8IILASViw4WTnjlwUdI1KzVtDHQ== + +"@oxc-parser/binding-linux-arm-musleabihf@0.127.0": + version "0.127.0" + resolved "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.127.0.tgz#c44a8f10e6c903685825aebf1289fc2086aed61e" + integrity sha512-Cs2fdJ8cPpFdeebj6p4dag8A4+56hPvZ0AhQQzlaLswGz1tz7bXt1nETLeorrM9+AMcWFFkqxcXwDGfTVidY8g== + +"@oxc-parser/binding-linux-arm64-gnu@0.127.0": + version "0.127.0" + resolved "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.127.0.tgz#61c245abfab6f63045915b5c9cfa7d335ad7c440" + integrity sha512-qdOfTcT6SY8gsJrrV92uyEUyjqMGPpIB5JZUG6QN5dukYd+7/j0kX6MwK1DgQj39jtUYixxPiaRUiEN1+0CXgQ== + +"@oxc-parser/binding-linux-arm64-musl@0.127.0": + version "0.127.0" + resolved "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.127.0.tgz#358bbd90e5c85b6c35125f5a6ff084e09b694c04" + integrity sha512-EoTCZneNFU/P2qrpEM+RHmQwt+CvDkyGESG6qhr7KaegXLZwePfbrkCDfAk8/rhxbDUVGsZILX+2tqPzFtoFWA== + +"@oxc-parser/binding-linux-ppc64-gnu@0.127.0": + version "0.127.0" + resolved "https://registry.npmjs.org/@oxc-parser/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.127.0.tgz#b7ea7b51bf54db4c42819187f760e069d433dac3" + integrity sha512-zALjmZYgxFLHjXeudcDF0xFGNydTAtkAeXAr2EuC17ywCyFxcmQra4w0BMde0Yi/re4Bi4iwEoEXtYN7l6eBLQ== + +"@oxc-parser/binding-linux-riscv64-gnu@0.127.0": + version "0.127.0" + resolved "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.127.0.tgz#3a3b10d160988df50bbbcd631c6af39de3dd451d" + integrity sha512-fPP8M6zQLS7Jz7o9d5ArUSuAuSK3e+WCYVrCpdzeCOejidtZExJ9tjhDrAd3HEPqARBCPmdpqxESPFqy44vkBQ== + +"@oxc-parser/binding-linux-riscv64-musl@0.127.0": + version "0.127.0" + resolved "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.127.0.tgz#3787d37e1d0a15ee239f51610298500321b31730" + integrity sha512-7IcC4Ao02oGpfnjt+X/oF4U2mllo2qoSkw5xxiXNKL9MCTsTiAC6616beOuehdxGcnz1bRoPC1RQ2f1GQDdN+g== + +"@oxc-parser/binding-linux-s390x-gnu@0.127.0": + version "0.127.0" + resolved "https://registry.npmjs.org/@oxc-parser/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.127.0.tgz#b71a16cbba115a4696498f9149bc54cc4e1df9cd" + integrity sha512-pbXIhiNFHoqWeqDNLiJ9JkpHz1IM9k4DXa66x+1GTWMG7iLxtkXgE53iiuKSXwmk3zIYmaPVfBvgcAhS583K4Q== + +"@oxc-parser/binding-linux-x64-gnu@0.127.0": + version "0.127.0" + resolved "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.127.0.tgz#71527dd0284ba727d35a93c841c91192af3ebdec" + integrity sha512-MYCguB9RvBvlSd6gbuNI7QwiLoCCAlGnlRJFPrzLI6U1/9wkC/WK6LtBAUln55H1Ctqw45PWmqrobKoMhsYQzQ== + +"@oxc-parser/binding-linux-x64-musl@0.127.0": + version "0.127.0" + resolved "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-musl/-/binding-linux-x64-musl-0.127.0.tgz#16830afa4b001f349cebb93e12b278e72601cb3f" + integrity sha512-5eY0B/bxf1xIUxb4NOTvOI3KWtBQfPWYyKAzgcrCt0mDibSZygVpO1Pz8bkeiSZ5Jj9+M09dkggG3H8I5d0Uyg== + +"@oxc-parser/binding-openharmony-arm64@0.127.0": + version "0.127.0" + resolved "https://registry.npmjs.org/@oxc-parser/binding-openharmony-arm64/-/binding-openharmony-arm64-0.127.0.tgz#a41c71d249cb597dc357038eb1cbe3ce732453f8" + integrity sha512-Gld0ajrFTUXNtdw20fVBuTQx66FA75nIVg+//pPfR3sXkuABB4mTBhl3r9JNzrJpgW//qiwxf0nWXUWGJSL3UQ== + +"@oxc-parser/binding-wasm32-wasi@0.127.0": + version "0.127.0" + resolved "https://registry.npmjs.org/@oxc-parser/binding-wasm32-wasi/-/binding-wasm32-wasi-0.127.0.tgz#b1efcdb433b30ed4a3ad912fa03da3834bd4845d" + integrity sha512-T6KVD7rhLzFlwGRXMnxUFfkCZD8FHnb968wVXW1mXzgRFc5RNXOBY2mPPDZ77x5Ln76ltLMgtPg0cOkU1NSrEQ== + dependencies: + "@emnapi/core" "1.9.2" + "@emnapi/runtime" "1.9.2" + "@napi-rs/wasm-runtime" "^1.1.4" + +"@oxc-parser/binding-win32-arm64-msvc@0.127.0": + version "0.127.0" + resolved "https://registry.npmjs.org/@oxc-parser/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.127.0.tgz#b62b5e328126323d41ae1ee7adc95537c4c4423a" + integrity sha512-Ujvw4X+LD1CCGULcsQcvb4YNVoBGqt+JHgNNzGGaCImELiZLk477ifUH53gIbE7EKd933NdTi25JWEr9K2HwXw== + +"@oxc-parser/binding-win32-ia32-msvc@0.127.0": + version "0.127.0" + resolved "https://registry.npmjs.org/@oxc-parser/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.127.0.tgz#dac30de6971dbe63aa5722be9a4cc070fd3c650e" + integrity sha512-0cwxKO7KHQQQfo4Uf4B2SQrhgm+cJaP9OvFFhx52Tkg4bezsacu83GB2/In5bC415Ueeym+kXdnge/57rbSfTw== + +"@oxc-parser/binding-win32-x64-msvc@0.127.0": + version "0.127.0" + resolved "https://registry.npmjs.org/@oxc-parser/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.127.0.tgz#a2df879b0803f72b350a7567365cee5b8978edf0" + integrity sha512-rOrnSQSCbhI2kowr9XxE7m9a8oQXnBHjnS6j95LxxAnEZ0+Fz20WlRXG4ondQb+ejjt2KOsa65sE6++L6kUd+w== + +"@oxc-project/types@^0.127.0": + version "0.127.0" + resolved "https://registry.npmjs.org/@oxc-project/types/-/types-0.127.0.tgz#8374fcdfb4a641861218daa5700c447c00b66663" + integrity sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ== + +"@oxc-resolver/binding-android-arm-eabi@11.24.2": + version "11.24.2" + resolved "https://registry.npmjs.org/@oxc-resolver/binding-android-arm-eabi/-/binding-android-arm-eabi-11.24.2.tgz#5db3f0dcd659e1de664fb0ae912420839348309b" + integrity sha512-y09e0L0SRI2OA2tUIrjBgoV3eH5hvUKXNkJqXmNo5V2WxIjyC7I7aJfRLMEVpA8yi95f90gFDvO0VMgrDw+vwA== + +"@oxc-resolver/binding-android-arm64@11.24.2": + version "11.24.2" + resolved "https://registry.npmjs.org/@oxc-resolver/binding-android-arm64/-/binding-android-arm64-11.24.2.tgz#fec00a8bc89afa9a164bad79b23c14b8c86f95cf" + integrity sha512-cl4icWaZFnLdg8m6qtnh5rBMuGbxc/ptStFHLeCNwr+2cZjkjNwQu/jYRS0CHlnPecOJMpuS5M6/BH+0J/YkEg== + +"@oxc-resolver/binding-darwin-arm64@11.24.2": + version "11.24.2" + resolved "https://registry.npmjs.org/@oxc-resolver/binding-darwin-arm64/-/binding-darwin-arm64-11.24.2.tgz#b5e6e2c2bed585cbfd67b6e41eea7fce2a3da5f8" + integrity sha512-At29QEMF6HajbQvgY8K6OXnHD1x9rad74xBEfmCB6ZqCGsdq75aK7tOYcTbOanMy8qdIBrfL3SMr3p/lfSlb9w== + +"@oxc-resolver/binding-darwin-x64@11.24.2": + version "11.24.2" + resolved "https://registry.npmjs.org/@oxc-resolver/binding-darwin-x64/-/binding-darwin-x64-11.24.2.tgz#db759d6fadac262a7da21b1bfb712b0199c9cd18" + integrity sha512-A5Kqr1EUj4oIL5CF4WRssq/o5P0Y11cwoFouMRmQ7YnC/A8V93nv1nb7aSU8HwcgmXropjLNkVTl4MN87cu28Q== + +"@oxc-resolver/binding-freebsd-x64@11.24.2": + version "11.24.2" + resolved "https://registry.npmjs.org/@oxc-resolver/binding-freebsd-x64/-/binding-freebsd-x64-11.24.2.tgz#7fe0ab0725284aee9b6b6c45b81f0facf895fc62" + integrity sha512-R5xkRBRRz7ceH/P5Jrc6G7FmdUdgpLYyESFAUDVTNQ9K0sGPxcp4ljiwEwEqsvNcQ4sYbMRrWcHHBCu7ksAJVw== + +"@oxc-resolver/binding-linux-arm-gnueabihf@11.24.2": + version "11.24.2" + resolved "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-11.24.2.tgz#91a72b7987930c3acc5337237454ba0339f80333" + integrity sha512-k/RuYL4L/R58IBn3wT5ma3Wh4k62bp1eYCFRWCmMsasUOqL+H6sW0VGFadEzKWXFFlz+2uIMoeMk9ySSZJHgbg== + +"@oxc-resolver/binding-linux-arm-musleabihf@11.24.2": + version "11.24.2" + resolved "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-11.24.2.tgz#72d04ad7d2227fb3ac71aa7933794bfb88194b7b" + integrity sha512-bnHAak3ujYfH5pKk4NieFNbvYvernfoQDgwLddbZ3OtMYrem87/qjlA+u+aKG0oZcqSLGCful/6/CEA+aeAgaA== + +"@oxc-resolver/binding-linux-arm64-gnu@11.24.2": + version "11.24.2" + resolved "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-11.24.2.tgz#b87faf59bde9ecff0b8288fe99a831eb7492f99d" + integrity sha512-vDT3KHgzYp47gmtNOqL2VNhCyl5Zv643eyxm//A68J8DeUGXrvD1pZFiaT4jSfe+RInfnn1R2yVHye4enx6RnA== + +"@oxc-resolver/binding-linux-arm64-musl@11.24.2": + version "11.24.2" + resolved "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm64-musl/-/binding-linux-arm64-musl-11.24.2.tgz#2da6551c561bf2f2bed34c312a99e18d184a9f07" + integrity sha512-+kMlQvbzfyEYtu5FcjE4p+ttBLpKW4d/AsAsuE69BxV6V4twZJeIQZFfD8gh/wqglY0MkPSezWXQH0jBV13MUw== + +"@oxc-resolver/binding-linux-ppc64-gnu@11.24.2": + version "11.24.2" + resolved "https://registry.npmjs.org/@oxc-resolver/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-11.24.2.tgz#fda4558cc94e43fefdfa4f9b96391c30ec137adb" + integrity sha512-shjfMhmZ3gq9fv/w7bi3PnZlgOPG+2QAOFf0BJF0EgBSIGZ6PMLN2zbGEblTUYB/NKVDRyYhE2ff3dJ1QqNPkA== + +"@oxc-resolver/binding-linux-riscv64-gnu@11.24.2": + version "11.24.2" + resolved "https://registry.npmjs.org/@oxc-resolver/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-11.24.2.tgz#2fe243a5112d221021a8b2fccd7b36aa96adc946" + integrity sha512-zGelwFR5oRo+b69k8Lrzun86DyUHzfKN6cnjbR9l7Z7NIRznOE/2ZvPa1IUKqAL2PzAXOdwkfVqNvO1H2RlpAw== + +"@oxc-resolver/binding-linux-riscv64-musl@11.24.2": + version "11.24.2" + resolved "https://registry.npmjs.org/@oxc-resolver/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-11.24.2.tgz#e95cb43856f7e9c4aa0afa438e043fdbf6c6d40d" + integrity sha512-qxZ1SWCXJY0eyhAlP6Lmo9F2Nrtx7EkYj9oCgL8apDPCwXwCEDA2U697bbT81JIc2IrVjxO4KX6WU2N+oN9Z4w== + +"@oxc-resolver/binding-linux-s390x-gnu@11.24.2": + version "11.24.2" + resolved "https://registry.npmjs.org/@oxc-resolver/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-11.24.2.tgz#8e3ca765e1af7ccab8a61adc96323810f9770535" + integrity sha512-sGCecF3cx2DFlH4t/z7ApnOnXqN48p5p5mlHDEnHTAukQa2P+qMVE4CwyWE9W+q/m3QJ7kKfGrIjax31f44oFQ== + +"@oxc-resolver/binding-linux-x64-gnu@11.24.2": + version "11.24.2" + resolved "https://registry.npmjs.org/@oxc-resolver/binding-linux-x64-gnu/-/binding-linux-x64-gnu-11.24.2.tgz#a2b14c1efc3252e705038bc23b7225a7cb434df2" + integrity sha512-k/VlMMcSzMlahb3/fENM4rTlsJ0s3fFROA0KXPBmKggqmTSaE383sl8F3KCOXPLmVsYfW6hCitMhXCEtNeZxxg== + +"@oxc-resolver/binding-linux-x64-musl@11.24.2": + version "11.24.2" + resolved "https://registry.npmjs.org/@oxc-resolver/binding-linux-x64-musl/-/binding-linux-x64-musl-11.24.2.tgz#d42f14a6a286b0a81871e2be6ee2eeb3d044afce" + integrity sha512-8hbnZyNi97b/8wapYaIF9+t9GmZKBW2vunaOc3h9HGJptH7b7XpvZqOTBSm/MpTjr7H497BlgOaSfLUdhmy2bw== + +"@oxc-resolver/binding-openharmony-arm64@11.24.2": + version "11.24.2" + resolved "https://registry.npmjs.org/@oxc-resolver/binding-openharmony-arm64/-/binding-openharmony-arm64-11.24.2.tgz#1ce27bc037073b624c484e481ae61f4cc9b5cfbc" + integrity sha512-MvyGik3a6pVgZ0t/kWlbmFxFLmXQJwgLsY2eYFHLpy0wGwRbfzeIGgDwQ3kXqE30z+kSXennRkCrT7TUvkptNg== + +"@oxc-resolver/binding-wasm32-wasi@11.24.2": + version "11.24.2" + resolved "https://registry.npmjs.org/@oxc-resolver/binding-wasm32-wasi/-/binding-wasm32-wasi-11.24.2.tgz#9c818fd9512eed502da1972de1f8c9528b4c9d27" + integrity sha512-vHcssMPwO08RTvj/c0iOBz90attxyG3wQJ0dTcyEQK43LRpcdLWZlV5feBhv6Isn6ahbQIzHbCgfa81+RiML0Q== + dependencies: + "@emnapi/core" "1.11.2" + "@emnapi/runtime" "1.11.2" + "@napi-rs/wasm-runtime" "^1.1.6" + +"@oxc-resolver/binding-win32-arm64-msvc@11.24.2": + version "11.24.2" + resolved "https://registry.npmjs.org/@oxc-resolver/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-11.24.2.tgz#0e2bd6869ef554ffd3016594951f1f44f5f02617" + integrity sha512-uokJqro2iBqkFvJdKQLP7d8/BUmFwESQFVmIJUQKj1Xn1a/LysJoe1vmeECLF5b3jsV8CAL5sEMJXX6SdK9Nhg== + +"@oxc-resolver/binding-win32-x64-msvc@11.24.2": + version "11.24.2" + resolved "https://registry.npmjs.org/@oxc-resolver/binding-win32-x64-msvc/-/binding-win32-x64-msvc-11.24.2.tgz#d0649344fcd504dfaf7f3561a53617c38d98d789" + integrity sha512-UqGPmo56KDfLlfXFAFIrNflHT8tFxWGEivWg3Zeyp4Uy2NlKN1FGPr6/BxcLGG3+kZ6Wp14g5Uj+n71boqZfiw== + "@polka/url@^1.0.0-next.24": version "1.0.0-next.29" resolved "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz#5a40109a1ab5f84d6fd8fc928b19f367cbe7e7b1" @@ -2698,7 +2827,7 @@ resolved "https://registry.npmjs.org/@storybook/global/-/global-5.0.0.tgz#b793d34b94f572c1d7d9e0f44fac4e0dbc9572ed" integrity sha512-FcOqPAXACP0I3oJ/ws6/rrPT9WGhu915Cg8D02a9YxLo0DE9zI+a9A5gRGvmQ09fiWPukqI8ZAEoQEdWUKMQdQ== -"@storybook/icons@^2.0.1": +"@storybook/icons@^2.0.1", "@storybook/icons@^2.0.2": version "2.1.0" resolved "https://registry.npmjs.org/@storybook/icons/-/icons-2.1.0.tgz#edfc2450a39c5e780f28c6cbc49acd7bff59b41a" integrity sha512-Fxh9vYpX9bQqFeHRiY8h2ApeRGDzRSMLwJwNZ/AIRqnyOKHxRKL+yFe+ctEkVJmuptRE9u1Hrn8ZZNHyfDKKNg== @@ -2954,7 +3083,7 @@ resolved "https://registry.yarnpkg.com/@tanstack/virtual-core/-/virtual-core-3.11.2.tgz#00409e743ac4eea9afe5b7708594d5fcebb00212" integrity sha512-vTtpNt7mKCiZ1pwU9hfKPhpdVO2sVzFQsxoVBGtOSHxlrRRzYr8iQ2TlwbAcRYCcEiZ9ECAM8kBzH0v2+VzfKw== -"@testing-library/dom@10.4.1": +"@testing-library/dom@10.4.1", "@testing-library/dom@^10.4.1": version "10.4.1" resolved "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz#d444f8a889e9a46e9a3b4f3b88e0fcb3efb6cf95" integrity sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg== @@ -2980,18 +3109,6 @@ picocolors "^1.1.1" redent "^3.0.0" -"@testing-library/jest-dom@^6.9.1": - version "6.10.0" - resolved "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.10.0.tgz#8a76841e94b72d55d09d2a34b9db9d75da9cbc08" - integrity sha512-HQwu0KaB2zyT0iLzBL+8CLyZDL3KlZlZJ+2iyc9uCUnlJVskJU/UlPuVCyIPhtukjPQdT2QNoR5nCP5FqTmmDQ== - dependencies: - "@adobe/css-tools" "^4.4.0" - aria-query "^5.0.0" - css.escape "^1.5.1" - dom-accessibility-api "^0.6.3" - picocolors "^1.1.1" - redent "^3.0.0" - "@testing-library/react@16.3.2": version "16.3.2" resolved "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz#672883b7acb8e775fc0492d9e9d25e06e89786d0" @@ -3211,6 +3328,13 @@ dependencies: tslib "^2.4.0" +"@tybys/wasm-util@^0.10.3": + version "0.10.3" + resolved "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz#015cba9e9dd47ce14d03d2a8c5d547bfb169665d" + integrity sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg== + dependencies: + tslib "^2.4.0" + "@types/aria-query@^5.0.1": version "5.0.4" resolved "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz#1a31c3d378850d2778dabb6374d036dcba4ba708" @@ -5189,69 +5313,37 @@ es-toolkit@^1.39.3: resolved "https://registry.yarnpkg.com/es-toolkit/-/es-toolkit-1.45.1.tgz#21b28b2bd43178fd4c9c937c445d5bcaccce907b" integrity sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw== -"esbuild@^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0 || ^0.26.0 || ^0.27.0": - version "0.27.7" - resolved "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz#bcadce22b2f3fd76f257e3a64f83a64986fea11f" - integrity sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w== +"esbuild@^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0 || ^0.26.0 || ^0.27.0 || ^0.28.0", "esbuild@^0.27.0 || ^0.28.0": + version "0.28.2" + resolved "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz#0f43bd1bad955b72d24e2261e3abe5957ccf0816" + integrity sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA== optionalDependencies: - "@esbuild/aix-ppc64" "0.27.7" - "@esbuild/android-arm" "0.27.7" - "@esbuild/android-arm64" "0.27.7" - "@esbuild/android-x64" "0.27.7" - "@esbuild/darwin-arm64" "0.27.7" - "@esbuild/darwin-x64" "0.27.7" - "@esbuild/freebsd-arm64" "0.27.7" - "@esbuild/freebsd-x64" "0.27.7" - "@esbuild/linux-arm" "0.27.7" - "@esbuild/linux-arm64" "0.27.7" - "@esbuild/linux-ia32" "0.27.7" - "@esbuild/linux-loong64" "0.27.7" - "@esbuild/linux-mips64el" "0.27.7" - "@esbuild/linux-ppc64" "0.27.7" - "@esbuild/linux-riscv64" "0.27.7" - "@esbuild/linux-s390x" "0.27.7" - "@esbuild/linux-x64" "0.27.7" - "@esbuild/netbsd-arm64" "0.27.7" - "@esbuild/netbsd-x64" "0.27.7" - "@esbuild/openbsd-arm64" "0.27.7" - "@esbuild/openbsd-x64" "0.27.7" - "@esbuild/openharmony-arm64" "0.27.7" - "@esbuild/sunos-x64" "0.27.7" - "@esbuild/win32-arm64" "0.27.7" - "@esbuild/win32-ia32" "0.27.7" - "@esbuild/win32-x64" "0.27.7" - -"esbuild@^0.27.0 || ^0.28.0": - version "0.28.1" - resolved "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz#ef45b4634c9c9d97a296aea4114a5f9840f95578" - integrity sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw== - optionalDependencies: - "@esbuild/aix-ppc64" "0.28.1" - "@esbuild/android-arm" "0.28.1" - "@esbuild/android-arm64" "0.28.1" - "@esbuild/android-x64" "0.28.1" - "@esbuild/darwin-arm64" "0.28.1" - "@esbuild/darwin-x64" "0.28.1" - "@esbuild/freebsd-arm64" "0.28.1" - "@esbuild/freebsd-x64" "0.28.1" - "@esbuild/linux-arm" "0.28.1" - "@esbuild/linux-arm64" "0.28.1" - "@esbuild/linux-ia32" "0.28.1" - "@esbuild/linux-loong64" "0.28.1" - "@esbuild/linux-mips64el" "0.28.1" - "@esbuild/linux-ppc64" "0.28.1" - "@esbuild/linux-riscv64" "0.28.1" - "@esbuild/linux-s390x" "0.28.1" - "@esbuild/linux-x64" "0.28.1" - "@esbuild/netbsd-arm64" "0.28.1" - "@esbuild/netbsd-x64" "0.28.1" - "@esbuild/openbsd-arm64" "0.28.1" - "@esbuild/openbsd-x64" "0.28.1" - "@esbuild/openharmony-arm64" "0.28.1" - "@esbuild/sunos-x64" "0.28.1" - "@esbuild/win32-arm64" "0.28.1" - "@esbuild/win32-ia32" "0.28.1" - "@esbuild/win32-x64" "0.28.1" + "@esbuild/aix-ppc64" "0.28.2" + "@esbuild/android-arm" "0.28.2" + "@esbuild/android-arm64" "0.28.2" + "@esbuild/android-x64" "0.28.2" + "@esbuild/darwin-arm64" "0.28.2" + "@esbuild/darwin-x64" "0.28.2" + "@esbuild/freebsd-arm64" "0.28.2" + "@esbuild/freebsd-x64" "0.28.2" + "@esbuild/linux-arm" "0.28.2" + "@esbuild/linux-arm64" "0.28.2" + "@esbuild/linux-ia32" "0.28.2" + "@esbuild/linux-loong64" "0.28.2" + "@esbuild/linux-mips64el" "0.28.2" + "@esbuild/linux-ppc64" "0.28.2" + "@esbuild/linux-riscv64" "0.28.2" + "@esbuild/linux-s390x" "0.28.2" + "@esbuild/linux-x64" "0.28.2" + "@esbuild/netbsd-arm64" "0.28.2" + "@esbuild/netbsd-x64" "0.28.2" + "@esbuild/openbsd-arm64" "0.28.2" + "@esbuild/openbsd-x64" "0.28.2" + "@esbuild/openharmony-arm64" "0.28.2" + "@esbuild/sunos-x64" "0.28.2" + "@esbuild/win32-arm64" "0.28.2" + "@esbuild/win32-ia32" "0.28.2" + "@esbuild/win32-x64" "0.28.2" escalade@^3.1.1, escalade@^3.2.0: version "3.2.0" @@ -6650,6 +6742,11 @@ json5@^2.2.2, json5@^2.2.3: resolved "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz#78cd6f1a19bdc12b73db5ad0c61efd66c1e29283" integrity sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg== +jsonc-parser@^3.3.1: + version "3.3.1" + resolved "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz#f2a524b4f7fd11e3d791e559977ad60b98b798b4" + integrity sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ== + jspdf-autotable@^5.0.8: version "5.0.8" resolved "https://registry.npmjs.org/jspdf-autotable/-/jspdf-autotable-5.0.8.tgz#b010dab34caf5eff60bbcd09a36d0608dc0a84ae" @@ -7694,6 +7791,59 @@ own-keys@^1.0.1: object-keys "^1.1.1" safe-push-apply "^1.0.0" +oxc-parser@^0.127.0: + version "0.127.0" + resolved "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.127.0.tgz#bb14600f5c59fb6b1fbac0ab6ff2cd3495a6df1d" + integrity sha512-bkgD4qHlN7WxLdX8bLXdaU54TtQtAIg/ZBAfm0aje/mo3MRDo3P0hZSgr4U7O3xfX+fQmR5AP04JS/TGcZLcFA== + dependencies: + "@oxc-project/types" "^0.127.0" + optionalDependencies: + "@oxc-parser/binding-android-arm-eabi" "0.127.0" + "@oxc-parser/binding-android-arm64" "0.127.0" + "@oxc-parser/binding-darwin-arm64" "0.127.0" + "@oxc-parser/binding-darwin-x64" "0.127.0" + "@oxc-parser/binding-freebsd-x64" "0.127.0" + "@oxc-parser/binding-linux-arm-gnueabihf" "0.127.0" + "@oxc-parser/binding-linux-arm-musleabihf" "0.127.0" + "@oxc-parser/binding-linux-arm64-gnu" "0.127.0" + "@oxc-parser/binding-linux-arm64-musl" "0.127.0" + "@oxc-parser/binding-linux-ppc64-gnu" "0.127.0" + "@oxc-parser/binding-linux-riscv64-gnu" "0.127.0" + "@oxc-parser/binding-linux-riscv64-musl" "0.127.0" + "@oxc-parser/binding-linux-s390x-gnu" "0.127.0" + "@oxc-parser/binding-linux-x64-gnu" "0.127.0" + "@oxc-parser/binding-linux-x64-musl" "0.127.0" + "@oxc-parser/binding-openharmony-arm64" "0.127.0" + "@oxc-parser/binding-wasm32-wasi" "0.127.0" + "@oxc-parser/binding-win32-arm64-msvc" "0.127.0" + "@oxc-parser/binding-win32-ia32-msvc" "0.127.0" + "@oxc-parser/binding-win32-x64-msvc" "0.127.0" + +oxc-resolver@^11.19.1: + version "11.24.2" + resolved "https://registry.npmjs.org/oxc-resolver/-/oxc-resolver-11.24.2.tgz#85c08d9f5797e600175fa8524d2d271c685d97cf" + integrity sha512-FY91FiDBj7ls5MsFS9jN3tjz2o0/zsdSsymlakySaBwVJZorHhkWyICLZMKxlu1R9vYo+sd3z1jwb4J8x7bNDw== + optionalDependencies: + "@oxc-resolver/binding-android-arm-eabi" "11.24.2" + "@oxc-resolver/binding-android-arm64" "11.24.2" + "@oxc-resolver/binding-darwin-arm64" "11.24.2" + "@oxc-resolver/binding-darwin-x64" "11.24.2" + "@oxc-resolver/binding-freebsd-x64" "11.24.2" + "@oxc-resolver/binding-linux-arm-gnueabihf" "11.24.2" + "@oxc-resolver/binding-linux-arm-musleabihf" "11.24.2" + "@oxc-resolver/binding-linux-arm64-gnu" "11.24.2" + "@oxc-resolver/binding-linux-arm64-musl" "11.24.2" + "@oxc-resolver/binding-linux-ppc64-gnu" "11.24.2" + "@oxc-resolver/binding-linux-riscv64-gnu" "11.24.2" + "@oxc-resolver/binding-linux-riscv64-musl" "11.24.2" + "@oxc-resolver/binding-linux-s390x-gnu" "11.24.2" + "@oxc-resolver/binding-linux-x64-gnu" "11.24.2" + "@oxc-resolver/binding-linux-x64-musl" "11.24.2" + "@oxc-resolver/binding-openharmony-arm64" "11.24.2" + "@oxc-resolver/binding-wasm32-wasi" "11.24.2" + "@oxc-resolver/binding-win32-arm64-msvc" "11.24.2" + "@oxc-resolver/binding-win32-x64-msvc" "11.24.2" + p-limit@^3.0.2: version "3.1.0" resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-3.1.0.tgz#e1daccbe78d0d1388ca18c64fea38e3e57e3706b" @@ -8788,16 +8938,11 @@ semver@^6.3.1: resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4" integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== -semver@^7.5.3: +semver@^7.5.3, semver@^7.7.1, semver@^7.7.3: version "7.8.5" resolved "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz#39b646037dd50c14fb451e7e4cac58ed8b863f69" integrity sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA== -semver@^7.7.1, semver@^7.7.3: - version "7.7.4" - resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.4.tgz#28464e36060e991fa7a11d0279d2d3f3b57a7e8a" - integrity sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA== - set-function-length@^1.2.2: version "1.2.2" resolved "https://registry.yarnpkg.com/set-function-length/-/set-function-length-1.2.2.tgz#aac72314198eaed975cf77b2c3b6b880695e5449" @@ -9027,24 +9172,28 @@ stop-iteration-iterator@^1.1.0: es-errors "^1.3.0" internal-slot "^1.1.0" -storybook@10.3.5: - version "10.3.5" - resolved "https://registry.npmjs.org/storybook/-/storybook-10.3.5.tgz#77bc13217db7b3c2ba5a73c1f2d469bfc0675da1" - integrity sha512-uBSZu/GZa9aEIW3QMGvdQPMZWhGxSe4dyRWU8B3/Vd47Gy/XLC7tsBxRr13txmmPOEDHZR94uLuq0H50fvuqBw== +storybook@10.5.7: + version "10.5.7" + resolved "https://registry.npmjs.org/storybook/-/storybook-10.5.7.tgz#adfc465e51f337291c095278c23f1b8024ef2da7" + integrity sha512-oiKvWIwIoOhFP1i6dASYyMXwPHKEtVZMshqSB7EvIVYjWRh0l9H7gHEt1z4Gh2rLGFMekWdsm4s94rvwpR7gkg== dependencies: "@storybook/global" "^5.0.0" - "@storybook/icons" "^2.0.1" - "@testing-library/jest-dom" "^6.9.1" + "@storybook/icons" "^2.0.2" + "@testing-library/dom" "^10.4.1" + "@testing-library/jest-dom" "6.9.1" "@testing-library/user-event" "^14.6.1" "@vitest/expect" "3.2.4" "@vitest/spy" "3.2.4" "@webcontainer/env" "^1.1.1" - esbuild "^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0 || ^0.26.0 || ^0.27.0" + esbuild "^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0 || ^0.26.0 || ^0.27.0 || ^0.28.0" + jsonc-parser "^3.3.1" open "^10.2.0" + oxc-parser "^0.127.0" + oxc-resolver "^11.19.1" recast "^0.23.5" semver "^7.7.3" use-sync-external-store "^1.5.0" - ws "^8.18.0" + ws "^8.21.1" strict-event-emitter@^0.5.1: version "0.5.1" @@ -9946,10 +10095,10 @@ wrap-ansi@^7.0.0: string-width "^4.1.0" strip-ansi "^6.0.0" -ws@^8.18.0, ws@^8.19.0: - version "8.21.1" - resolved "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz#045650cd4b1207809e7547146223c3814a9af586" - integrity sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw== +ws@^8.19.0, ws@^8.21.1: + version "8.21.3" + resolved "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz#660b4faddb6a3e575c86e078126919961f4de4fc" + integrity sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw== wsl-utils@^0.1.0: version "0.1.0" From 449fcd02995f5aff6a87278fad4acba0dcb2e9cd Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 11 Aug 2026 04:15:58 +0000 Subject: [PATCH 026/226] chore(deps): bump react-dropzone from 15.0.0 to 20.0.0 in /frontend Bumps [react-dropzone](https://github.com/react-dropzone/react-dropzone) from 15.0.0 to 20.0.0. - [Release notes](https://github.com/react-dropzone/react-dropzone/releases) - [Commits](https://github.com/react-dropzone/react-dropzone/compare/v15.0.0...v20.0.0) --- updated-dependencies: - dependency-name: react-dropzone dependency-version: 20.0.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- frontend/package.json | 2 +- frontend/yarn.lock | 29 +++++++++++++---------------- 2 files changed, 14 insertions(+), 17 deletions(-) diff --git a/frontend/package.json b/frontend/package.json index f5e3782ce8..d60b196a93 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -91,7 +91,7 @@ "react-apexcharts": "2.1.1", "react-beautiful-dnd": "13.1.1", "react-dom": "19.2.8", - "react-dropzone": "15.0.0", + "react-dropzone": "20.0.0", "react-error-boundary": "^6.1.2", "react-hook-form": "^7.76.1", "react-hot-toast": "2.6.0", diff --git a/frontend/yarn.lock b/frontend/yarn.lock index cdccb617fe..061e567304 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -4051,9 +4051,9 @@ asynckit@^0.4.0: resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" integrity sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q== -attr-accept@^2.2.4: +attr-accept@^2.2.5: version "2.2.5" - resolved "https://registry.yarnpkg.com/attr-accept/-/attr-accept-2.2.5.tgz#d7061d958e6d4f97bf8665c68b75851a0713ab5e" + resolved "https://registry.npmjs.org/attr-accept/-/attr-accept-2.2.5.tgz#d7061d958e6d4f97bf8665c68b75851a0713ab5e" integrity sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ== available-typed-arrays@^1.0.7: @@ -5634,12 +5634,10 @@ file-entry-cache@^8.0.0: dependencies: flat-cache "^4.0.0" -file-selector@^2.1.0: - version "2.1.2" - resolved "https://registry.yarnpkg.com/file-selector/-/file-selector-2.1.2.tgz#fe7c7ee9e550952dfbc863d73b14dc740d7de8b4" - integrity sha512-QgXo+mXTe8ljeqUFaX3QVHc5osSItJ/Km+xpocx0aSqWGMSCf6qYs/VnzZgS864Pjn5iceMRFigeAV7AfTlaig== - dependencies: - tslib "^2.7.0" +file-selector@^4.1.0: + version "4.1.0" + resolved "https://registry.npmjs.org/file-selector/-/file-selector-4.1.0.tgz#8759e5b0ef030c5cee36ea6f4b66cd9b23a40d86" + integrity sha512-Io1mP8CI3zec5Bxy3P3TxdrKnt35Cm8vNIHnZsvyj43l4YFjD4NRInBp240S5bDJQ0EP1jnh7nCAwXsO818OCg== fill-range@^7.1.1: version "7.1.1" @@ -8173,14 +8171,13 @@ react-dom@19.2.8, "react-dom@^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0": dependencies: scheduler "^0.27.0" -react-dropzone@15.0.0: - version "15.0.0" - resolved "https://registry.yarnpkg.com/react-dropzone/-/react-dropzone-15.0.0.tgz#bd03c7c2b14fe4ea9db1a9c74502b85339f2e505" - integrity sha512-lGjYV/EoqEjEWPnmiSvH4v5IoIAwQM2W4Z1C0Q/Pw2xD0eVzKPS359BQTUMum+1fa0kH2nrKjuavmTPOGhpLPg== +react-dropzone@20.0.0: + version "20.0.0" + resolved "https://registry.npmjs.org/react-dropzone/-/react-dropzone-20.0.0.tgz#75eade48bede945796aac3a25cba5488d5d640a9" + integrity sha512-Xw8tvvVPJQzj8ir5wivUMzA+G6R+aGhdU5KQzUMvVBlJNb26AW/0137VoYVmb5UgZcbhM9OCpjE4KOqqSL9QuQ== dependencies: - attr-accept "^2.2.4" - file-selector "^2.1.0" - prop-types "^15.8.1" + attr-accept "^2.2.5" + file-selector "^4.1.0" react-error-boundary@^6.1.2: version "6.1.2" @@ -9434,7 +9431,7 @@ tsconfig-paths@^4.2.0: minimist "^1.2.6" strip-bom "^3.0.0" -tslib@^2.0.0, tslib@^2.0.1, tslib@^2.0.3, tslib@^2.4.0, tslib@^2.7.0, tslib@^2.8.0: +tslib@^2.0.0, tslib@^2.0.1, tslib@^2.0.3, tslib@^2.4.0, tslib@^2.8.0: version "2.8.1" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== From 66d4e1c31e610e979b8ec4ce1d8beaa859fb681f Mon Sep 17 00:00:00 2001 From: k-grube Date: Mon, 10 Aug 2026 22:55:21 -0700 Subject: [PATCH 027/226] fix: on viewports between 900-1199px use mobile nav instead of hiding both navs --- frontend/src/hooks/use-breakpoint.js | 15 +++-- frontend/src/layouts/index.js | 13 ++-- frontend/src/layouts/top-nav.js | 37 ++++++----- frontend/tests/hooks/use-breakpoint.test.jsx | 65 ++++++++++++++++++- .../tests/lint/mobile-layout-patterns.test.js | 19 ++++++ 5 files changed, 120 insertions(+), 29 deletions(-) diff --git a/frontend/src/hooks/use-breakpoint.js b/frontend/src/hooks/use-breakpoint.js index 06baf39328..4fff2b65e2 100644 --- a/frontend/src/hooks/use-breakpoint.js +++ b/frontend/src/hooks/use-breakpoint.js @@ -1,9 +1,14 @@ import { useMediaQuery } from "@mui/material"; import { useSettings } from "./use-settings"; -// Shared breakpoint hooks so the mobile threshold lives in one place. Every responsive -// pivot in the app uses down('md') — if that ever needs to move, move it here. -export const useIsMobileLayout = () => useMediaQuery((theme) => theme.breakpoints.down("md")); +// Shared breakpoint hooks so the two mobile thresholds sit next to each other. + +// Chrome pivots where the side nav gives way to the drawer (layouts/index.js). Everything that +// has to agree with the nav reads this: content gutter, top-nav hamburger, page toolbars. +export const useIsMobileLayout = () => useMediaQuery((theme) => theme.breakpoints.down("lg")); + +// Tables pivot narrower: a table still reads fine at 1100, cards that wide are mostly whitespace. +const useIsNarrowForTables = () => useMediaQuery((theme) => theme.breakpoints.down("md")); export const useIsTabletLayout = () => useMediaQuery((theme) => theme.breakpoints.between("sm", "md")); @@ -27,13 +32,13 @@ const VALID_MODES = ["auto", "cards", "table"]; */ export const useTableViewMode = ({ viewMode, simple = false } = {}) => { const settings = useSettings(); - const isMobile = useIsMobileLayout(); + const isNarrow = useIsNarrowForTables(); if (simple) return "table"; let mode = unwrap(viewMode) ?? unwrap(settings?.tableViewMode) ?? "auto"; if (!VALID_MODES.includes(mode)) mode = "auto"; - if (mode === "auto") return isMobile ? "cards" : "table"; + if (mode === "auto") return isNarrow ? "cards" : "table"; return mode; }; diff --git a/frontend/src/layouts/index.js b/frontend/src/layouts/index.js index 9a873f94b8..4a1dd989c7 100644 --- a/frontend/src/layouts/index.js +++ b/frontend/src/layouts/index.js @@ -1,7 +1,8 @@ import { useCallback, useEffect, useMemo, useState, useRef } from 'react' import { usePathname } from 'next/navigation' -import { Box, Container, Divider, Stack, useMediaQuery } from '@mui/material' +import { Box, Container, Divider, Stack } from '@mui/material' import { styled } from '@mui/material/styles' +import { useIsMobileLayout } from '../hooks/use-breakpoint' import { useSettings } from '../hooks/use-settings' import { Footer } from './footer' import { MobileNav } from './mobile-nav' @@ -82,7 +83,8 @@ export const Layout = (props) => { // showBreadcrumb: the error routes opt out — there is no trail to a page that // doesn't exist or just crashed, and the bookmark button lives in there too. const { children, allTenantsSupport = true, showBreadcrumb = true } = props - const lgDown = useMediaQuery((theme) => theme.breakpoints.down('lg')) + // one gate for the swap: drawer, the hamburger that opens it (top-nav), the gutter below + const navCollapsed = useIsMobileLayout() const settings = useSettings() const mobileNav = useMobileNav() const [fetchingVisible, setFetchingVisible] = useState([]) @@ -308,7 +310,7 @@ export const Layout = (props) => { {hideSidebar === false && ( <> - {lgDown && ( + {navCollapsed && ( { open={mobileNav.open} /> )} - {!lgDown && } + {!navCollapsed && } )} diff --git a/frontend/src/layouts/top-nav.js b/frontend/src/layouts/top-nav.js index 7b9da31973..ac54a053a9 100644 --- a/frontend/src/layouts/top-nav.js +++ b/frontend/src/layouts/top-nav.js @@ -26,7 +26,6 @@ import { Stack, SvgIcon, Tooltip, - useMediaQuery, Popover, List, ListItem, @@ -35,6 +34,7 @@ import { } from '@mui/material' import { useTheme } from '@mui/material/styles' import { Logo } from '../components/logo' +import { useIsMobileLayout } from '../hooks/use-breakpoint' import { useSettings } from '../hooks/use-settings' import { useUserBookmarks } from '../hooks/use-user-bookmarks' import { paths } from '../paths' @@ -54,7 +54,8 @@ export const TopNav = (props) => { const { onNavOpen } = props const settings = useSettings() const { bookmarks, setBookmarks } = useUserBookmarks() - const mdDown = useMediaQuery((theme) => theme.breakpoints.down('md')) + // same gate as the side nav in layouts/index.js, the hamburger below is the drawer's only opener + const navCollapsed = useIsMobileLayout() const showPopoverBookmarks = settings.bookmarkPopover === true const reorderMode = settings.bookmarkReorderMode || 'arrows' const locked = settings.bookmarkLocked ?? true @@ -272,10 +273,10 @@ export const TopNav = (props) => { { > {/* On phones the logo gives way to the tenant chip — the app's primary scoping control earns the space a 24px decorative link was using. */} - {!mdDown && ( + {!navCollapsed && ( { )} - {!mdDown && ( + {!navCollapsed && ( { /> )} - {mdDown && ( + {navCollapsed && ( { )} - {mdDown && ( + {navCollapsed && ( )} - - {!mdDown && ( + + {!navCollapsed && ( { )} - {!mdDown && ( + {!navCollapsed && ( {effectivePaletteMode === 'dark' ? : } )} - {!mdDown && ( + {!navCollapsed && ( { open={universalSearchDialog.open} onClose={closeUniversalSearch} fullWidth - fullScreen={mdDown} + fullScreen={navCollapsed} maxWidth="md" sx={{ '& .MuiDialog-container': { alignItems: 'flex-start', }, '& .MuiDialog-paper': { - mt: mdDown ? 0 : 8, + mt: navCollapsed ? 0 : 8, }, }} > - + { > {/* Fullscreen on mobile leaves no backdrop to tap — provide a close button */} - {mdDown && ( + {navCollapsed && ( { )} Universal Search - {!mdDown && ( + {!navCollapsed && ( Pages: Ctrl/Cmd+K · Users: Ctrl/Cmd+Shift+F · Tenant: Ctrl/Cmd+Alt+K diff --git a/frontend/tests/hooks/use-breakpoint.test.jsx b/frontend/tests/hooks/use-breakpoint.test.jsx index 6a11579a46..ed8d92fbb1 100644 --- a/frontend/tests/hooks/use-breakpoint.test.jsx +++ b/frontend/tests/hooks/use-breakpoint.test.jsx @@ -1,7 +1,7 @@ import React from 'react' import { screen } from '@testing-library/react' import { renderWithProviders, settingsWith } from '../test-utils' -import { useTableViewMode } from '../../src/hooks/use-breakpoint' +import { useIsMobileLayout, useTableViewMode } from '../../src/hooks/use-breakpoint' // jsdom has no width-based matchMedia, so useIsMobileLayout is always false here — // which is exactly why the explicit settings/prop path must exist and is what we test. @@ -41,3 +41,66 @@ describe('useTableViewMode', () => { expect(screen.getByTestId('mode')).toHaveTextContent('table') }) }) + +// Width-aware stub so the two thresholds can be told apart. MUI asks in '@media (max-width:Npx)' +// form; anything it doesn't ask about is left unmatched. +const atWidth = (width) => { + const cache = new Map() + window.matchMedia = (query) => { + if (!cache.has(query)) { + const max = /max-width:\s*([\d.]+)px/.exec(query) + const min = /min-width:\s*([\d.]+)px/.exec(query) + cache.set(query, { + matches: (!max || width <= parseFloat(max[1])) && (!min || width >= parseFloat(min[1])), + media: query, + onchange: null, + addListener: () => {}, + removeListener: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false, + }) + } + return cache.get(query) + } +} + +afterEach(() => { + delete window.matchMedia +}) + +const SplitProbe = () => ( + <> +
    {String(useIsMobileLayout())}
    +
    {useTableViewMode()}
    + +) + +// The two thresholds are deliberately different. One query for both breaks an end either way: +// at md the 900-1200 band loses the side nav with no hamburger to open the drawer, at lg +// desktop-width tables become card lists. +describe('the chrome/table split', () => { + it('treats the 900-1200 band as mobile chrome but keeps tables tabular', () => { + atWidth(1000) + renderWithProviders() + + expect(screen.getByTestId('chrome')).toHaveTextContent('true') + expect(screen.getByTestId('mode')).toHaveTextContent('table') + }) + + it('moves both to mobile on a phone', () => { + atWidth(800) + renderWithProviders() + + expect(screen.getByTestId('chrome')).toHaveTextContent('true') + expect(screen.getByTestId('mode')).toHaveTextContent('cards') + }) + + it('leaves both on desktop above lg', () => { + atWidth(1300) + renderWithProviders() + + expect(screen.getByTestId('chrome')).toHaveTextContent('false') + expect(screen.getByTestId('mode')).toHaveTextContent('table') + }) +}) diff --git a/frontend/tests/lint/mobile-layout-patterns.test.js b/frontend/tests/lint/mobile-layout-patterns.test.js index 372d50326b..1e405d604a 100644 --- a/frontend/tests/lint/mobile-layout-patterns.test.js +++ b/frontend/tests/lint/mobile-layout-patterns.test.js @@ -170,6 +170,25 @@ describe("mobile layout patterns", () => { expect(pinnedHeightOffenders(` // ${MARKER}\n \n`)).toEqual([]); }); + // Side nav, the drawer that replaces it, the hamburger that opens the drawer and the content + // gutter are four gates on one decision. Any of them declaring its own query lets them + // disagree, and a width with no side nav and no way to open the drawer has no nav at all. + it("keys layout chrome off the shared breakpoint hook, not its own media query", () => { + const offenders = []; + for (const name of ["index.js", "top-nav.js"]) { + const source = stripComments(fs.readFileSync(path.join(SRC, "layouts", name), "utf8")); + source.split("\n").forEach((line, i) => { + if (/useMediaQuery\(.*breakpoints\.(down|up|between)\(/.test(line)) { + offenders.push(`layouts/${name}:${i + 1}`); + } + }); + } + expect( + offenders, + `Nav gates have to agree. Use useIsMobileLayout from hooks/use-breakpoint:\n${offenders.join("\n")}` + ).toEqual([]); + }); + it("gives every wrapping Stack useFlexGap", () => { const offenders = []; for (const file of files) { From 1bcdc6fda82127f1c89d7c03bd6b3286c5ff8091 Mon Sep 17 00:00:00 2001 From: k-grube Date: Tue, 11 Aug 2026 00:09:01 -0700 Subject: [PATCH 028/226] Fix(frontend): remove autocomplete prop spread, keep autocomplete props off input DOM node --- .../CippComponents/CippAutocomplete.jsx | 17 +++---- .../CippComponents/CippAutocomplete.test.jsx | 47 +++++++++++++++++++ 2 files changed, 53 insertions(+), 11 deletions(-) diff --git a/frontend/src/components/CippComponents/CippAutocomplete.jsx b/frontend/src/components/CippComponents/CippAutocomplete.jsx index 5a19533820..0073f4e018 100644 --- a/frontend/src/components/CippComponents/CippAutocomplete.jsx +++ b/frontend/src/components/CippComponents/CippAutocomplete.jsx @@ -24,20 +24,12 @@ const MemoTextField = React.memo(function MemoTextField({ params, label, placeholder, + variant, // Field-level required: asterisk on the label. HTML5 required is separate because // Autocomplete (especially multiple) clears the input after selection — a static // required on the input would falsely block submit even when chips/value exist. required = false, htmlRequired = false, - // Autocomplete-specific props that must not be forwarded to TextField/DOM - getOptionLabel, - isOptionEqualToValue, - filterOptions, - getOptionDisabled, - groupBy, - renderGroup, - renderOption, - ...otherProps }) { const { InputProps, ...otherParams } = params @@ -47,7 +39,7 @@ const MemoTextField = React.memo(function MemoTextField({ {...otherParams} label={label} placeholder={placeholder} - {...otherProps} + variant={variant} required={htmlRequired} slotProps={{ inputLabel: { @@ -96,6 +88,8 @@ export const CippAutoComplete = React.forwardRef((props, ref) => { renderGroup, customAction, handleHomeEndKeys = false, + // TextField-bound, MUI Autocomplete would pass it through to its root div + variant, ...other } = props @@ -619,13 +613,14 @@ export const CippAutoComplete = React.forwardRef((props, ref) => { return ( + {/* caller props stay on , anything spread here reaches the input as a DOM attr */} {api?.url && api?.showRefresh && ( diff --git a/frontend/tests/components/CippComponents/CippAutocomplete.test.jsx b/frontend/tests/components/CippComponents/CippAutocomplete.test.jsx index e5a47bc584..90dbd2aa6a 100644 --- a/frontend/tests/components/CippComponents/CippAutocomplete.test.jsx +++ b/frontend/tests/components/CippComponents/CippAutocomplete.test.jsx @@ -345,4 +345,51 @@ describe('CippAutoComplete', () => { expect(document.querySelector('.MuiFormLabel-asterisk')).toBeTruthy() }) }) + + // TextField forwards what it doesn't consume to the FormControl root, so a leak lands as a DOM attr + describe('prop routing', () => { + it('keeps autocomplete-only props off the DOM', () => { + const { container } = renderWithProviders( + {}} + noOptionsText="nothing here" + /> + ) + expect(container.querySelector('[nooptionstext]')).toBeNull() + }) + + it('routes variant to the text field, not to the autocomplete root', () => { + const { container } = renderWithProviders( + {}} + variant="outlined" + /> + ) + // outlined draws the notched fieldset/legend, the themed filled default does not + expect(container.querySelector('fieldset legend')).toBeTruthy() + expect(container.querySelector('[variant]')).toBeNull() + }) + + it('forwards filterSelectedOptions to the autocomplete, selected option stays listed', async () => { + const user = userEvent.setup() + renderWithProviders( + {}} + filterSelectedOptions={false} + /> + ) + await user.click(screen.getByRole('combobox')) + expect(await screen.findByRole('option', { name: 'Alpha' })).toBeInTheDocument() + }) + }) }) From 0f70f8bdd5d941f9223bb6635467d39ae53f771f Mon Sep 17 00:00:00 2001 From: k-grube Date: Tue, 11 Aug 2026 11:03:27 -0700 Subject: [PATCH 029/226] fix(frontend): fix side-nav SwipeableDrawer bugs on mobile --- .../CippComponents/CippBottomSheet.jsx | 6 +- .../src/hooks/use-swipe-close-transition.js | 46 +++++++ frontend/src/layouts/mobile-nav.js | 10 +- .../CippBottomSheet.stories.jsx | 15 ++ frontend/tests/layouts/MobileNav.stories.jsx | 129 ++++++++++++++++++ frontend/tests/layouts/MobileNav.test.jsx | 77 +++++++++++ 6 files changed, 280 insertions(+), 3 deletions(-) create mode 100644 frontend/src/hooks/use-swipe-close-transition.js create mode 100644 frontend/tests/layouts/MobileNav.stories.jsx create mode 100644 frontend/tests/layouts/MobileNav.test.jsx diff --git a/frontend/src/components/CippComponents/CippBottomSheet.jsx b/frontend/src/components/CippComponents/CippBottomSheet.jsx index 6255abb67e..a1bb308472 100644 --- a/frontend/src/components/CippComponents/CippBottomSheet.jsx +++ b/frontend/src/components/CippComponents/CippBottomSheet.jsx @@ -1,4 +1,5 @@ import { Box, SwipeableDrawer, Typography } from "@mui/material"; +import { useSwipeCloseTransition } from "../../hooks/use-swipe-close-transition"; // SwipeableDrawer requires onOpen; these sheets are only ever opened programmatically. const noop = () => {}; @@ -8,18 +9,19 @@ const noop = () => {}; export const CippBottomSheet = (props) => { const { open, onClose, title, children, footer, onExited, SlideProps, ModalProps, ...other } = props; + const swipeClose = useSwipeCloseTransition(open, onClose); return ( theme.zIndex.modal + 1 }} PaperProps={{ sx: { diff --git a/frontend/src/hooks/use-swipe-close-transition.js b/frontend/src/hooks/use-swipe-close-transition.js new file mode 100644 index 0000000000..2f83e9b25c --- /dev/null +++ b/frontend/src/hooks/use-swipe-close-transition.js @@ -0,0 +1,46 @@ +import { useCallback, useEffect, useRef } from "react"; + +// Slide probes the paper's untranslated position when the exit starts (Slide.js +// getTranslateValue), so a paper carrying a drag transform snaps wide open and animates the +// full width out. Re-seed the start position with where the finger let go. +export const useSwipeCloseTransition = (open, onClose) => { + const paperRef = useRef(null); + const dragFrom = useRef(null); + + // fires as the open transition starts, so a drag that begins mid-animation still has the node + const handleEnter = useCallback((node) => { + paperRef.current = node; + }, []); + + const handleClose = useCallback( + (...args) => { + const transform = paperRef.current?.style.transform; + dragFrom.current = transform && transform !== "none" ? transform : null; + onClose?.(...args); + }, + [onClose] + ); + + // Effects flush child-first, so this lands after Slide's own exit effect, which runs the same + // probe again. Repairing from the transition's onExit callback gets overwritten by it. + useEffect(() => { + const node = paperRef.current; + const from = dragFrom.current; + dragFrom.current = null; + if (open || !node || !from) { + return; + } + const target = node.style.transform; + const transition = node.style.transition; + node.style.transition = "none"; + node.style.transform = from; + node.getBoundingClientRect(); + node.style.transition = transition; + node.style.transform = target; + }, [open]); + + return { + onClose: handleClose, + transitionProps: { onEnter: handleEnter }, + }; +}; diff --git a/frontend/src/layouts/mobile-nav.js b/frontend/src/layouts/mobile-nav.js index c476b713ff..12f2bee420 100644 --- a/frontend/src/layouts/mobile-nav.js +++ b/frontend/src/layouts/mobile-nav.js @@ -11,6 +11,7 @@ import { paths } from "../paths"; import { MobileNavItem } from "./mobile-nav-item"; import { SideNavBookmarks } from "./side-nav-bookmarks"; import { useSettings } from "../hooks/use-settings"; +import { useSwipeCloseTransition } from "../hooks/use-swipe-close-transition"; // 80% of the viewport truncated third-level labels at 320px (256px) and was absurd at // 899px (719px). Cap it like a real nav drawer. @@ -111,6 +112,7 @@ export const MobileNav = (props) => { const { open, onClose, onOpen, items } = props; const pathname = usePathname(); const settings = useSettings(); + const swipeClose = useSwipeCloseTransition(open, onClose); const [search, setSearch] = useState(""); const showSidebarBookmarks = settings.bookmarkSidebar !== false; @@ -123,9 +125,15 @@ export const MobileNav = (props) => { return ( {})} open={open} + slotProps={{ transition: swipeClose.transitionProps }} PaperProps={{ sx: { width: MOBILE_NAV_WIDTH, diff --git a/frontend/tests/components/CippComponents/CippBottomSheet.stories.jsx b/frontend/tests/components/CippComponents/CippBottomSheet.stories.jsx index 5d7b532661..71e9268c2b 100644 --- a/frontend/tests/components/CippComponents/CippBottomSheet.stories.jsx +++ b/frontend/tests/components/CippComponents/CippBottomSheet.stories.jsx @@ -185,8 +185,23 @@ export const DragHandleDismisses = { fire('touchmove', from + dy) await tick() } + const draggedTo = new DOMMatrixReadOnly(getComputedStyle(paper).transform).m42 + expect(draggedTo).toBeGreaterThan(100) fire('touchend', from + 260) + // The exit has to continue from where the finger let go. Slide probes the paper's + // untranslated position when the exit starts (Slide.js getTranslateValue), and the browser + // takes that probe as the transition's start, which puts the sheet back at full height for + // the length of the close. + const firstExitFrame = await new Promise((resolve) => { + requestAnimationFrame(() => + requestAnimationFrame(() => + resolve(new DOMMatrixReadOnly(getComputedStyle(paper).transform).m42) + ) + ) + }) + expect(firstExitFrame).toBeGreaterThan(draggedTo * 0.6) + await waitFor(() => expect(body.queryByText('Reset password')).not.toBeInTheDocument()) }, } diff --git a/frontend/tests/layouts/MobileNav.stories.jsx b/frontend/tests/layouts/MobileNav.stories.jsx new file mode 100644 index 0000000000..9bb0198e39 --- /dev/null +++ b/frontend/tests/layouts/MobileNav.stories.jsx @@ -0,0 +1,129 @@ +import React, { useState } from 'react' +import { within, expect, userEvent, waitFor } from 'storybook/test' +import { Box, Button } from '@mui/material' +import { MobileNav } from '../../src/layouts/mobile-nav' +import { shrinkToPhoneViewport } from '../viewport' + +const items = [ + { title: 'Dashboard', path: '/' }, + { + title: 'Identity Management', + path: '/identity', + items: [ + { title: 'Users', path: '/identity/administration/users' }, + { title: 'Groups', path: '/identity/administration/groups' }, + { title: 'Devices', path: '/identity/administration/devices' }, + ], + }, + { + title: 'Tenant Administration', + path: '/tenant', + items: [ + { title: 'Tenants', path: '/tenant/administration/tenants' }, + { title: 'Alerts', path: '/tenant/administration/alert-configuration' }, + ], + }, + { title: 'Tools', path: '/tools' }, + { title: 'Settings', path: '/cipp/settings' }, +] + +// Mirrors the open/close state Layout owns (layouts/index.js useMobileNav), so the drawer +// behaves here exactly as it does in the app. +const Harness = (props) => { + const [open, setOpen] = useState(false) + return ( + + + setOpen(true)} + onClose={() => setOpen(false)} + {...props} + /> + + ) +} + +export default { + title: 'Layouts/MobileNav', + component: MobileNav, + tags: ['autodocs'], + parameters: { + layout: 'fullscreen', + }, +} + +export const Default = { + render: () => , +} + +// Only a real browser can settle this: jsdom runs no transitions, so the frame the close +// animation starts from does not exist there. +export const DragClosesFromWhereItWasLeft = { + render: () => , + play: async ({ canvasElement }) => { + const onAPhone = await shrinkToPhoneViewport() + const canvas = within(canvasElement) + + await userEvent.click(canvas.getByTestId('open-nav')) + const paper = await waitFor(() => { + const node = document.querySelector('.MuiDrawer-paper') + expect(node).not.toBeNull() + return node + }) + if (!onAPhone) { + return + } + await waitFor(() => + expect(new DOMMatrixReadOnly(getComputedStyle(paper).transform).m41).toBe(0) + ) + + // Dispatched on a node inside the paper and left to bubble: MUI reads event.target to + // decide the gesture started in the drawer, so firing at the document bails immediately. + const target = paper.querySelector('nav') ?? paper + const at = (clientX) => + new Touch({ identifier: 1, target, clientX, clientY: 400, pageX: clientX, pageY: 400 }) + const fire = (type, clientX) => + target.dispatchEvent( + new TouchEvent(type, { + bubbles: true, + cancelable: true, + touches: type === 'touchend' ? [] : [at(clientX)], + changedTouches: [at(clientX)], + }) + ) + + // MUI flags "maybe swiping" in React state on touchstart and ignores moves until that has + // been applied, so the gesture has to be spread across ticks like a real one. + const tick = () => new Promise((resolve) => setTimeout(resolve, 30)) + fire('touchstart', 300) + await tick() + for (const x of [285, 230, 160, 80, 40]) { + fire('touchmove', x) + await tick() + } + + const draggedTo = new DOMMatrixReadOnly(getComputedStyle(paper).transform).m41 + expect(draggedTo).toBeLessThan(-100) + fire('touchend', 40) + + // The exit has to continue from where the finger let go. Slide probes the paper's + // untranslated position when the exit starts (Slide.js getTranslateValue), and the browser + // takes that probe as the transition's start, which snaps the drawer wide open first. + const firstExitFrame = await new Promise((resolve) => { + requestAnimationFrame(() => + requestAnimationFrame(() => + resolve(new DOMMatrixReadOnly(getComputedStyle(paper).transform).m41) + ) + ) + }) + expect(firstExitFrame).toBeLessThan(draggedTo * 0.6) + + await waitFor(() => + expect(document.querySelector('.MuiDrawer-root').getAttribute('aria-hidden')).toBe('true') + ) + }, +} diff --git a/frontend/tests/layouts/MobileNav.test.jsx b/frontend/tests/layouts/MobileNav.test.jsx new file mode 100644 index 0000000000..6dc18ba068 --- /dev/null +++ b/frontend/tests/layouts/MobileNav.test.jsx @@ -0,0 +1,77 @@ +import React from 'react' +import { describe, it, expect, vi } from 'vitest' +import { act } from '@testing-library/react' +import { renderWithProviders, settingsWith } from '../test-utils' + +vi.mock('next/navigation', () => ({ + usePathname: () => '/', + useRouter: () => ({ push: vi.fn() }), + useSearchParams: () => new URLSearchParams(''), +})) + +const idle = vi.hoisted(() => ({ + isSuccess: false, + isFetching: false, + isPending: false, + isError: false, + data: undefined, + mutate: () => {}, + reset: () => {}, + refetch: () => {}, +})) +vi.mock('../../src/api/ApiCall', () => ({ + ApiGetCall: () => idle, + ApiPostCall: () => idle, + ApiGetCallWithPagination: () => ({ ...idle, fetchNextPage: () => {} }), +})) + +import { MobileNav } from '../../src/layouts/mobile-nav' + +const items = [{ title: 'Dashboard', path: '/' }] + +// MUI binds touchstart/touchmove/touchend on the document, so the swipe lifecycle is driven +// with native events; userEvent emits pointer/mouse, which SwipeableDrawer ignores. +const touch = (el, type, x = 5, y = 200) => { + const event = new Event(type, { bubbles: true, cancelable: true }) + const point = { pageX: x, pageY: y, clientX: x, clientY: y } + Object.defineProperty(event, 'touches', { value: type === 'touchend' ? [] : [point] }) + Object.defineProperty(event, 'changedTouches', { value: [point] }) + act(() => { + el.dispatchEvent(event) + }) +} + +const renderNav = (props = {}) => { + const onOpen = vi.fn() + const onClose = vi.fn() + renderWithProviders( + , + { settings: settingsWith({ bookmarkSidebar: false }) } + ) + return { onOpen, onClose } +} + +describe('MobileNav', () => { + it('renders no edge swipe area', () => { + renderNav() + expect(document.querySelector('.PrivateSwipeArea-root')).toBeNull() + }) + + // MUI forces the modal open while a swipe is in progress (maybeSwiping), and a touch with no + // movement never sets isSwiping, so handleBodyTouchEnd bails before onOpen/onClose. The drawer + // animates in and straight back out with the app's open state untouched. + it('leaves the drawer closed on a left-edge tap', () => { + const { onOpen, onClose } = renderNav() + const target = document.querySelector('.PrivateSwipeArea-root') ?? document.body + const drawer = document.querySelector('.MuiDrawer-root') + expect(drawer.getAttribute('aria-hidden')).toBe('true') + + touch(target, 'touchstart') + expect(drawer.getAttribute('aria-hidden')).toBe('true') + + touch(target, 'touchend') + expect(drawer.getAttribute('aria-hidden')).toBe('true') + expect(onOpen).not.toHaveBeenCalled() + expect(onClose).not.toHaveBeenCalled() + }) +}) From d3ed0d3ba0d2da629118a161a6daac31b0f61105 Mon Sep 17 00:00:00 2001 From: k-grube Date: Wed, 12 Aug 2026 00:02:46 -0700 Subject: [PATCH 030/226] feat(mobile): session toggle between card view and the full table card lists gain a per-session toggle to the real MRT table with whatever columns the page shows; the table toolbar carries a return button. the toggle never persists; refresh resets to the tableViewMode preference. phone table chrome is rebuilt around one shared bottom sheet: page actions move into the actions fab, bulk actions into the table header, and the sheet (opened from a kebab labeled Table options on both bars) carries data source controls, presets, fields shown, export, refresh and rows-per-page. the footer slims to range plus prev/next below md (mrt wraps a full footer under its 720px pivot), and narrow viewports size the table's scroll viewport from measurement (container position plus footer height, scroll reset on flip) so desktop-tuned maxHeightOffset numbers cannot stack a second scrollbar on phones. filter and search state now lives in CippDataTable rather than the toolbar: the cards and table branches mount alternating toolbar instances, and toolbar-local state plus mount effects previously wiped graph filters, column changes, preset highlights and bulk selection on every flip. pages pass dataSourceControls (live/cached + sync) as its own prop; desktop renders it in the card header, phones only in the sheet. card view renders on the same Card surface as the table path (overflow visible keeps the controls bar sticky), and the card bar adopts the desktop toolbar's tonal primitives, extracted to toolbar-primitives.js. also adds tableViewMode to the preferences save allowlist; it was silently unsaveable since it shipped. --- .../CippComponents/CippSettingsSideBar.jsx | 1 + .../CippTable/CIPPTableToptoolbar.js | 482 +++++++++--------- .../src/components/CippTable/CippDataTable.js | 318 ++++++++++-- .../CippTable/CippMobileTableControls.jsx | 250 +++------ .../CippTable/CippTableFilterSheet.jsx | 211 ++++++++ .../CippTable/toolbar-primitives.js | 103 ++++ .../components/CippTable/util-tablemode.js | 14 +- frontend/src/hooks/use-breakpoint.js | 2 +- .../administration/hve-accounts/index.js | 2 +- .../administration/mailbox-rules/index.js | 2 +- .../email/administration/mailboxes/index.js | 2 +- .../tenant-allow-block-lists/index.js | 2 +- .../SharedMailboxEnabledAccount/index.js | 2 +- .../reports/calendar-permissions/index.js | 2 +- .../email/reports/mailbox-forwarding/index.js | 2 +- .../reports/mailbox-permissions/index.js | 2 +- .../endpoint/MEM/assignment-filters/index.js | 2 +- .../MEM/list-appprotection-policies/index.js | 2 +- .../MEM/list-compliance-policies/index.js | 2 +- .../pages/endpoint/MEM/list-policies/index.js | 2 +- .../pages/endpoint/MEM/list-scripts/index.jsx | 2 +- .../endpoint/MEM/reusable-settings/index.js | 2 +- .../pages/endpoint/applications/list/index.js | 2 +- .../identity/administration/groups/index.js | 2 +- .../reports/inactive-users-report/index.js | 2 +- .../identity/reports/mfa-report/index.js | 2 +- .../security/reports/mde-onboarding/index.js | 2 +- .../src/pages/teams-share/onedrive/index.js | 2 +- .../src/pages/teams-share/sharepoint/index.js | 2 +- .../teams-share/teams/business-voice/index.js | 2 +- .../teams-share/teams/list-team/index.js | 2 +- .../teams-share/teams/teams-activity/index.js | 2 +- .../reports/application-consent/index.js | 2 +- .../CippSettingsSideBar.test.jsx | 46 ++ .../CippTable/CIPPTableToptoolbar.test.jsx | 22 + .../CippTable/CippMobileCardList.stories.jsx | 185 ++++++- .../CippTable/CippMobileCardList.test.jsx | 320 ++++++++++++ .../CippTable/util-tablemode.test.jsx | 20 + .../CippWizardAutopilotImport.test.jsx | 4 +- frontend/tests/layouts/TabbedLayout.test.jsx | 4 +- 40 files changed, 1523 insertions(+), 509 deletions(-) create mode 100644 frontend/src/components/CippTable/CippTableFilterSheet.jsx create mode 100644 frontend/src/components/CippTable/toolbar-primitives.js create mode 100644 frontend/tests/components/CippComponents/CippSettingsSideBar.test.jsx create mode 100644 frontend/tests/components/CippTable/CippMobileCardList.test.jsx diff --git a/frontend/src/components/CippComponents/CippSettingsSideBar.jsx b/frontend/src/components/CippComponents/CippSettingsSideBar.jsx index a2b9635953..feaebdda60 100644 --- a/frontend/src/components/CippComponents/CippSettingsSideBar.jsx +++ b/frontend/src/components/CippComponents/CippSettingsSideBar.jsx @@ -60,6 +60,7 @@ export const CippSettingsSideBar = (props) => { // General Settings usageLocation: formValues.usageLocation, tablePageSize: formValues.tablePageSize, + tableViewMode: formValues.tableViewMode, defaultTestSuite: formValues.defaultTestSuite, userAttributes: formValues.userAttributes, diff --git a/frontend/src/components/CippTable/CIPPTableToptoolbar.js b/frontend/src/components/CippTable/CIPPTableToptoolbar.js index 7cc3612af4..9a7a5c6d0d 100644 --- a/frontend/src/components/CippTable/CIPPTableToptoolbar.js +++ b/frontend/src/components/CippTable/CIPPTableToptoolbar.js @@ -1,5 +1,7 @@ import React, { useState, useEffect, useRef, useCallback } from 'react' +import { createPortal } from 'react-dom' import { + Badge, Box, Button, Menu, @@ -11,8 +13,6 @@ import { IconButton, Tooltip, Typography, - InputBase, - Paper, Checkbox, SvgIcon, Dialog, @@ -33,12 +33,12 @@ import { Check as CheckIcon, MoreVert as MoreVertIcon, Fullscreen as FullscreenIcon, + ViewAgenda, } from '@mui/icons-material' import { ExclamationCircleIcon, ChevronDownIcon, } from '@heroicons/react/24/outline' -import { styled, alpha } from '@mui/material/styles' import { PDFExportButton, exportRowsToPdf } from '../pdfExportButton' import { CSVExportButton, exportRowsToCsv } from '../csvExportButton' import { getCippTranslation } from '../../utils/get-cipp-translation' @@ -57,91 +57,15 @@ import GraphExplorerPresets from '../../data/GraphExplorerPresets.json' import CippGraphExplorerFilter from './CippGraphExplorerFilter' import { Stack } from '@mui/system' import { CippMobileTableControls } from './CippMobileTableControls' +import { CippTableFilterSheet } from './CippTableFilterSheet' +import { useSheetHandoff } from '../../hooks/use-sheet-handoff' -// Styled components for modern design -const ModernSearchContainer = styled(Paper)(({ theme }) => ({ - display: 'flex', - alignItems: 'center', - width: '100%', - maxWidth: '300px', - minWidth: '200px', - height: '40px', - backgroundColor: theme.palette.mode === 'dark' ? '#2A2D3A' : '#F8F9FA', - border: `1px solid ${theme.palette.mode === 'dark' ? '#404040' : '#E0E0E0'}`, - borderRadius: '8px', - padding: '0 12px', - '&:hover': { - borderColor: theme.palette.primary.main, - }, - '&:focus-within': { - borderColor: theme.palette.primary.main, - boxShadow: `0 0 0 2px ${alpha(theme.palette.primary.main, 0.2)}`, - }, - [theme.breakpoints.down('md')]: { - minWidth: '0', - maxWidth: 'none', - flex: 1, - }, -})) - -const ModernSearchInput = styled(InputBase)(({ theme }) => ({ - marginLeft: theme.spacing(1), - flex: 1, - fontSize: '14px', - '& .MuiInputBase-input': { - padding: '8px 0', - '&::placeholder': { - color: theme.palette.text.secondary, - opacity: 0.7, - }, - }, -})) - -const ModernButton = styled(Button)(({ theme }) => ({ - height: '40px', - borderRadius: '8px', - textTransform: 'none', - fontWeight: 500, - fontSize: '14px', - padding: '8px 16px', - backgroundColor: theme.palette.mode === 'dark' ? '#2A2D3A' : '#F8F9FA', - border: `1px solid ${theme.palette.mode === 'dark' ? '#404040' : '#E0E0E0'}`, - color: theme.palette.text.primary, - minWidth: 'auto', - whiteSpace: 'nowrap', - '&:hover': { - backgroundColor: theme.palette.mode === 'dark' ? '#363A4A' : '#F0F0F0', - borderColor: theme.palette.primary.main, - }, - '& .MuiButton-startIcon': { - marginRight: '8px', - }, - '& .MuiButton-endIcon': { - marginLeft: '8px', - }, - [theme.breakpoints.down('md')]: { - padding: '8px 12px', - fontSize: '13px', - '& .MuiButton-startIcon': { - marginRight: '6px', - }, - '& .MuiButton-endIcon': { - marginLeft: '6px', - }, - }, - [theme.breakpoints.down('sm')]: { - padding: '8px 10px', - fontSize: '12px', - '& .MuiButton-startIcon': { - marginRight: '4px', - }, - '& .MuiButton-endIcon': { - marginLeft: '4px', - }, - }, -})) - -const RefreshButton = styled(IconButton)(({ theme }) => ({})) +import { + ModernSearchContainer, + ModernSearchInput, + ModernButton, + RefreshButton, +} from './toolbar-primitives' export const CIPPTableToptoolbar = React.memo( ({ @@ -173,13 +97,31 @@ export const CIPPTableToptoolbar = React.memo( selectMode = false, onSelectModeChange, selectModeLocked = false, + onViewToggle, + tableViewActive = false, + showReturnToCards = false, + // when set, the selection count + Bulk Actions button portal into this node + // (the Card header's slot) rather than rendering inline in the toolbar + bulkActionsSlot = null, + // Live/Cached data-source controls, rendered in the mobile Table options sheet + dataSourceControls, + // Owned by CippDataTable: this toolbar mounts as two alternating instances (the cards + // branch and the renderTopToolbar branch), so state that must survive the cards<->table + // flip is passed down as props rather than kept in local useState/useRef here. + activeFilters = { graph: null, table: null }, + setActiveFilters, + searchValue = '', + setSearchValue, + restoredFiltersRef, }) => { const popover = usePopover() const [filtersAnchor, setFiltersAnchor] = useState(null) const [columnsAnchor, setColumnsAnchor] = useState(null) const [exportAnchor, setExportAnchor] = useState(null) const [actionMenuAnchor, setActionMenuAnchor] = useState(null) - const [searchValue, setSearchValue] = useState('') + const [mobileFilterSheetOpen, setMobileFilterSheetOpen] = useState(false) + // table branch's own handoff instance — the cards branch (CippMobileTableControls) owns a separate one + const mobileFilterSheet = useSheetHandoff(() => setMobileFilterSheetOpen(false)) const mdDown = useMediaQuery((theme) => theme.breakpoints.down('md')) const settings = useSettings() @@ -200,13 +142,8 @@ export const CIPPTableToptoolbar = React.memo( const [originalSimpleColumns, setOriginalSimpleColumns] = useState(simpleColumns) const [filterCanvasVisible, setFilterCanvasVisible] = useState(false) - const [activeFilters, setActiveFilters] = useState({ - graph: null, - table: null, - }) const presetKey = (filter) => filter?.id ?? filter?.filterName const pageName = router.pathname.split('/').slice(1).join('/') - const currentTenant = settings?.currentTenant const [useCompactMode, setUseCompactMode] = useState(false) const toolbarRef = useRef(null) const leftContainerRef = useRef(null) @@ -321,43 +258,12 @@ export const CIPPTableToptoolbar = React.memo( closeMenu() } - // Track if we've restored filters for this page to prevent infinite loops - const restoredFiltersRef = useRef(new Set()) - - useEffect(() => { - //if usedData changes, deselect all rows - table.toggleAllRowsSelected(false) - }, [usedData]) - - // Sync currentEffectiveQueryKey with queryKey prop changes (e.g., tenant changes) + // Sync currentEffectiveQueryKey with queryKey prop changes (e.g., tenant changes) — a + // plain re-derivation from the same props this instance was given, harmless on remount useEffect(() => { setCurrentEffectiveQueryKey(queryKey || title) - // Clear active filter name when query key changes (page load, tenant change, etc.) - setActiveFilters({ graph: null, table: null }) }, [queryKey, title]) - //if the currentTenant Switches, remove Graph filters - useEffect(() => { - if (currentTenant) { - setGraphFilterData({}) - // Clear active filter name when tenant changes - setActiveFilters({ graph: null, table: null }) - // Clear restoration tracking so saved filters can be re-applied - const restorationKey = `${pageName}-graph` - restoredFiltersRef.current.delete(restorationKey) - } - }, [currentTenant, pageName]) - - //useEffect to set the column visibility to the preferred columns if they exist - useEffect(() => { - if ( - settings?.columnDefaults?.[pageName] && - Object.keys(settings?.columnDefaults?.[pageName]).length > 0 - ) { - setColumnVisibility(settings?.columnDefaults?.[pageName]) - } - }, [settings?.columnDefaults?.[pageName], router, usedColumns]) - useEffect(() => { setOriginalSimpleColumns(simpleColumns) }, [simpleColumns]) @@ -436,11 +342,6 @@ export const CIPPTableToptoolbar = React.memo( title, ]) - // Clear restoration tracking when page changes - useEffect(() => { - restoredFiltersRef.current.clear() - }, [pageName]) - // Detect overflow and switch to compact mode useEffect(() => { const checkOverflow = () => { @@ -990,6 +891,72 @@ export const CIPPTableToptoolbar = React.memo( ) + // count + button share this gate whether rendered inline or portaled into the header + const bulkActionsContent = ( + <> + {(table.getIsAllRowsSelected() || table.getIsSomeRowsSelected()) && ( + + {table.getSelectedRowModel().rows.length} rows selected + + )} + + {showBulkActionsButton && ( + + )} + + ) + + // feeds both CippMobileTableControls (cards) and CippTableFilterSheet (table branch) + const mobileColumnItems = table + .getAllColumns() + .filter((column) => !column.id.startsWith('mrt-')) + .map((column) => ({ + id: column.id, + visible: Boolean(column.getIsVisible()), + })) + const handleToggleColumn = (columnId, visible) => + setColumnVisibility({ ...columnVisibility, [columnId]: !visible }) + const handleExportCsvClick = () => + document.querySelector(`[data-csv-export="${title}"]`)?.click() + const handleExportPdfClick = () => + document.querySelector(`[data-pdf-export="${title}"]`)?.click() + const handleViewApiResponse = () => + isInDialog ? setJsonDialogOpen(true) : setOffcanvasVisible(true) + const handleEditGraphFilters = + api?.url === '/api/ListGraphRequest' ? () => setFilterCanvasVisible(true) : undefined + const handleResetFilters = () => setTableFilter('', 'reset', '') + const mobileIsRefreshing = Boolean( + getRequestData?.isFetching || refreshFunction?.isFetching + ) + return ( <> {viewMode === 'cards' ? ( @@ -998,13 +965,12 @@ export const CIPPTableToptoolbar = React.memo( searchValue={searchValue} onSearchChange={handleSearchChange} onRefresh={handleRefresh} - isRefreshing={Boolean( - getRequestData?.isFetching || refreshFunction?.isFetching - )} + isRefreshing={mobileIsRefreshing} selectionEnabled={Boolean(table.options.enableRowSelection)} selectMode={selectMode} onSelectModeChange={onSelectModeChange} selectModeLocked={selectModeLocked} + onViewToggle={onViewToggle} customBulkActions={customBulkActions} onBulkAction={handleBulkAction} graphPresetItems={graphPresetItems} @@ -1013,32 +979,14 @@ export const CIPPTableToptoolbar = React.memo( activeSlotCount={activeSlotCount} presetKey={presetKey} onPresetClick={handlePresetClick} - onResetFilters={() => setTableFilter('', 'reset', '')} - onEditGraphFilters={ - api?.url === '/api/ListGraphRequest' - ? () => setFilterCanvasVisible(true) - : undefined - } - columnItems={table - .getAllColumns() - .filter((column) => !column.id.startsWith('mrt-')) - .map((column) => ({ - id: column.id, - visible: Boolean(column.getIsVisible()), - }))} - onToggleColumn={(columnId, visible) => - setColumnVisibility({ ...columnVisibility, [columnId]: !visible }) - } + onResetFilters={handleResetFilters} + onEditGraphFilters={handleEditGraphFilters} + columnItems={mobileColumnItems} + onToggleColumn={handleToggleColumn} exportEnabled={exportEnabled} - onExportCsv={() => - document.querySelector(`[data-csv-export="${title}"]`)?.click() - } - onExportPdf={() => - document.querySelector(`[data-pdf-export="${title}"]`)?.click() - } - onViewApiResponse={() => - isInDialog ? setJsonDialogOpen(true) : setOffcanvasVisible(true) - } + onExportCsv={handleExportCsvClick} + onExportPdf={handleExportPdfClick} + onViewApiResponse={handleViewApiResponse} fixedChrome={!isInDialog} queueTracker={ queueMetadata?.QueueId ? ( @@ -1049,8 +997,10 @@ export const CIPPTableToptoolbar = React.memo( /> ) : undefined } + dataSourceControls={dataSourceControls} /> ) : ( + <> - {/* Refresh Button */} - - - - + + - {getRequestData?.isFetchNextPageError ? ( - - ) : ( - - )} - - - - + + {getRequestData?.isFetchNextPageError ? ( + + ) : ( + + )} + + + + + )} {/* Search Input */} @@ -1228,8 +1180,8 @@ export const CIPPTableToptoolbar = React.memo( )} - {/* Mobile/Compact Action Button */} - {(mdDown || useCompactMode) && !hasSelection && ( + {/* Compact Action Button — desktop compact mode only, the phone table uses the filter sheet */} + {!mdDown && useCompactMode && !hasSelection && ( setActionMenuAnchor(event.currentTarget)} sx={{ flexShrink: 0 }} @@ -1238,7 +1190,50 @@ export const CIPPTableToptoolbar = React.memo( )} - {/* Mobile Action Menu */} + {/* phones keep the kebab open regardless of selection, the only route to the + sheet (refresh, export, rows-per-page) down there, not just filters */} + {(mdDown || (useCompactMode && !hasSelection)) && ( + { + if (mdDown) { + setMobileFilterSheetOpen(true) + return + } + setFiltersAnchor(event.currentTarget) + }} + sx={{ + flexShrink: 0, + ...(mdDown && activeSlotCount > 0 && { color: 'primary.main' }), + }} + > + {mdDown ? ( + + + + ) : ( + + )} + + )} + + {/* way back to cards, far right to match the card bar's toggle position */} + {tableViewActive && showReturnToCards && ( + + + + {/* destination icon: tapping here returns to cards */} + + + + + )} + + {/* Compact Action Menu — desktop compact mode only */} { - setFiltersAnchor(actionMenuAnchor) - setActionMenuAnchor(null) - }} - > - - - - Filters - { setColumnsAnchor(actionMenuAnchor) @@ -1515,46 +1499,8 @@ export const CIPPTableToptoolbar = React.memo( mt: { xs: 1, md: 0 }, }} > - {/* Selected rows indicator */} - {(table.getIsAllRowsSelected() || - table.getIsSomeRowsSelected()) && ( - - {table.getSelectedRowModel().rows.length} rows selected - - )} - - {/* Bulk Actions - inline with toolbar */} - {showBulkActionsButton && ( - - )} + {/* Selected rows indicator + Bulk Actions - inline, unless portaled into the header */} + {!bulkActionsSlot && bulkActionsContent} {/* Queue tracker */} + table.setPageSize(size)} + pageSizeOptions={[25, 50, 100, 250, 500]} + dataSourceControls={dataSourceControls} + /> + )} + {bulkActionsSlot && createPortal(bulkActionsContent, bulkActionsSlot)} + {/* Hidden export buttons for triggering — outside the mode branch so the mobile filter sheet's export items can click them too */} diff --git a/frontend/src/components/CippTable/CippDataTable.js b/frontend/src/components/CippTable/CippDataTable.js index 821eaabff3..d97fdb04a8 100644 --- a/frontend/src/components/CippTable/CippDataTable.js +++ b/frontend/src/components/CippTable/CippDataTable.js @@ -14,6 +14,7 @@ import { ResourceUnavailable } from '../resource-unavailable' import { ResourceError } from '../resource-error' import { Scrollbar } from '../scrollbar' import { useCallback, useEffect, useMemo, useState, useRef } from 'react' +import { useRouter } from 'next/router' import { ApiGetCallWithPagination } from '../../api/ApiCall' import { utilTableMode } from './util-tablemode' import { @@ -26,13 +27,14 @@ import { CippOffCanvas } from '../CippComponents/CippOffCanvas' import { useDialog } from '../../hooks/use-dialog' import { CippApiDialog } from '../CippComponents/CippApiDialog' import { getCippError } from '../../utils/get-cipp-error' -import { Box } from '@mui/system' +import { Box, Stack } from '@mui/system' import { useSettings } from '../../hooks/use-settings' import { parseCippDate } from '../../utils/parse-cipp-date' import { isEqual } from 'lodash' // Import lodash for deep comparison import { useLicenseBackfill } from '../../hooks/use-license-backfill' -import { useTableViewMode } from '../../hooks/use-breakpoint' +import { useTableViewMode, useIsNarrowForTables } from '../../hooks/use-breakpoint' import { CippMobileCardList } from './CippMobileCardList' +import { CippPageActionsFab } from '../CippComponents/CippPageActionsFab' // Resolve dot-delimited property paths against arbitrary data objects. const getNestedValue = (source, path) => { @@ -83,6 +85,22 @@ const compareNullable = (aVal, bVal) => { return aVal > bVal ? 1 : -1 } +// walk up from the toggled node to the page's scrolling ancestor (LayoutContainer, +// overflowY auto) and reset it, so a narrow-table height measurement taken right after +// starts from a deterministic scroll position +const scrollScrollableAncestorToTop = (node) => { + let ancestor = node?.parentElement + while (ancestor && ancestor !== document.body) { + const overflowY = window.getComputedStyle(ancestor).overflowY + if (overflowY === 'auto' || overflowY === 'scroll') { + ancestor.scrollTop = 0 + return + } + ancestor = ancestor.parentElement + } + window.scrollTo(0, 0) +} + // ── Module-level constants ────────────────────────────────────────────────── // These never change between renders, so extracting them avoids creating new // object references on every render cycle. @@ -393,6 +411,7 @@ export const CippDataTable = (props) => { showBulkExportAction = true, viewMode: viewModeProp, mobileCard, + dataSourceControls, } = props // Create a map of column IDs to their filterType for quick lookup @@ -436,16 +455,40 @@ export const CippDataTable = (props) => { const [columnFilters, setColumnFilters] = useState([]) const waitingBool = api?.url ? true : false + // The cards branch and the renderTopToolbar branch are two alternating CIPPTableToptoolbar + // instances (only one is ever mounted), so state that must survive the cards<->table flip + // lives here and is passed down as props to both. + const [activeFilters, setActiveFilters] = useState({ graph: null, table: null }) + const [searchValue, setSearchValue] = useState('') + const restoredFiltersRef = useRef(new Set()) + const settings = useSettings() + const router = useRouter() + const pageName = router.pathname.split('/').slice(1).join('/') // 'cards' below the md breakpoint (or when forced via settings/prop), 'table' otherwise. // simple tables always resolve to 'table'. const resolvedViewMode = useTableViewMode({ viewMode: viewModeProp, simple }) - const isCardView = resolvedViewMode === 'cards' + // same pivot as the cards/table auto mode, so the FAB and the card list agree on width + const isNarrowViewport = useIsNarrowForTables() + // viewMode prop or simple is a hard force, the toggle never overrides it + const toggleAllowed = !viewModeProp && !simple // Mobile select mode: checkboxes on cards + the bottom bulk bar. Lives here so the // toolbar (which renders the Select toggle) and the card list stay in sync. Picker // tables (onChange) force it on — selection is their entire purpose. const [mobileSelectMode, setMobileSelectMode] = useState(false) + // portal target for the header's bulk-actions slot, set by the CardHeader's ref callback + const [headerBulkSlot, setHeaderBulkSlot] = useState(null) + + // transient cards<->table override, session-only, never persisted + const [viewOverride, setViewOverride] = useState(null) + const effectiveViewMode = toggleAllowed ? (viewOverride ?? resolvedViewMode) : resolvedViewMode + const isCardView = effectiveViewMode === 'cards' + const tableViewActive = effectiveViewMode === 'table' + const CardViewSurface = noCard ? Box : Card + const [narrowTableMaxHeight, setNarrowTableMaxHeight] = useState(null) + // way back button: table is only up because of the override, phone default is still cards + const showReturnToCards = toggleAllowed && tableViewActive && resolvedViewMode === 'cards' // Hook to trigger re-render when license backfill completes const { updateTrigger } = useLicenseBackfill() @@ -645,6 +688,62 @@ export const CippDataTable = (props) => { filterTypeMap, ]) + // Previous-value refs for the guards below: CippDataTable is the single owner of this + // state across both toolbar instances, so an effect can compare against the last value + // it actually saw rather than firing unconditionally on every render. + const prevTenantRef = useRef(settings?.currentTenant) + const prevQueryKeyRef = useRef(queryKey || title) + const prevPageNameRef = useRef(pageName) + const appliedColumnDefaultsRef = useRef({}) + + // if the currentTenant switches, remove graph filters and the active-filter highlight + useEffect(() => { + const currentTenant = settings?.currentTenant + if (prevTenantRef.current === currentTenant) { + return + } + prevTenantRef.current = currentTenant + if (currentTenant) { + setGraphFilterData({}) + setActiveFilters({ graph: null, table: null }) + restoredFiltersRef.current.delete(`${pageName}-graph`) + } + }, [settings?.currentTenant, pageName]) + + // clear the active-filter highlight when the effective query key changes (tenant swap, + // different queryKey/title) + useEffect(() => { + const effectiveKey = queryKey || title + if (prevQueryKeyRef.current === effectiveKey) { + return + } + prevQueryKeyRef.current = effectiveKey + setActiveFilters({ graph: null, table: null }) + }, [queryKey, title]) + + // clear persisted-filter restoration tracking only when the page actually changes + useEffect(() => { + if (prevPageNameRef.current === pageName) { + return + } + prevPageNameRef.current = pageName + restoredFiltersRef.current.clear() + }, [pageName]) + + // apply preferred columns once per page, and again whenever the saved preference's + // identity changes + useEffect(() => { + const preferred = settings?.columnDefaults?.[pageName] + if ( + preferred && + Object.keys(preferred).length > 0 && + appliedColumnDefaultsRef.current[pageName] !== preferred + ) { + appliedColumnDefaultsRef.current[pageName] = preferred + setColumnVisibility(preferred) + } + }, [settings?.columnDefaults?.[pageName], pageName]) + const createDialog = useDialog() const hasActions = !!actions const hasOffCanvas = !!offCanvas @@ -662,7 +761,8 @@ export const CippDataTable = (props) => { onChange, maxHeightOffset, settings, - resolvedViewMode + effectiveViewMode, + isNarrowViewport ), [ simple, @@ -671,7 +771,8 @@ export const CippDataTable = (props) => { hasOnChange, maxHeightOffset, settings?.tablePageSize?.value, - resolvedViewMode, + effectiveViewMode, + isNarrowViewport, ] ) @@ -855,6 +956,16 @@ export const CippDataTable = (props) => { // eslint-disable-next-line react-hooks/exhaustive-deps }, []) + // the flipped table shows whatever columns are visible; horizontal scroll covers the width + const handleViewToggle = useCallback((event) => { + const nextView = effectiveViewMode === 'table' ? 'cards' : 'table' + setViewOverride(nextView) + if (nextView === 'table' && isNarrowViewport) { + scrollScrollableAncestorToTop(event?.currentTarget) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [effectiveViewMode, isNarrowViewport]) + // Memoize renderRowActionMenuItems to avoid re-creating on each render. const renderRowActionMenuItems = useMemo(() => { if (actions) { @@ -952,6 +1063,16 @@ export const CippDataTable = (props) => { queueMetadata={getRequestData.data?.pages?.[0]?.Metadata} isInDialog={isInDialog} showBulkExportAction={showBulkExportAction} + onViewToggle={toggleAllowed ? handleViewToggle : undefined} + tableViewActive={toggleAllowed ? tableViewActive : undefined} + showReturnToCards={showReturnToCards} + bulkActionsSlot={isNarrowViewport && !isInDialog ? headerBulkSlot : null} + dataSourceControls={dataSourceControls} + activeFilters={activeFilters} + setActiveFilters={setActiveFilters} + searchValue={searchValue} + setSearchValue={setSearchValue} + restoredFiltersRef={restoredFiltersRef} /> )} @@ -975,6 +1096,15 @@ export const CippDataTable = (props) => { graphFilterData, isInDialog, showBulkExportAction, + toggleAllowed, + handleViewToggle, + tableViewActive, + showReturnToCards, + isNarrowViewport, + headerBulkSlot, + dataSourceControls, + activeFilters, + searchValue, ] ) @@ -1005,6 +1135,18 @@ export const CippDataTable = (props) => { renderEmptyRowsFallback, onColumnVisibilityChange: setColumnVisibility, ...modeInfo, + // narrow table views size their scroll viewport from measurement (see the effect below), + // the modeInfo calc budget only holds for desktop chrome + ...(isNarrowViewport && + effectiveViewMode === 'table' && { + muiTableContainerProps: { + ...modeInfo.muiTableContainerProps, + sx: { + ...modeInfo.muiTableContainerProps?.sx, + maxHeight: narrowTableMaxHeight ? `${narrowTableMaxHeight}px` : 'none', + }, + }, + }), renderRowActionMenuItems, renderTopToolbar, sortingFns: SORTING_FNS, @@ -1015,6 +1157,64 @@ export const CippDataTable = (props) => { renderColumnFilterModeMenuItems: renderColumnFilterModeMenuItemsFn, }) + // deselect all rows when the underlying data set actually changes, guarded so a toolbar + // remount (cards<->table flip) does not wipe an in-progress selection + const prevUsedDataRef = useRef(memoizedData) + useEffect(() => { + if (prevUsedDataRef.current === memoizedData) { + return + } + prevUsedDataRef.current = memoizedData + table.toggleAllRowsSelected(false) + }, [memoizedData]) + + // size the narrow table's scroll viewport from where it actually sits: viewport height + // minus the container's measured top, the real footer height and the chrome below the + // paper. the desktop calc assumes chrome heights that phone layouts do not have. + // deps include getRequestData.isSuccess so a cold load (table not yet mounted when the + // toggle flips tableViewActive true) re-arms the measurement once MRT actually renders + useEffect(() => { + if (!(isNarrowViewport && tableViewActive)) { + setNarrowTableMaxHeight(null) + return undefined + } + const measure = () => { + const container = table.refs.tableContainerRef?.current + if (!container) { + return + } + const footer = table.refs.bottomToolbarRef?.current?.offsetHeight ?? 0 + // chrome between the paper's bottom edge and the page bottom (CardContent padding + page gap) + const BELOW_PAPER_PX = 40 + // viewport-relative, so a scrolled page needs the toggle handler to reset scroll first + const top = container.getBoundingClientRect().top + let next = Math.max(240, Math.floor(window.innerHeight - top - footer - BELOW_PAPER_PX)) + // 120 = minimal chrome allowance, keeps the table from claiming the full viewport + next = Math.min(next, window.innerHeight - 120) + setNarrowTableMaxHeight((prev) => { + if (prev !== null && Math.abs(prev - next) <= 1) { + return prev + } + return next + }) + } + const raf = requestAnimationFrame(() => requestAnimationFrame(measure)) + window.addEventListener('resize', measure) + let observer + // the paper mounts in the same commit as the container and the footer, so it is a + // reliable observation target even on the pass where the footer ref is still null + const paper = table.refs.tablePaperRef?.current + if (typeof ResizeObserver !== 'undefined' && paper) { + observer = new ResizeObserver(measure) + observer.observe(paper) + } + return () => { + cancelAnimationFrame(raf) + window.removeEventListener('resize', measure) + observer?.disconnect() + } + }, [isNarrowViewport, tableViewActive, table, getRequestData.isSuccess]) + // A card shows at most a title, subtitle, three chips and three detail rows, so the rest // of the row has to live in the drawer. On a page with no offCanvas that means every // column the user has chosen to show; on a page that configured one, its curated fields @@ -1105,10 +1305,19 @@ export const CippDataTable = (props) => { const selectModeActive = hasOnChange ? true : mobileSelectMode + // below md, table-in-Card branch: the actions FAB carries cardButton + const headerAction = isNarrowViewport && !isInDialog ? undefined : cardButton + return ( <> {isCardView ? ( - + // same paper surface as the table path; overflow visible keeps the controls bar sticky + {!hideTitle && ( { hasOnChange ? undefined : handleMobileSelectModeChange } selectModeLocked={hasOnChange} + onViewToggle={toggleAllowed ? handleViewToggle : undefined} + tableViewActive={toggleAllowed ? tableViewActive : undefined} + dataSourceControls={dataSourceControls} + activeFilters={activeFilters} + setActiveFilters={setActiveFilters} + searchValue={searchValue} + setSearchValue={setSearchValue} + restoredFiltersRef={restoredFiltersRef} /> { message={`Error Loading data: ${getCippError(getRequestData.error)}`} /> )} - + ) : noCard ? ( {!Array.isArray(usedData) && usedData ? ( @@ -1213,40 +1430,61 @@ export const CippDataTable = (props) => { ) : ( // Render the table inside a Card - - {cardButton || !hideTitle ? ( - <> - - - - ) : null} - - - {!Array.isArray(usedData) && usedData ? ( - - ) : ( - <> - {(getRequestData.isSuccess || - getRequestData.data?.pages.length >= 0 || - (data && !getRequestData.isError)) && ( - - )} - - )} - {getRequestData.isError && - !getRequestData.isFetchNextPageError && ( - getRequestData.refetch()} - message={`Error Loading data: ${getCippError(getRequestData.error)}`} - /> + <> + + {cardButton || !hideTitle ? ( + <> + + + {/* narrow viewports carry these in the Table options sheet */} + {dataSourceControls && !isNarrowViewport ? ( + + {dataSourceControls} + {headerAction} + + ) : ( + headerAction + )} + + } + title={hideTitle ? '' : title} + {...props.cardHeaderProps} + /> + + + ) : null} + + + {!Array.isArray(usedData) && usedData ? ( + + ) : ( + <> + {(getRequestData.isSuccess || + getRequestData.data?.pages.length >= 0 || + (data && !getRequestData.isError)) && ( + + )} + )} - - - + {getRequestData.isError && + !getRequestData.isFetchNextPageError && ( + getRequestData.refetch()} + message={`Error Loading data: ${getCippError(getRequestData.error)}`} + /> + )} + + + + {isNarrowViewport && !isInDialog && cardButton && ( + {cardButton} + )} + )} { selectMode = false, onSelectModeChange, selectModeLocked = false, + onViewToggle, customBulkActions = [], onBulkAction, graphPresetItems = [], @@ -66,6 +63,7 @@ export const CippMobileTableControls = (props) => { onViewApiResponse, fixedChrome = true, queueTracker, + dataSourceControls, } = props; const [sortOpen, setSortOpen] = useState(false); @@ -97,26 +95,6 @@ export const CippMobileTableControls = (props) => { const totalCount = table.getFilteredRowModel().rows.length; const enabledBulkActions = customBulkActions.filter((action) => !action.disabled); - const renderPresetChips = (items, layer) => ( - - {items.map((filter) => { - const key = presetKey(filter); - const active = activeFilters[layer]?.id === key; - return ( - : undefined} - onClick={() => onPresetClick(filter)} - sx={{ height: 36, borderRadius: 999 }} - /> - ); - })} - - ); - return ( <> { gap: 1, px: 1, py: 1, - bgcolor: "background.default", + // matches the card-view paper surface it sticks over + bgcolor: "background.paper", borderBottom: 1, borderColor: "divider", }} > - - - - } - sx={{ minHeight: 44, flex: 1, minWidth: 0 }} - /> + + + + {selectionEnabled && !selectModeLocked && ( - + )} - setSortOpen(true)} - sx={{ - minWidth: 44, - minHeight: 44, - border: 1, - borderColor: sorting.length ? "primary.main" : "divider", - borderRadius: 1, - color: sorting.length ? "primary.main" : "inherit", - flexShrink: 0, - }} + sx={sorting.length ? { borderColor: "primary.main", color: "primary.main" } : undefined} > - - + {/* kebab, the sheet is a grab-bag (presets, fields, export, refresh), not just filters */} + setFilterOpen(true)} - sx={{ - minWidth: 44, - minHeight: 44, - border: 1, - borderColor: activeSlotCount > 0 ? "primary.main" : "divider", - borderRadius: 1, - color: activeSlotCount > 0 ? "primary.main" : "inherit", - flexShrink: 0, - }} + sx={ + activeSlotCount > 0 + ? { borderColor: "primary.main", color: "primary.main" } + : undefined + } > - + - + + {onViewToggle && ( + + {/* destination icon: tapping here opens the table */} + + + )} {queueTracker && {queueTracker}} @@ -237,114 +209,28 @@ export const CippMobileTableControls = (props) => { {/* Filter sheet — presets first, then card fields, then table utilities */} - setFilterOpen(false)}> - Done - - } - > - {tablePresetItems.length > 0 && ( - <> - - Presets - - {renderPresetChips(tablePresetItems, "table")} - - )} - {graphPresetItems.length > 0 && ( - <> - - Graph filters - - {renderPresetChips(graphPresetItems, "graph")} - - )} - {columnItems.length > 0 && ( - <> - - Fields shown - - {columnItems.map((column) => ( - onToggleColumn(column.id, column.visible)} - sx={{ minHeight: 44, py: 0 }} - > - - - - ))} - - )} - - { - onResetFilters(); - setFilterOpen(false); - }} - sx={{ minHeight: 48 }} - > - - - - - - {onEditGraphFilters && ( - filterSheet.run(onEditGraphFilters)} - sx={{ minHeight: 48 }} - > - - - - - - )} - {exportEnabled && ( - <> - - - - - - - - - - - - - - )} - filterSheet.run(onViewApiResponse)} - sx={{ minHeight: 48 }} - > - - - - - - { - onRefresh(); - setFilterOpen(false); - }} - sx={{ minHeight: 48 }} - > - - - - - - + run={filterSheet.run} + tablePresetItems={tablePresetItems} + graphPresetItems={graphPresetItems} + activeFilters={activeFilters} + presetKey={presetKey} + onPresetClick={onPresetClick} + columnItems={columnItems} + onToggleColumn={onToggleColumn} + onResetFilters={onResetFilters} + onEditGraphFilters={onEditGraphFilters} + exportEnabled={exportEnabled} + onExportCsv={onExportCsv} + onExportPdf={onExportPdf} + onViewApiResponse={onViewApiResponse} + onRefresh={onRefresh} + isRefreshing={isRefreshing} + dataSourceControls={dataSourceControls} + /> {/* Bulk action bar — bottom, in thumb reach, instead of the desktop top-toolbar strip */} {selectMode && selectionEnabled && ( diff --git a/frontend/src/components/CippTable/CippTableFilterSheet.jsx b/frontend/src/components/CippTable/CippTableFilterSheet.jsx new file mode 100644 index 0000000000..408fc9c6c7 --- /dev/null +++ b/frontend/src/components/CippTable/CippTableFilterSheet.jsx @@ -0,0 +1,211 @@ +import { + Box, + Button, + Checkbox, + Chip, + Divider, + ListItemButton, + ListItemIcon, + ListItemText, + ListSubheader, + Stack, +} from "@mui/material"; +import { + Check, + DataObject, + FileDownload, + FilterList, + PictureAsPdf, + RestartAlt, + Sync, +} from "@mui/icons-material"; +import { getCippTranslation } from "../../utils/get-cipp-translation"; +import { CippBottomSheet } from "../CippComponents/CippBottomSheet"; + +// Shared filter bottom sheet — presets, field visibility, and the export/API-response +// utilities that live behind the desktop Filters menu on narrow layouts. Used by the +// mobile card list and the mobile/compact table toolbar, one code path for both. +export const CippTableFilterSheet = (props) => { + const { + open, + onClose, + onExited, + run, + tablePresetItems = [], + graphPresetItems = [], + activeFilters = { graph: null, table: null }, + presetKey, + onPresetClick, + columnItems = [], + onToggleColumn, + onResetFilters, + onEditGraphFilters, + exportEnabled = false, + onExportCsv, + onExportPdf, + onViewApiResponse, + onRefresh, + isRefreshing = false, + // section renders only when onPageSizeChange is provided (the table-view sheet) + pageSize, + onPageSizeChange, + pageSizeOptions = [], + dataSourceControls, + } = props; + + const renderPresetChips = (items, layer) => ( + + {items.map((filter) => { + const key = presetKey(filter); + const active = activeFilters[layer]?.id === key; + return ( + : undefined} + onClick={() => onPresetClick(filter)} + sx={{ height: 36, borderRadius: 999 }} + /> + ); + })} + + ); + + return ( + + Done + + } + > + {dataSourceControls && ( + <> + + Data source + + {dataSourceControls} + + )} + {tablePresetItems.length > 0 && ( + <> + + Presets + + {renderPresetChips(tablePresetItems, "table")} + + )} + {graphPresetItems.length > 0 && ( + <> + + Graph filters + + {renderPresetChips(graphPresetItems, "graph")} + + )} + {columnItems.length > 0 && ( + <> + + Fields shown + + {columnItems.map((column) => ( + onToggleColumn(column.id, column.visible)} + sx={{ minHeight: 44, py: 0 }} + > + + + + ))} + + )} + {onPageSizeChange && pageSizeOptions.length > 0 && ( + <> + + Rows per page + + + {pageSizeOptions.map((option) => { + const active = option === pageSize; + return ( + : undefined} + onClick={() => onPageSizeChange(option)} + sx={{ height: 36, borderRadius: 999 }} + /> + ); + })} + + + )} + + { + onResetFilters(); + onClose(); + }} + sx={{ minHeight: 48 }} + > + + + + + + {onEditGraphFilters && ( + run(onEditGraphFilters)} sx={{ minHeight: 48 }}> + + + + + + )} + {exportEnabled && ( + <> + + + + + + + + + + + + + + )} + run(onViewApiResponse)} sx={{ minHeight: 48 }}> + + + + + + { + onRefresh(); + onClose(); + }} + sx={{ minHeight: 48 }} + > + + + + + + + ); +}; diff --git a/frontend/src/components/CippTable/toolbar-primitives.js b/frontend/src/components/CippTable/toolbar-primitives.js new file mode 100644 index 0000000000..cfea4a82d5 --- /dev/null +++ b/frontend/src/components/CippTable/toolbar-primitives.js @@ -0,0 +1,103 @@ +import { styled, alpha } from '@mui/material/styles' +import { Button, IconButton, InputBase, Paper } from '@mui/material' + +// shared toolbar styling for the desktop table toolbar and the mobile card controls bar + +export const ModernSearchContainer = styled(Paper)(({ theme }) => ({ + display: 'flex', + alignItems: 'center', + width: '100%', + maxWidth: '300px', + minWidth: '200px', + height: '40px', + backgroundColor: theme.palette.mode === 'dark' ? '#2A2D3A' : '#F8F9FA', + border: `1px solid ${theme.palette.mode === 'dark' ? '#404040' : '#E0E0E0'}`, + borderRadius: '8px', + padding: '0 12px', + '&:hover': { + borderColor: theme.palette.primary.main, + }, + '&:focus-within': { + borderColor: theme.palette.primary.main, + boxShadow: `0 0 0 2px ${alpha(theme.palette.primary.main, 0.2)}`, + }, + [theme.breakpoints.down('md')]: { + minWidth: '0', + maxWidth: 'none', + flex: 1, + }, +})) + +export const ModernSearchInput = styled(InputBase)(({ theme }) => ({ + marginLeft: theme.spacing(1), + flex: 1, + fontSize: '14px', + '& .MuiInputBase-input': { + padding: '8px 0', + '&::placeholder': { + color: theme.palette.text.secondary, + opacity: 0.7, + }, + }, +})) + +export const ModernButton = styled(Button)(({ theme }) => ({ + height: '40px', + borderRadius: '8px', + textTransform: 'none', + fontWeight: 500, + fontSize: '14px', + padding: '8px 16px', + backgroundColor: theme.palette.mode === 'dark' ? '#2A2D3A' : '#F8F9FA', + border: `1px solid ${theme.palette.mode === 'dark' ? '#404040' : '#E0E0E0'}`, + color: theme.palette.text.primary, + minWidth: 'auto', + whiteSpace: 'nowrap', + '&:hover': { + backgroundColor: theme.palette.mode === 'dark' ? '#363A4A' : '#F0F0F0', + borderColor: theme.palette.primary.main, + }, + '& .MuiButton-startIcon': { + marginRight: '8px', + }, + '& .MuiButton-endIcon': { + marginLeft: '8px', + }, + [theme.breakpoints.down('md')]: { + padding: '8px 12px', + fontSize: '13px', + '& .MuiButton-startIcon': { + marginRight: '6px', + }, + '& .MuiButton-endIcon': { + marginLeft: '6px', + }, + }, + [theme.breakpoints.down('sm')]: { + padding: '8px 10px', + fontSize: '12px', + '& .MuiButton-startIcon': { + marginRight: '4px', + }, + '& .MuiButton-endIcon': { + marginLeft: '4px', + }, + }, +})) + +// tonal icon button matching ModernButton, 44px for phone touch targets +export const ModernIconButton = styled(IconButton)(({ theme }) => ({ + width: '44px', + height: '44px', + borderRadius: '8px', + backgroundColor: theme.palette.mode === 'dark' ? '#2A2D3A' : '#F8F9FA', + border: `1px solid ${theme.palette.mode === 'dark' ? '#404040' : '#E0E0E0'}`, + color: theme.palette.text.primary, + flexShrink: 0, + '&:hover': { + backgroundColor: theme.palette.mode === 'dark' ? '#363A4A' : '#F0F0F0', + borderColor: theme.palette.primary.main, + }, +})) + +export const RefreshButton = styled(IconButton)(({ theme }) => ({})) diff --git a/frontend/src/components/CippTable/util-tablemode.js b/frontend/src/components/CippTable/util-tablemode.js index bbc66b913d..92fbbcd2ab 100644 --- a/frontend/src/components/CippTable/util-tablemode.js +++ b/frontend/src/components/CippTable/util-tablemode.js @@ -11,7 +11,8 @@ export const utilTableMode = ( onChange, maxHeightOffset = '380px', settings = {}, - viewMode = 'table' + viewMode = 'table', + narrowTable = false ) => { if (mode === true) { return { @@ -63,9 +64,18 @@ export const utilTableMode = ( enableColumnPinning: !isCards, muiPaginationProps: { rowsPerPageOptions: [25, 50, 100, 250, 500], + // a full footer wraps below MRT's 720px pivot, the extra row scrolls the page chrome + ...(narrowTable && { + showRowsPerPage: false, + showFirstButton: false, + showLastButton: false, + }), }, muiTableContainerProps: { - sx: { maxHeight: `calc(100vh - ${maxHeightOffset})` }, + // offset numbers are tuned against desktop chrome, narrow viewports page-scroll + sx: { + maxHeight: narrowTable ? 'none' : `calc(100vh - ${maxHeightOffset})`, + }, }, displayColumnDefOptions: { 'mrt-row-actions': { diff --git a/frontend/src/hooks/use-breakpoint.js b/frontend/src/hooks/use-breakpoint.js index 4fff2b65e2..dedf377e95 100644 --- a/frontend/src/hooks/use-breakpoint.js +++ b/frontend/src/hooks/use-breakpoint.js @@ -8,7 +8,7 @@ import { useSettings } from "./use-settings"; export const useIsMobileLayout = () => useMediaQuery((theme) => theme.breakpoints.down("lg")); // Tables pivot narrower: a table still reads fine at 1100, cards that wide are mostly whitespace. -const useIsNarrowForTables = () => useMediaQuery((theme) => theme.breakpoints.down("md")); +export const useIsNarrowForTables = () => useMediaQuery((theme) => theme.breakpoints.down("md")); export const useIsTabletLayout = () => useMediaQuery((theme) => theme.breakpoints.between("sm", "md")); diff --git a/frontend/src/pages/email/administration/hve-accounts/index.js b/frontend/src/pages/email/administration/hve-accounts/index.js index 265884d446..3a786d57cd 100644 --- a/frontend/src/pages/email/administration/hve-accounts/index.js +++ b/frontend/src/pages/email/administration/hve-accounts/index.js @@ -190,9 +190,9 @@ const Page = () => { cardButton={ - {reportDB.controls} } + dataSourceControls={reportDB.controls} /> {reportDB.syncDialog} diff --git a/frontend/src/pages/email/administration/mailbox-rules/index.js b/frontend/src/pages/email/administration/mailbox-rules/index.js index 98f0d076ca..82980484b1 100644 --- a/frontend/src/pages/email/administration/mailbox-rules/index.js +++ b/frontend/src/pages/email/administration/mailbox-rules/index.js @@ -114,7 +114,7 @@ const Page = () => { simpleColumns={simpleColumns} offCanvas={offCanvas} actions={actions} - cardButton={reportDB.controls} + dataSourceControls={reportDB.controls} /> {reportDB.syncDialog} diff --git a/frontend/src/pages/email/administration/mailboxes/index.js b/frontend/src/pages/email/administration/mailboxes/index.js index bde310b633..23191895b4 100644 --- a/frontend/src/pages/email/administration/mailboxes/index.js +++ b/frontend/src/pages/email/administration/mailboxes/index.js @@ -101,9 +101,9 @@ const Page = () => { cardButton={ - {reportDB.controls} } + dataSourceControls={reportDB.controls} /> {reportDB.syncDialog} diff --git a/frontend/src/pages/email/administration/tenant-allow-block-lists/index.js b/frontend/src/pages/email/administration/tenant-allow-block-lists/index.js index 75d1431415..6a51ac4fbc 100644 --- a/frontend/src/pages/email/administration/tenant-allow-block-lists/index.js +++ b/frontend/src/pages/email/administration/tenant-allow-block-lists/index.js @@ -61,9 +61,9 @@ const Page = () => { cardButton={ - {reportDB.controls} } + dataSourceControls={reportDB.controls} /> {reportDB.syncDialog} diff --git a/frontend/src/pages/email/reports/SharedMailboxEnabledAccount/index.js b/frontend/src/pages/email/reports/SharedMailboxEnabledAccount/index.js index 0c9794f33e..79c81d1299 100644 --- a/frontend/src/pages/email/reports/SharedMailboxEnabledAccount/index.js +++ b/frontend/src/pages/email/reports/SharedMailboxEnabledAccount/index.js @@ -29,7 +29,7 @@ const Page = () => { title="Shared Mailbox with Enabled Account" apiUrl={reportDB.resolvedApiUrl} queryKey={reportDB.resolvedQueryKey} - cardButton={reportDB.controls} + dataSourceControls={reportDB.controls} actions={[ { label: "Block Sign In", diff --git a/frontend/src/pages/email/reports/calendar-permissions/index.js b/frontend/src/pages/email/reports/calendar-permissions/index.js index eb4620f1cf..1482b80e41 100644 --- a/frontend/src/pages/email/reports/calendar-permissions/index.js +++ b/frontend/src/pages/email/reports/calendar-permissions/index.js @@ -56,7 +56,6 @@ const Page = () => { variant="outlined" /> - {reportDB.controls} ) @@ -70,6 +69,7 @@ const Page = () => { apiData={{ ...reportDB.resolvedApiData, ByUser: byUser }} simpleColumns={columns} cardButton={pageActions} + dataSourceControls={reportDB.controls} offCanvas={null} /> {reportDB.syncDialog} diff --git a/frontend/src/pages/email/reports/mailbox-forwarding/index.js b/frontend/src/pages/email/reports/mailbox-forwarding/index.js index a24c324f98..8fbf2ee7aa 100644 --- a/frontend/src/pages/email/reports/mailbox-forwarding/index.js +++ b/frontend/src/pages/email/reports/mailbox-forwarding/index.js @@ -45,7 +45,7 @@ const Page = () => { apiData={reportDB.resolvedApiData} simpleColumns={columns} filters={filters} - cardButton={reportDB.controls} + dataSourceControls={reportDB.controls} offCanvas={null} /> {reportDB.syncDialog} diff --git a/frontend/src/pages/email/reports/mailbox-permissions/index.js b/frontend/src/pages/email/reports/mailbox-permissions/index.js index cef96534a3..bd05a2005a 100644 --- a/frontend/src/pages/email/reports/mailbox-permissions/index.js +++ b/frontend/src/pages/email/reports/mailbox-permissions/index.js @@ -181,7 +181,6 @@ const Page = () => { variant="outlined" /> - {reportDB.controls} ) @@ -196,6 +195,7 @@ const Page = () => { simpleColumns={columns} actions={byUser ? byUserActions : byMailboxActions} cardButton={pageActions} + dataSourceControls={reportDB.controls} offCanvas={null} /> {reportDB.syncDialog} diff --git a/frontend/src/pages/endpoint/MEM/assignment-filters/index.js b/frontend/src/pages/endpoint/MEM/assignment-filters/index.js index bedf0ef1ad..e41259dad6 100644 --- a/frontend/src/pages/endpoint/MEM/assignment-filters/index.js +++ b/frontend/src/pages/endpoint/MEM/assignment-filters/index.js @@ -88,9 +88,9 @@ const Page = () => { - {reportDB.controls} } + dataSourceControls={reportDB.controls} apiUrl={reportDB.resolvedApiUrl} queryKey={reportDB.resolvedQueryKey} actions={actions} diff --git a/frontend/src/pages/endpoint/MEM/list-appprotection-policies/index.js b/frontend/src/pages/endpoint/MEM/list-appprotection-policies/index.js index e2a4acb12a..66b5ac85bc 100644 --- a/frontend/src/pages/endpoint/MEM/list-appprotection-policies/index.js +++ b/frontend/src/pages/endpoint/MEM/list-appprotection-policies/index.js @@ -67,9 +67,9 @@ const Page = () => { requiredPermissions={cardButtonPermissions} PermissionButton={PermissionButton} /> - {reportDB.controls} } + dataSourceControls={reportDB.controls} /> {reportDB.syncDialog} diff --git a/frontend/src/pages/endpoint/MEM/list-compliance-policies/index.js b/frontend/src/pages/endpoint/MEM/list-compliance-policies/index.js index 32574567a0..f3f8299baa 100644 --- a/frontend/src/pages/endpoint/MEM/list-compliance-policies/index.js +++ b/frontend/src/pages/endpoint/MEM/list-compliance-policies/index.js @@ -65,9 +65,9 @@ const Page = () => { requiredPermissions={cardButtonPermissions} PermissionButton={PermissionButton} /> - {reportDB.controls} } + dataSourceControls={reportDB.controls} /> {reportDB.syncDialog} diff --git a/frontend/src/pages/endpoint/MEM/list-policies/index.js b/frontend/src/pages/endpoint/MEM/list-policies/index.js index 5895224b9e..b20f3733ad 100644 --- a/frontend/src/pages/endpoint/MEM/list-policies/index.js +++ b/frontend/src/pages/endpoint/MEM/list-policies/index.js @@ -69,9 +69,9 @@ const Page = () => { requiredPermissions={cardButtonPermissions} PermissionButton={PermissionButton} /> - {reportDB.controls} } + dataSourceControls={reportDB.controls} /> {reportDB.syncDialog} diff --git a/frontend/src/pages/endpoint/MEM/list-scripts/index.jsx b/frontend/src/pages/endpoint/MEM/list-scripts/index.jsx index 2661e040c2..0a1d6ead38 100644 --- a/frontend/src/pages/endpoint/MEM/list-scripts/index.jsx +++ b/frontend/src/pages/endpoint/MEM/list-scripts/index.jsx @@ -502,7 +502,7 @@ const Page = () => { actions={actions} offCanvas={offCanvas} simpleColumns={simpleColumns} - cardButton={reportDB.controls} + dataSourceControls={reportDB.controls} /> diff --git a/frontend/src/pages/endpoint/MEM/reusable-settings/index.js b/frontend/src/pages/endpoint/MEM/reusable-settings/index.js index 75219f0d41..b42086aa1a 100644 --- a/frontend/src/pages/endpoint/MEM/reusable-settings/index.js +++ b/frontend/src/pages/endpoint/MEM/reusable-settings/index.js @@ -76,9 +76,9 @@ const Page = () => { cardButton={ - {reportDB.controls} } + dataSourceControls={reportDB.controls} apiUrl={reportDB.resolvedApiUrl} queryKey={reportDB.resolvedQueryKey} actions={actions} diff --git a/frontend/src/pages/endpoint/applications/list/index.js b/frontend/src/pages/endpoint/applications/list/index.js index 232e55a21d..cdbbc0d97b 100644 --- a/frontend/src/pages/endpoint/applications/list/index.js +++ b/frontend/src/pages/endpoint/applications/list/index.js @@ -386,9 +386,9 @@ const Page = () => { - {reportDB.controls} } + dataSourceControls={reportDB.controls} /> { > Deploy Group Template - {reportDB.controls} } + dataSourceControls={reportDB.controls} apiUrl={reportDB.resolvedApiUrl} apiData={ reportDB.useReportDB diff --git a/frontend/src/pages/identity/reports/inactive-users-report/index.js b/frontend/src/pages/identity/reports/inactive-users-report/index.js index 8e8e7a0edc..59b8307caf 100644 --- a/frontend/src/pages/identity/reports/inactive-users-report/index.js +++ b/frontend/src/pages/identity/reports/inactive-users-report/index.js @@ -89,7 +89,7 @@ const Page = () => { actions={actions} offCanvas={offCanvas} simpleColumns={simpleColumns} - cardButton={reportDB.controls} + dataSourceControls={reportDB.controls} /> {reportDB.syncDialog} diff --git a/frontend/src/pages/identity/reports/mfa-report/index.js b/frontend/src/pages/identity/reports/mfa-report/index.js index 668030f9f9..efe9e34a5c 100644 --- a/frontend/src/pages/identity/reports/mfa-report/index.js +++ b/frontend/src/pages/identity/reports/mfa-report/index.js @@ -117,7 +117,7 @@ const Page = () => { simpleColumns={simpleColumns} filters={filters} actions={actions} - cardButton={reportDB.controls} + dataSourceControls={reportDB.controls} initialFilters={urlFilters} /> {reportDB.syncDialog} diff --git a/frontend/src/pages/security/reports/mde-onboarding/index.js b/frontend/src/pages/security/reports/mde-onboarding/index.js index 955ec17fa6..aa88b8c751 100644 --- a/frontend/src/pages/security/reports/mde-onboarding/index.js +++ b/frontend/src/pages/security/reports/mde-onboarding/index.js @@ -355,7 +355,7 @@ const Page = () => { "partnerUnresponsivenessThresholdInDays", "CacheTimestamp", ]} - cardButton={reportDB.controls} + dataSourceControls={reportDB.controls} /> {reportDB.syncDialog} diff --git a/frontend/src/pages/teams-share/onedrive/index.js b/frontend/src/pages/teams-share/onedrive/index.js index 9b4f9029c0..bb4fa1c56d 100644 --- a/frontend/src/pages/teams-share/onedrive/index.js +++ b/frontend/src/pages/teams-share/onedrive/index.js @@ -119,7 +119,7 @@ const Page = () => { queryKey={reportDB.resolvedQueryKey} actions={actions} simpleColumns={simpleColumns} - cardButton={reportDB.controls} + dataSourceControls={reportDB.controls} /> {reportDB.syncDialog} diff --git a/frontend/src/pages/teams-share/sharepoint/index.js b/frontend/src/pages/teams-share/sharepoint/index.js index f0c85a6ccf..5d41ce6554 100644 --- a/frontend/src/pages/teams-share/sharepoint/index.js +++ b/frontend/src/pages/teams-share/sharepoint/index.js @@ -712,7 +712,6 @@ const Page = () => { > Bulk Add Sites - {reportDB.controls} ) @@ -727,6 +726,7 @@ const Page = () => { offCanvas={offCanvas} simpleColumns={simpleColumns} cardButton={pageActions} + dataSourceControls={reportDB.controls} /> {reportDB.syncDialog} diff --git a/frontend/src/pages/teams-share/teams/business-voice/index.js b/frontend/src/pages/teams-share/teams/business-voice/index.js index 2a600064ca..72f80d1dda 100644 --- a/frontend/src/pages/teams-share/teams/business-voice/index.js +++ b/frontend/src/pages/teams-share/teams/business-voice/index.js @@ -139,7 +139,7 @@ const Page = () => { "Complex: AssignmentStatus eq Unassigned; AcquiredCapabilities like UserAssignment", }, ]} - cardButton={reportDB.controls} + dataSourceControls={reportDB.controls} /> {reportDB.syncDialog} diff --git a/frontend/src/pages/teams-share/teams/list-team/index.js b/frontend/src/pages/teams-share/teams/list-team/index.js index 99b51994ba..82f175d825 100644 --- a/frontend/src/pages/teams-share/teams/list-team/index.js +++ b/frontend/src/pages/teams-share/teams/list-team/index.js @@ -62,9 +62,9 @@ const Page = () => { - {reportDB.controls} } + dataSourceControls={reportDB.controls} /> {reportDB.syncDialog} diff --git a/frontend/src/pages/teams-share/teams/teams-activity/index.js b/frontend/src/pages/teams-share/teams/teams-activity/index.js index 69d3fa3ebc..bc3f825da9 100644 --- a/frontend/src/pages/teams-share/teams/teams-activity/index.js +++ b/frontend/src/pages/teams-share/teams/teams-activity/index.js @@ -39,7 +39,7 @@ const Page = () => { "CallCount", "TeamsChat", ]} - cardButton={reportDB.controls} + dataSourceControls={reportDB.controls} /> {reportDB.syncDialog} diff --git a/frontend/src/pages/tenant/reports/application-consent/index.js b/frontend/src/pages/tenant/reports/application-consent/index.js index 08ac8b35d4..28c07f3bc3 100644 --- a/frontend/src/pages/tenant/reports/application-consent/index.js +++ b/frontend/src/pages/tenant/reports/application-consent/index.js @@ -31,7 +31,7 @@ const Page = () => { apiUrl={reportDB.resolvedApiUrl} queryKey={reportDB.resolvedQueryKey} simpleColumns={simpleColumns} - cardButton={reportDB.controls} + dataSourceControls={reportDB.controls} /> {reportDB.syncDialog} diff --git a/frontend/tests/components/CippComponents/CippSettingsSideBar.test.jsx b/frontend/tests/components/CippComponents/CippSettingsSideBar.test.jsx new file mode 100644 index 0000000000..2362102662 --- /dev/null +++ b/frontend/tests/components/CippComponents/CippSettingsSideBar.test.jsx @@ -0,0 +1,46 @@ +import React from 'react' +import { describe, it, expect, vi } from 'vitest' +import { screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { useForm } from 'react-hook-form' +import { renderWithProviders } from '../../test-utils' + +vi.mock('../../../src/api/ApiCall', async () => (await import('../../mocks/api-call')).apiCallMock()) +import { api, getResult, postResult } from '../../mocks/api-call' + +import { CippSettingsSideBar } from '../../../src/components/CippComponents/CippSettingsSideBar' + +const meResult = getResult({ data: { clientPrincipal: { userDetails: 'admin@contoso.com' } } }) +api.get = meResult + +// handleSaveChanges posts an explicit field allowlist, a preference missing from it saves +// as a silent no-op ("Settings saved successfully" toast, nothing stored) +const Harness = () => { + const formcontrol = useForm({ + defaultValues: { + user: { label: 'Current User', value: 'admin@contoso.com' }, + tableViewMode: { value: 'table', label: 'Always classic table' }, + tablePageSize: { value: '50', label: '50' }, + }, + }) + return +} + +describe('CippSettingsSideBar save allowlist', () => { + it('Save Changes posts tableViewMode with the settings blob', async () => { + const user = userEvent.setup() + api.post = postResult() + renderWithProviders() + + await user.click(await screen.findByRole('button', { name: /save changes/i })) + + await waitFor(() => expect(api.post.mutate).toHaveBeenCalled()) + const payload = api.post.mutate.mock.calls[0][0] + expect(payload.data.user).toBe('admin@contoso.com') + expect(payload.data.currentSettings.tableViewMode).toEqual({ + value: 'table', + label: 'Always classic table', + }) + expect(payload.data.currentSettings.tablePageSize).toEqual({ value: '50', label: '50' }) + }) +}) diff --git a/frontend/tests/components/CippTable/CIPPTableToptoolbar.test.jsx b/frontend/tests/components/CippTable/CIPPTableToptoolbar.test.jsx index f9f3bae112..bfa93e89bb 100644 --- a/frontend/tests/components/CippTable/CIPPTableToptoolbar.test.jsx +++ b/frontend/tests/components/CippTable/CIPPTableToptoolbar.test.jsx @@ -388,3 +388,25 @@ describe('CIPPTableToptoolbar - preset list refresh', () => { }) }, 30000) }) + +describe('CIPPTableToptoolbar desktop export', () => { + it('Export menu carries the row exports and opens the API response viewer', async () => { + const user = userEvent.setup() + renderWithProviders( + + ) + await screen.findByText('Users') + + await user.click(screen.getByRole('button', { name: /Export/ })) + await screen.findByRole('menuitem', { name: 'Export to CSV' }) + expect(screen.getByRole('menuitem', { name: 'Export to PDF' })).toBeInTheDocument() + + await user.click(screen.getByRole('menuitem', { name: 'View API Response' })) + await screen.findByText('API Response') + }) +}) diff --git a/frontend/tests/components/CippTable/CippMobileCardList.stories.jsx b/frontend/tests/components/CippTable/CippMobileCardList.stories.jsx index 9c06031353..f23120ccf7 100644 --- a/frontend/tests/components/CippTable/CippMobileCardList.stories.jsx +++ b/frontend/tests/components/CippTable/CippMobileCardList.stories.jsx @@ -4,10 +4,9 @@ import { Box, Button } from '@mui/material' import { Add, Block, Delete, Edit } from '@mui/icons-material' import { CippDataTable } from '../../../src/components/CippTable/CippDataTable' import { SettingsProvider } from '../../../src/contexts/settings-context' +import { shrinkToPhoneViewport, growToDesktopViewport } from '../../viewport' -// Card view is normally chosen by viewport (below md), but no story in this repo sets a -// viewport — the explicit viewMode prop is the supported override and is what the unit -// tests use too. +// most stories force cards via the viewMode prop; TableViewToggle shrinks the real viewport instead, since the toggle needs no explicit prop const users = [ { id: 'u-1', @@ -100,6 +99,17 @@ export const Default = { const body = within(document.body) await waitFor(() => expect(body.getByText('Block sign-in')).toBeInTheDocument()) expect(body.getByText('Delete user')).toBeInTheDocument() + await userEvent.keyboard('{Escape}') + await waitFor(() => expect(body.queryByRole('dialog')).toBeNull()) + }) + + await step('Filters opens the shared bottom sheet with the card fields', async () => { + const body = within(document.body) + await userEvent.click(canvas.getByRole('button', { name: 'Table options' })) + const filterSheet = await body.findByRole('dialog') + expect(within(filterSheet).getByText('Fields shown')).toBeInTheDocument() + await userEvent.keyboard('{Escape}') + await waitFor(() => expect(body.queryByRole('dialog')).toBeNull()) }) }, } @@ -242,3 +252,172 @@ export const MobileCards = { }) }, } + +export const TableViewToggle = { + render: () => ( + + + + ), + play: async ({ canvasElement }) => { + // shrink for real: a viewMode prop would also force cards, but hides the toggle (precedence rule) + const onAPhone = await shrinkToPhoneViewport() + const canvas = within(canvasElement) + await canvas.findByText('Alice Smith') + if (!onAPhone) { + return + } + + // 'Alice Smith' renders in both branches, so the card list itself has to settle + await waitFor(() => expect(canvas.getByTestId('cipp-mobile-card-list')).toBeInTheDocument()) + + await userEvent.click(await canvas.findByRole('button', { name: 'Toggle table view' })) + await waitFor(() => { + expect(canvasElement.querySelector('table')).not.toBeNull() + expect(canvas.queryByTestId('cipp-mobile-card-list')).toBeNull() + }) + + // real MRT table mounts with the page's configured columns + await waitFor(() => expect(canvas.getAllByRole('columnheader').length).toBeGreaterThan(0)) + const headerText = canvas.getAllByRole('columnheader').map((cell) => cell.textContent) + expect(headerText.some((text) => text.includes('Display Name'))).toBe(true) + + // transient: the toggle never persists + const persisted = JSON.parse(window.localStorage.getItem('app.settings')) + expect(persisted.tableViewMode).toBe('auto') + + // phone table bar: kebab opens the shared sheet, which carries refresh. + // MUI's Tooltip stamps the 'Refresh data' aria-label onto the wrapping span, so that's + // the queryable anchor for the desktop refresh button (the IconButton has no name of its own) + expect(canvasElement.querySelector('[aria-label="Refresh data"]')).toBeNull() + const optionsButton = canvas.getByRole('button', { name: 'Table options' }) + await userEvent.click(optionsButton) + const filterSheet = await within(document.body).findByRole('dialog') + expect(within(filterSheet).getByText('Fields shown')).toBeInTheDocument() + expect(within(filterSheet).getByText('Reset all filters')).toBeInTheDocument() + expect(within(filterSheet).getByText('Refresh data')).toBeInTheDocument() + // the sheet owns page size on phones, current size marked active + expect(within(filterSheet).getByText('Rows per page')).toBeInTheDocument() + const activeSize = within(filterSheet).getByText('25').closest('.MuiChip-root') + expect(activeSize.className).toContain('MuiChip-filled') + await userEvent.keyboard('{Escape}') + await waitFor(() => expect(within(document.body).queryByRole('dialog')).toBeNull()) + + // same aria-label, now the desktop toolbar's "way back" button + await userEvent.click(canvas.getByRole('button', { name: 'Toggle table view' })) + await waitFor(() => expect(canvas.getByTestId('cipp-mobile-card-list')).toBeInTheDocument()) + }, +} + +export const TableViewToggleWithActions = { + // render ignores the meta's default args (viewMode: 'cards' would hide the toggle button) + render: () => ( + + + + + } + /> + + ), + play: async ({ canvasElement }) => { + const onAPhone = await shrinkToPhoneViewport() + const canvas = within(canvasElement) + const body = within(document.body) + await canvas.findByText('Alice Smith') + if (!onAPhone) { + return + } + + await waitFor(() => expect(canvas.getByTestId('cipp-mobile-card-list')).toBeInTheDocument()) + + await userEvent.click(await canvas.findByRole('button', { name: 'Toggle table view' })) + await waitFor(() => { + expect(canvasElement.querySelector('table')).not.toBeNull() + expect(canvas.queryByTestId('cipp-mobile-card-list')).toBeNull() + }) + + // narrow table view: cardButton lives behind the page actions FAB, absent from the canvas until opened + expect(canvas.queryByRole('button', { name: 'Add User' })).toBeNull() + const fab = await body.findByRole('button', { name: 'Page actions' }) + + await userEvent.click(fab) + await waitFor(() => expect(body.getByRole('button', { name: 'Add User' })).toBeInTheDocument()) + }, +} + +export const TableViewToggleBulkActionsInHeader = { + // render ignores the meta's default args, same reason as the sibling toggle stories + render: () => ( + + + + ), + play: async ({ canvasElement }) => { + const onAPhone = await shrinkToPhoneViewport() + const canvas = within(canvasElement) + await canvas.findByText('Alice Smith') + if (!onAPhone) { + return + } + + await waitFor(() => expect(canvas.getByTestId('cipp-mobile-card-list')).toBeInTheDocument()) + await userEvent.click(await canvas.findByRole('button', { name: 'Toggle table view' })) + await waitFor(() => { + expect(canvasElement.querySelector('table')).not.toBeNull() + expect(canvas.queryByTestId('cipp-mobile-card-list')).toBeNull() + }) + + const firstRow = await waitFor(() => { + const row = canvasElement.querySelector('tbody tr') + expect(row).not.toBeNull() + return row + }) + await userEvent.click(within(firstRow).getByRole('checkbox')) + + // the mostly-empty header row is where the narrow toolbar's selection UI lands + const header = canvasElement.querySelector('.MuiCardHeader-root') + await waitFor(() => { + expect(within(header).getByText(/rows selected/)).toBeInTheDocument() + expect(within(header).getByRole('button', { name: 'Bulk Actions' })).toBeInTheDocument() + }) + // exactly one Bulk Actions button on screen — it moved, it did not duplicate + expect(canvas.getAllByRole('button', { name: 'Bulk Actions' })).toHaveLength(1) + + await userEvent.click(within(header).getByRole('button', { name: 'Bulk Actions' })) + const body = within(document.body) + await waitFor(() => expect(body.getByText('Delete user')).toBeInTheDocument()) + await userEvent.keyboard('{Escape}') + }, +} + +export const DesktopBulkActionsStayInToolbar = { + args: { + title: 'Users', + viewMode: 'table', + data: users, + simpleColumns, + actions, + }, + play: async ({ canvasElement }) => { + await growToDesktopViewport() + const canvas = within(canvasElement) + await waitFor(() => expect(canvasElement.querySelector('table')).not.toBeNull()) + await canvas.findByText('Alice Smith') + + const firstRow = canvasElement.querySelector('tbody tr') + await userEvent.click(within(firstRow).getByRole('checkbox')) + + await waitFor(() => expect(canvas.getByRole('button', { name: 'Bulk Actions' })).toBeInTheDocument()) + // the header exists (title-only, no cardButton on this story) but never received the portal + const header = canvasElement.querySelector('.MuiCardHeader-root') + expect(within(header).queryByRole('button', { name: 'Bulk Actions' })).toBeNull() + }, +} diff --git a/frontend/tests/components/CippTable/CippMobileCardList.test.jsx b/frontend/tests/components/CippTable/CippMobileCardList.test.jsx new file mode 100644 index 0000000000..7958bce39d --- /dev/null +++ b/frontend/tests/components/CippTable/CippMobileCardList.test.jsx @@ -0,0 +1,320 @@ +import React from 'react' +import { vi } from 'vitest' +import { screen, waitFor, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { Button } from '@mui/material' +import { renderWithProviders, settingsWith } from '../../test-utils' + +// jsdom matchMedia never matches, so this overrides only useIsNarrowForTables for the FAB pivot, useTableViewMode stays real +const narrowState = vi.hoisted(() => ({ narrow: false })) +vi.mock('../../../src/hooks/use-breakpoint', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, useIsNarrowForTables: () => narrowState.narrow } +}) + +import { CippDataTable } from '../../../src/components/CippTable/CippDataTable' + +// wide enough that full mode overflows into "+N more fields" +const users = [ + { + displayName: 'Alice Smith', + userPrincipalName: 'alice@contoso.com', + department: 'IT', + jobTitle: 'Engineer', + city: 'Seattle', + country: 'US', + accountEnabled: true, + }, +] +const columns = [ + 'displayName', + 'userPrincipalName', + 'department', + 'jobTitle', + 'city', + 'country', + 'accountEnabled', +] + +// no viewMode prop, so settings.tableViewMode='cards' forces cards but leaves the toggle allowed +const renderCards = (settings = {}, componentProps = {}) => + renderWithProviders( + , + { settings: settingsWith({ tableViewMode: 'cards', ...settings }) } + ) + +describe('CippMobileCardList card anatomy', () => { + it('shows the slotted anatomy: overflow counter, secondary slot as bare text', async () => { + renderCards() + await waitFor(() => expect(screen.getByText('Alice Smith')).toBeInTheDocument()) + // details cap at 3 of the 4 remaining columns -> 1 overflow + expect(screen.getByText(/more field/)).toBeInTheDocument() + // secondary slot is bare text, its column label never renders + expect(screen.queryByText('User Principal Name')).not.toBeInTheDocument() + }) +}) + +describe('CippMobileCardList table view toggle', () => { + afterEach(() => { + narrowState.narrow = false + }) + + it('opens the table view and the way back restores cards, never touching settings', async () => { + // narrow viewport: the round trip must hand back the card view intact + narrowState.narrow = true + const user = userEvent.setup() + const handleUpdate = vi.fn() + const { container } = renderCards({ handleUpdate }) + await waitFor(() => expect(screen.getByText('Alice Smith')).toBeInTheDocument()) + expect(screen.getByTestId('cipp-mobile-card-list')).toBeInTheDocument() + expect(screen.getByText('Department')).toBeInTheDocument() + expect(screen.getByText(/more field/)).toBeInTheDocument() + + // jsdom renders no MRT header/row text, so just check the table mounts and cards unmount + await user.click(screen.getByRole('button', { name: 'Toggle table view' })) + await waitFor(() => expect(container.querySelector('table')).not.toBeNull()) + expect(screen.queryByTestId('cipp-mobile-card-list')).not.toBeInTheDocument() + + // same aria-label, now the desktop toolbar's "way back" button + await user.click(screen.getByRole('button', { name: 'Toggle table view' })) + await waitFor(() => expect(screen.getByTestId('cipp-mobile-card-list')).toBeInTheDocument()) + + // full card content is back: detail rows and the overflow counter + expect(screen.getByText('Department')).toBeInTheDocument() + expect(screen.getByText(/more field/)).toBeInTheDocument() + + // transient: the view toggle never persists + expect(handleUpdate).not.toHaveBeenCalled() + }) + + it('the toggled table keeps every configured column visible', async () => { + narrowState.narrow = true + const user = userEvent.setup() + renderCards() + await waitFor(() => expect(screen.getByText('Alice Smith')).toBeInTheDocument()) + + await user.click(screen.getByRole('button', { name: 'Toggle table view' })) + await waitFor(() => expect(screen.getByRole('button', { name: 'Columns' })).toBeInTheDocument()) + await user.click(screen.getByRole('button', { name: 'Columns' })) + + // Columns menu reads table.getAllColumns(), unaffected by the virtualized header row + const menu = within(screen.getAllByRole('menu')[0]) + const checkbox = (name) => within(menu.getByRole('menuitem', { name })).getByRole('checkbox') + for (const name of [ + 'Display Name', + 'User Principal Name', + 'Account Enabled', + 'Department', + 'Job Title', + 'City', + 'Country', + ]) { + expect(checkbox(name)).toBeChecked() + } + }) + + it('a Fields shown toggle in the shared filter sheet changes what the card renders', async () => { + const user = userEvent.setup() + renderCards() + await waitFor(() => expect(screen.getByText('Alice Smith')).toBeInTheDocument()) + // department is a detail row on the card before the toggle + expect(screen.getByText('Department')).toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'Table options' })) + await screen.findByText('Fields shown') + // the sheet is a portal appended to body, so its entry sorts after the card's label + await user.click(screen.getAllByText('Department').at(-1)) + await user.click(screen.getByRole('button', { name: 'Done' })) + + await waitFor(() => expect(screen.queryByText('Department')).not.toBeInTheDocument()) + }) + + it('an explicit viewMode prop hides the toggle button', async () => { + renderWithProviders( + , + { settings: settingsWith() } + ) + await waitFor(() => expect(screen.getByText('Alice Smith')).toBeInTheDocument()) + expect(screen.queryByRole('button', { name: 'Toggle table view' })).not.toBeInTheDocument() + }) + + // Regression: the cards branch and the table branch are two alternating CIPPTableToptoolbar + // instances (only one mounts at a time), so activeFilters/searchValue/restoredFiltersRef used + // to live in the toolbar's own useState and reset on every flip. The table-branch kebab is + // unreachable here (mdDown from useMediaQuery never matches in jsdom, and useCompactMode stays + // false since offsetWidth/scrollWidth are always 0) — reopening the sheet after the round trip + // is the observable proxy for "state survived the two remounts". + it('an applied preset and its badge survive a flip to table and back', async () => { + narrowState.narrow = true + const user = userEvent.setup() + const presetFilters = [ + { filterName: 'IT department', value: [{ id: 'department', value: 'IT' }], type: 'column' }, + ] + renderCards({}, { filters: presetFilters }) + await waitFor(() => expect(screen.getByText('Alice Smith')).toBeInTheDocument()) + + await user.click(screen.getByRole('button', { name: 'Table options' })) + const sheet = await screen.findByRole('dialog') + await user.click(within(sheet).getByText('IT department')) + await user.click(within(sheet).getByRole('button', { name: 'Done' })) + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()) + + // preset active before the flip — the sheet's aria-hidden overlay is gone now + await waitFor(() => { + expect(within(screen.getByRole('button', { name: 'Table options' })).getByText('1')).toBeInTheDocument() + }) + + await user.click(screen.getByRole('button', { name: 'Toggle table view' })) + await waitFor(() => expect(document.querySelector('table')).not.toBeNull()) + await user.click(screen.getByRole('button', { name: 'Toggle table view' })) + await waitFor(() => expect(screen.getByTestId('cipp-mobile-card-list')).toBeInTheDocument()) + + // badge count survived both remounts + expect(within(screen.getByRole('button', { name: 'Table options' })).getByText('1')).toBeInTheDocument() + + // the preset chip is marked active too + await user.click(screen.getByRole('button', { name: 'Table options' })) + const reopened = await screen.findByRole('dialog') + const chip = within(reopened).getByText('IT department').closest('.MuiChip-root') + expect(chip.className).toContain('MuiChip-filled') + }, 15000) + + it('a manual field-visibility change survives a flip, even with preferred columns saved for the page', async () => { + narrowState.narrow = true + const user = userEvent.setup() + // router mock resolves pageName to '' in tests, matching CIPPTableToptoolbar.test.jsx's convention + const allColumnsVisible = Object.fromEntries(columns.map((c) => [c, true])) + renderCards({ columnDefaults: { '': allColumnsVisible } }) + await waitFor(() => expect(screen.getByText('Alice Smith')).toBeInTheDocument()) + expect(screen.getByText('Department')).toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'Table options' })) + await screen.findByText('Fields shown') + await user.click(screen.getAllByText('Department').at(-1)) + await user.click(screen.getByRole('button', { name: 'Done' })) + await waitFor(() => expect(screen.queryByText('Department')).not.toBeInTheDocument()) + + await user.click(screen.getByRole('button', { name: 'Toggle table view' })) + await waitFor(() => expect(document.querySelector('table')).not.toBeNull()) + await user.click(screen.getByRole('button', { name: 'Toggle table view' })) + await waitFor(() => expect(screen.getByTestId('cipp-mobile-card-list')).toBeInTheDocument()) + + // the manual hide must not be reverted by the saved preferred-columns set on remount + expect(screen.queryByText('Department')).not.toBeInTheDocument() + }, 15000) +}) + +describe('CippMobileCardList table-view page actions FAB', () => { + afterEach(() => { + narrowState.narrow = false + }) + + it('narrow viewport moves cardButton into the actions FAB once toggled to table view', async () => { + narrowState.narrow = true + const user = userEvent.setup() + renderCards({}, { cardButton: }) + await waitFor(() => expect(screen.getByText('Alice Smith')).toBeInTheDocument()) + + await user.click(screen.getByRole('button', { name: 'Toggle table view' })) + await waitFor(() => expect(screen.getByRole('button', { name: 'Page actions' })).toBeInTheDocument()) + + // action content stays behind the FAB until opened + expect(screen.queryByRole('button', { name: 'Add user' })).not.toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'Page actions' })) + await waitFor(() => expect(screen.getByRole('button', { name: 'Add user' })).toBeInTheDocument()) + }) + + it('desktop viewport keeps cardButton in the header, no FAB', async () => { + const user = userEvent.setup() + renderCards({}, { cardButton: }) + await waitFor(() => expect(screen.getByText('Alice Smith')).toBeInTheDocument()) + + await user.click(screen.getByRole('button', { name: 'Toggle table view' })) + await waitFor(() => expect(screen.getByRole('button', { name: 'Add user' })).toBeInTheDocument()) + expect(screen.queryByRole('button', { name: 'Page actions' })).not.toBeInTheDocument() + }) +}) + +// The Card header hosts a portal target for the toolbar's bulk-actions UI on narrow +// viewports (CIPPTableToptoolbar's bulkActionsSlot). Row selection itself can't be driven +// here: CippDataTable's table renders with enableRowVirtualization + enableColumnVirtualization +// always on, and jsdom never reports a nonzero container size, so react-virtual computes an +// empty range — thead and tbody both mount with zero cells (verified: no checkboxes, no +// columnheaders, table-view page actions FAB tests above only ever check for the
    +// element itself, never header/row content). Selecting a row to exercise the portal is +// covered in the CippMobileCardList.stories.jsx browser story instead. +describe('CippMobileCardList table-view header mounts as the bulk-actions portal target', () => { + afterEach(() => { + narrowState.narrow = false + }) + + it('narrow + hideTitle + cardButton: header still mounts even though the FAB owns cardButton', async () => { + narrowState.narrow = true + const user = userEvent.setup() + const { container } = renderCards( + {}, + { hideTitle: true, cardButton: } + ) + await waitFor(() => expect(screen.getByText('Alice Smith')).toBeInTheDocument()) + + await user.click(screen.getByRole('button', { name: 'Toggle table view' })) + await waitFor(() => expect(container.querySelector('table')).not.toBeNull()) + + // headerAction is undefined here (FAB owns cardButton), so the gate has to key off + // cardButton directly or this mounts nothing and the portal target never exists + expect(container.querySelector('.MuiCardHeader-root')).not.toBeNull() + }) + + it('desktop + hideTitle + cardButton: header mounts with cardButton in it, same as before', async () => { + const user = userEvent.setup() + const { container } = renderCards( + {}, + { hideTitle: true, cardButton: } + ) + await waitFor(() => expect(screen.getByText('Alice Smith')).toBeInTheDocument()) + + await user.click(screen.getByRole('button', { name: 'Toggle table view' })) + await waitFor(() => expect(container.querySelector('table')).not.toBeNull()) + + const header = container.querySelector('.MuiCardHeader-root') + expect(header).not.toBeNull() + expect(within(header).getByRole('button', { name: 'Add user' })).toBeInTheDocument() + }) +}) + +describe('CippMobileCardList data source controls', () => { + afterEach(() => { + narrowState.narrow = false + }) + + it('renders in the Table options sheet, not in the page actions FAB', async () => { + narrowState.narrow = true + const user = userEvent.setup() + renderCards( + {}, + { dataSourceControls: Live badge, cardButton: } + ) + await waitFor(() => expect(screen.getByText('Alice Smith')).toBeInTheDocument()) + + await user.click(screen.getByRole('button', { name: 'Table options' })) + const filterSheet = await within(document.body).findByRole('dialog') + expect(within(filterSheet).getByText('Data source')).toBeInTheDocument() + expect(within(filterSheet).getByText('Live badge')).toBeInTheDocument() + + await user.click(within(filterSheet).getByRole('button', { name: 'Done' })) + await waitFor(() => expect(within(document.body).queryByRole('dialog')).not.toBeInTheDocument()) + + // narrow + table view: cardButton lives behind the FAB, dataSourceControls must not follow it there + await user.click(screen.getByRole('button', { name: 'Toggle table view' })) + await waitFor(() => expect(screen.getByRole('button', { name: 'Page actions' })).toBeInTheDocument()) + + // and the table's card header must not double-render them (sheet is the only narrow home) + expect(screen.queryByText('Live badge')).not.toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'Page actions' })) + const fabSheet = await within(document.body).findByRole('dialog') + await waitFor(() => expect(within(fabSheet).getByRole('button', { name: 'Add user' })).toBeInTheDocument()) + expect(within(fabSheet).queryByText('Live badge')).not.toBeInTheDocument() + }) +}) diff --git a/frontend/tests/components/CippTable/util-tablemode.test.jsx b/frontend/tests/components/CippTable/util-tablemode.test.jsx index 8497f9d0dd..790704246c 100644 --- a/frontend/tests/components/CippTable/util-tablemode.test.jsx +++ b/frontend/tests/components/CippTable/util-tablemode.test.jsx @@ -42,6 +42,26 @@ describe('utilTableMode', () => { expect(result.muiPaginationProps.rowsPerPageOptions).toBeDefined() }) + it('narrow table slims the footer so it cannot wrap below MRT 720px pivot', () => { + const result = utilTableMode({}, false, null, [], false, null, '380px', defaultSettings, 'table', true) + expect(result.muiPaginationProps.showRowsPerPage).toBe(false) + expect(result.muiPaginationProps.showFirstButton).toBe(false) + expect(result.muiPaginationProps.showLastButton).toBe(false) + }) + + it('narrow table page-scrolls instead of keeping an inner scroll viewport', () => { + const result = utilTableMode({}, false, null, [], false, null, '380px', defaultSettings, 'table', true) + expect(result.muiTableContainerProps.sx.maxHeight).toBe('none') + }) + + it('wide table keeps the full footer and the viewport-budget maxHeight', () => { + const result = utilTableMode({}, false, null, [], false, null, '380px', defaultSettings, 'table', false) + expect(result.muiPaginationProps.showRowsPerPage).toBeUndefined() + expect(result.muiPaginationProps.showFirstButton).toBeUndefined() + expect(result.muiPaginationProps.showLastButton).toBeUndefined() + expect(result.muiTableContainerProps.sx.maxHeight).toBe('calc(100vh - 380px)') + }) + it('returns table container height config', () => { const result = utilTableMode({}, false, null, [], false, null, '500px', defaultSettings) expect(result.muiTableContainerProps).toBeDefined() diff --git a/frontend/tests/components/CippWizard/CippWizardAutopilotImport.test.jsx b/frontend/tests/components/CippWizard/CippWizardAutopilotImport.test.jsx index 8839c9858d..3720e883f9 100644 --- a/frontend/tests/components/CippWizard/CippWizardAutopilotImport.test.jsx +++ b/frontend/tests/components/CippWizard/CippWizardAutopilotImport.test.jsx @@ -8,7 +8,9 @@ import { CippWizardAutopilotImport } from '../../../src/components/CippWizard/Ci // jsdom has no width-based matchMedia, so the mobile branch is driven by mocking the hook const layoutState = vi.hoisted(() => ({ isMobile: false })) -vi.mock('../../../src/hooks/use-breakpoint', () => ({ +// partial mock: real module spread first, so new exports keep working here +vi.mock('../../../src/hooks/use-breakpoint', async (importOriginal) => ({ + ...(await importOriginal()), useIsMobileLayout: () => layoutState.isMobile, useIsTabletLayout: () => false, useTableViewMode: () => 'table', diff --git a/frontend/tests/layouts/TabbedLayout.test.jsx b/frontend/tests/layouts/TabbedLayout.test.jsx index dfbbb0f214..f993739370 100644 --- a/frontend/tests/layouts/TabbedLayout.test.jsx +++ b/frontend/tests/layouts/TabbedLayout.test.jsx @@ -7,7 +7,9 @@ import { renderWithProviders } from "../test-utils"; // jsdom has no width-based matchMedia, so the mobile branch is driven by mocking the hook const layoutState = vi.hoisted(() => ({ isMobile: false, viewMode: "table" })); -vi.mock("../../src/hooks/use-breakpoint", () => ({ +// partial mock: real module spread first, so new exports keep working here +vi.mock("../../src/hooks/use-breakpoint", async (importOriginal) => ({ + ...(await importOriginal()), useIsMobileLayout: () => layoutState.isMobile, useIsTabletLayout: () => false, useTableViewMode: () => layoutState.viewMode, From 3963eb110f92116d88cfb9e2e5924e759e109f05 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Thu, 13 Aug 2026 00:00:16 -0400 Subject: [PATCH 031/226] fix(queue-tracker): prevent status pill overflow on mobile Tenant default domains are single unbreakable tokens that exceeded phone-width cards, pushing the status pill off the right edge. Fixes by adding `overflowWrap: anywhere` and `minWidth: 0` to the task name, and `flexShrink: 0` + `whiteSpace: nowrap` to the status pill. Updates the Storybook story to use realistic long tenant domain names and adds a phone-viewport play test that asserts the pill stays within the drawer bounds. --- .../components/CippTable/CippQueueTracker.js | 12 +- .../CippTable/CippQueueTracker.stories.jsx | 230 +++++------------- 2 files changed, 73 insertions(+), 169 deletions(-) diff --git a/frontend/src/components/CippTable/CippQueueTracker.js b/frontend/src/components/CippTable/CippQueueTracker.js index 2d090a115f..9c813bac33 100644 --- a/frontend/src/components/CippTable/CippQueueTracker.js +++ b/frontend/src/components/CippTable/CippQueueTracker.js @@ -363,13 +363,23 @@ export const CippQueueTracker = ({ queueId, queryKey, title, onQueueComplete }) direction="row" justifyContent="space-between" alignItems="center" + spacing={1} > - + {/* Task names are tenant domains — one unbreakable token — so + without minWidth: 0 the row's min-content width exceeds a + phone-width card and shoves the status pill off its edge. */} + {task.Name} ({ + flexShrink: 0, + whiteSpace: "nowrap", px: 1.5, py: 0.5, borderRadius: 2, diff --git a/frontend/tests/components/CippTable/CippQueueTracker.stories.jsx b/frontend/tests/components/CippTable/CippQueueTracker.stories.jsx index 2c6ecf62b6..077059ccad 100644 --- a/frontend/tests/components/CippTable/CippQueueTracker.stories.jsx +++ b/frontend/tests/components/CippTable/CippQueueTracker.stories.jsx @@ -1,193 +1,87 @@ -import { fn, within, expect, userEvent, waitFor } from 'storybook/test' +import React from 'react' import { http, HttpResponse } from 'msw' +import { within, userEvent, waitFor, expect } from 'storybook/test' import { CippQueueTracker } from '../../../src/components/CippTable/CippQueueTracker' +import { shrinkToPhoneViewport } from '../../viewport' -const queueResponses = { - 'test-queue-running': { - PartitionKey: 'CippQueue', - RowKey: 'test-queue-running', - Name: 'Processing Users', - Status: 'Running', - TotalTasks: 10, - CompletedTasks: 6, - RunningTasks: 1, - FailedTasks: 0, - PercentComplete: 60.0, - PercentFailed: 0, - PercentRunning: 10.0, - Timestamp: '2026-04-08T10:00:00Z', - Tasks: [ - { Name: 'Process user 1', Status: 'Completed', Timestamp: '2026-04-08T10:00:01Z' }, - { Name: 'Process user 2', Status: 'Completed', Timestamp: '2026-04-08T10:00:02Z' }, - { Name: 'Process user 3', Status: 'Completed', Timestamp: '2026-04-08T10:00:03Z' }, - { Name: 'Process user 4', Status: 'Completed', Timestamp: '2026-04-08T10:00:04Z' }, - { Name: 'Process user 5', Status: 'Completed', Timestamp: '2026-04-08T10:00:05Z' }, - { Name: 'Process user 6', Status: 'Completed', Timestamp: '2026-04-08T10:00:06Z' }, - { Name: 'Process user 7', Status: 'Running', Timestamp: '2026-04-08T10:00:07Z' }, - { Name: 'Process user 8', Status: 'Pending', Timestamp: '2026-04-08T10:00:08Z' }, - { Name: 'Process user 9', Status: 'Pending', Timestamp: '2026-04-08T10:00:09Z' }, - { Name: 'Process user 10', Status: 'Pending', Timestamp: '2026-04-08T10:00:10Z' }, - ], - }, - 'test-queue-done': { - PartitionKey: 'CippQueue', - RowKey: 'test-queue-done', - Name: 'User Processing', - Status: 'Completed', - TotalTasks: 5, - CompletedTasks: 5, - RunningTasks: 0, - FailedTasks: 0, - PercentComplete: 100.0, - PercentFailed: 0, - PercentRunning: 0, - Timestamp: '2026-04-08T10:00:00Z', - Tasks: [ - { Name: 'Process user 1', Status: 'Completed', Timestamp: '2026-04-08T10:00:01Z' }, - { Name: 'Process user 2', Status: 'Completed', Timestamp: '2026-04-08T10:00:02Z' }, - { Name: 'Process user 3', Status: 'Completed', Timestamp: '2026-04-08T10:00:03Z' }, - { Name: 'Process user 4', Status: 'Completed', Timestamp: '2026-04-08T10:00:04Z' }, - { Name: 'Process user 5', Status: 'Completed', Timestamp: '2026-04-08T10:00:05Z' }, - ], - }, - 'test-queue-failed': { - PartitionKey: 'CippQueue', - RowKey: 'test-queue-failed', - Name: 'Failed Operation', - Status: 'Failed', - TotalTasks: 5, - CompletedTasks: 2, - RunningTasks: 0, - FailedTasks: 1, - PercentComplete: 40.0, - PercentFailed: 20.0, - PercentRunning: 0, - Timestamp: '2026-04-08T10:00:00Z', - Tasks: [ - { Name: 'Process user 1', Status: 'Completed', Timestamp: '2026-04-08T10:00:01Z' }, - { Name: 'Process user 2', Status: 'Completed', Timestamp: '2026-04-08T10:00:02Z' }, - { Name: 'Process user 3', Status: 'Failed', Timestamp: '2026-04-08T10:00:03Z' }, - { Name: 'Process user 4', Status: 'Pending', Timestamp: '2026-04-08T10:00:04Z' }, - { Name: 'Process user 5', Status: 'Pending', Timestamp: '2026-04-08T10:00:05Z' }, - ], - }, +// The task names are tenant default domains — one unbreakable token each, and the test +// tenants are the longest of them. +const queue = { + QueueId: 'q-1', + Name: 'Users (All Tenants)', + Status: 'Running', + PercentComplete: 20.3, + TotalTasks: 133, + CompletedTasks: 27, + RunningTasks: 4, + FailedTasks: 0, + Tasks: [ + { + Name: 'cyberdraintesttenant024.onmicrosoft.com', + Status: 'Completed', + Timestamp: '2026-08-12T23:15:33Z', + }, + { + Name: 'cyberdraintesttenant023.onmicrosoft.com', + Status: 'Running', + Timestamp: '2026-08-12T23:15:31Z', + }, + { + Name: 'cyberdraintesttenant022.onmicrosoft.com', + Status: 'Completed', + Timestamp: '2026-08-12T23:15:34Z', + }, + ], } -// Single handler that returns different data based on QueueId query param. -// Matches actual Invoke-ListCippQueue response shape. -const queueHandler = http.get('/api/ListCippQueue', ({ request }) => { - const url = new URL(request.url) - const queueId = url.searchParams.get('QueueId') - const data = queueResponses[queueId] - if (data) { - return HttpResponse.json([data]) - } - return HttpResponse.json([]) -}) +const handlers = [http.get('*/api/ListCippQueue', () => HttpResponse.json([queue]))] export default { title: 'Components/CippTable/CippQueueTracker', component: CippQueueTracker, tags: ['autodocs'], - args: { - onQueueComplete: fn(), - }, - beforeEach({ msw }) { - msw.use(queueHandler) - }, + parameters: { msw: { handlers } }, } -// Idle: no queueId, component renders nothing (returns null). -export const Idle = { - args: { - queueId: null, - queryKey: 'storybook-idle', - title: 'Queue Tracker', - }, +export const PhoneWidth = { + render: () => , play: async ({ canvasElement, step }) => { - await step('no queueId renders nothing', async () => { - // Component returns null when no queueId, canvas should be empty - await new Promise((r) => setTimeout(r, 500)) - expect(canvasElement.querySelector('button')).toBeNull() - }) - }, -} + const onAPhone = await shrinkToPhoneViewport() + const canvas = within(canvasElement) + const body = within(document.body) -export const InProgress = { - args: { - queueId: 'test-queue-running', - queryKey: 'storybook-running', - title: 'Processing Users', - }, - play: async ({ canvasElement, step }) => { - const root = within(document.body) - - await step('tracker button appears once queue data loads', async () => { - await waitFor(() => { - expect(canvasElement.querySelector('button')).not.toBeNull() - }) - await userEvent.click(canvasElement.querySelector('button')) + await step('the tracker opens the queue offcanvas', async () => { + const trigger = await canvas.findByRole('button') + await userEvent.click(trigger) + await waitFor(() => expect(body.getByText('Task Details')).toBeInTheDocument()) }) - await step('offcanvas shows running progress and the active task', async () => { - await waitFor(() => { - expect(root.getByText('Processing Users')).toBeVisible() - }) - expect(root.getByText(/60\.0%/)).toBeVisible() - expect(root.getByText('Process user 7')).toBeVisible() - }) - }, -} + if (!onAPhone) return -export const Completed = { - args: { - queueId: 'test-queue-done', - queryKey: 'storybook-done', - title: 'User Processing', - }, - play: async ({ canvasElement, args, step }) => { - const root = within(document.body) - - await step('open the tracker offcanvas', async () => { - await waitFor(() => { - expect(canvasElement.querySelector('button')).not.toBeNull() - }) - await userEvent.click(canvasElement.querySelector('button')) - }) - - await step('shows 100% and fires onQueueComplete', async () => { - await waitFor(() => { - expect(root.getByText('User Processing')).toBeVisible() - }) - expect(root.getByText(/100\.0%/)).toBeVisible() - await waitFor(() => { - expect(args.onQueueComplete).toHaveBeenCalled() - }) - }) - }, -} + // scope to one task card — statuses repeat across cards and in the stats row + const card = (name) => within(body.getByText(name).closest('.MuiBox-root')) -export const Failed = { - args: { - queueId: 'test-queue-failed', - queryKey: 'storybook-failed', - title: 'Failed Operation', - }, - play: async ({ canvasElement, step }) => { - const root = within(document.body) - - await step('open the tracker offcanvas', async () => { + await step('a full tenant domain does not push its status pill off the card', async () => { + const paper = body.getByText('Task Details').closest('.MuiDrawer-paper') + const pill = card('cyberdraintesttenant024.onmicrosoft.com').getByText(/^completed$/i) await waitFor(() => { - expect(canvasElement.querySelector('button')).not.toBeNull() + // the pill is intact inside the drawer, not clipped at its right edge + expect(pill.getBoundingClientRect().right).toBeLessThanOrEqual( + paper.getBoundingClientRect().right + ) + expect(paper.scrollWidth).toBeLessThanOrEqual(paper.clientWidth) }) - await userEvent.click(canvasElement.querySelector('button')) }) - await step('shows the failed operation and its failed task', async () => { - await waitFor(() => { - expect(root.getByText('Failed Operation')).toBeVisible() - }) - expect(root.getByText('Process user 3')).toBeVisible() + await step('and there is real space between the name and the pill', async () => { + const name = body.getByText('cyberdraintesttenant023.onmicrosoft.com') + const running = card('cyberdraintesttenant023.onmicrosoft.com').getByText(/^running$/i) + const nameBox = name.getBoundingClientRect() + const pillBox = running.getBoundingClientRect() + // either beside it with a gap, or wrapped below it — never overlapping + const besideWithGap = pillBox.left - nameBox.right >= 4 + const below = pillBox.top >= nameBox.bottom - 1 + await expect(besideWithGap || below).toBe(true) }) }, } From 050c4f1d25f186f27e489670fdafdba720888e56 Mon Sep 17 00:00:00 2001 From: Corsw <108132302+Corsw@users.noreply.github.com> Date: Thu, 13 Aug 2026 14:28:12 +0200 Subject: [PATCH 032/226] feat: add display name separator support to colleague impersonation alert Adds an optional display name separator setting. When configured, CIPP protects both the full display name and the part before the separator. This supports display names such as "John Doe | Contoso", while also matching "John Doe". Signed-off-by: Corsw <108132302+Corsw@users.noreply.github.com> --- ...IPPStandardColleagueImpersonationAlert.ps1 | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/backend/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardColleagueImpersonationAlert.ps1 b/backend/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardColleagueImpersonationAlert.ps1 index 2deb77bff0..9e6ee7bf0a 100644 --- a/backend/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardColleagueImpersonationAlert.ps1 +++ b/backend/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardColleagueImpersonationAlert.ps1 @@ -21,6 +21,8 @@ function Invoke-CIPPStandardColleagueImpersonationAlert { ADDEDCOMPONENT {"type":"heading","label":"Alert Banner (HTML)","required":false} {"type":"textField","name":"standards.ColleagueImpersonationAlert.disclaimerHtml","label":"Disclaimer HTML – Paste the full HTML for the warning banner","required":true} + {"type":"heading","label":"Display Name Matching","required":false} + {"type":"textField","name":"standards.ColleagueImpersonationAlert.displayNameSeparator","label":"Display name separator – Optional, for example |","required":false} {"type":"heading","label":"Keyword Exclusions (Exclude certain users by keywords)","required":false} {"type":"autoComplete","name":"standards.ColleagueImpersonationAlert.excludedMailboxes","label":"Exclude mailboxes by keywords for example any Displayname starting with (Leaver)","multiple":true,"creatable":true,"required":false} {"type":"heading","label":"Exempt Senders (Email Accounts)","required":false} @@ -52,6 +54,7 @@ function Invoke-CIPPStandardColleagueImpersonationAlert { } #we're done. $ruleHtml = $Settings.disclaimerHtml + $displayNameSeparator = [string]$Settings.displayNameSeparator $excludeKeywords = @( @($Settings.excludedMailboxes) | ForEach-Object { @@ -135,7 +138,25 @@ function Invoke-CIPPStandardColleagueImpersonationAlert { $range = $entry.Key $pattern = $entry.Value $ruleName = "($range) Colleague Impersonation Alert" - $names = @($displayNames | Where-Object { $_ -match $pattern } | ForEach-Object { [regex]::Escape($_) }) + $names = @( + $displayNames | Where-Object { $_ -match $pattern } | ForEach-Object { + $fullName = $_.Trim() + + [regex]::Escape($fullName) + + if (-not [string]::IsNullOrWhiteSpace($displayNameSeparator)) { + $separatorPattern = [regex]::Escape($displayNameSeparator.Trim()) + + if ($fullName -match $separatorPattern) { + $shortName = ($fullName -split "\s*$separatorPattern\s*", 2)[0].Trim() + + if (-not [string]::IsNullOrWhiteSpace($shortName) -and $shortName -ne $fullName) { + [regex]::Escape($shortName) + } + } + } + } | Sort-Object -Unique + ) if ($names.Count -eq 0) { $names = @([regex]::Escape("($range)")) } $existing = $Rules | Where-Object { $_.Name -eq $ruleName } | Select-Object -First 1 From 905799b5a36542e627071e83772828956c167ca0 Mon Sep 17 00:00:00 2001 From: Brandon Martinez Date: Thu, 13 Aug 2026 16:51:06 -0700 Subject: [PATCH 033/226] fix(standards): handle existing phishing branding Check for the default branding localization before creating it, and treat only the known object-conflict race as a recovered informational condition. Keep unexpected creation failures at error severity and cover the behavior with Pester tests. --- .../Invoke-CIPPStandardPhishProtection.ps1 | 27 +++- ...voke-CIPPStandardPhishProtection.Tests.ps1 | 144 ++++++++++++++++++ 2 files changed, 167 insertions(+), 4 deletions(-) create mode 100644 backend/Tests/Standards/Invoke-CIPPStandardPhishProtection.Tests.ps1 diff --git a/backend/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardPhishProtection.ps1 b/backend/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardPhishProtection.ps1 index 7e0fae4f45..6ef1dc2fcc 100644 --- a/backend/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardPhishProtection.ps1 +++ b/backend/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardPhishProtection.ps1 @@ -75,13 +75,32 @@ function Invoke-CIPPStandardPhishProtection { try { if (!$currentBody) { - $AddedHeaders = @{'Accept-Language' = 0 } - $defaultBrandingBody = '{"usernameHintText":null,"signInPageText":null,"backgroundColor":null,"customPrivacyAndCookiesText":null,"customCannotAccessYourAccountText":null,"customForgotMyPasswordText":null,"customTermsOfUseText":null,"loginPageLayoutConfiguration":{"layoutTemplateType":"default","isFooterShown":true,"isHeaderShown":false},"loginPageTextVisibilitySettings":{"hideAccountResetCredentials":false,"hideTermsOfUse":true,"hidePrivacyAndCookies":true},"contentCustomization":{"conditionalAccess":[],"attributeCollection":[]}}' + $DefaultLocalizationExists = $false try { - New-GraphPostRequest -tenantid $tenant -Uri "https://graph.microsoft.com/beta/organization/$($TenantId.customerId)/branding/localizations/" -ContentType 'application/json' -asApp $true -Type POST -Body $defaultBrandingBody -AddedHeaders $AddedHeaders + $Localizations = New-GraphGetRequest -Uri "https://graph.microsoft.com/beta/organization/$($TenantId.customerId)/branding/localizations" -tenantid $tenant -AsApp $true + $DefaultLocalizationExists = [bool]($Localizations | Where-Object { $_.id -eq '0' }) } catch { $ErrorMessage = Get-CippException -Exception $_ - Write-LogMessage -API 'Standards' -tenant $tenant -message "Failed to create default branding localization. Error: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage + Write-LogMessage -API 'Standards' -tenant $tenant -message "Could not check for the default branding localization. Creation will be attempted. Error: $($ErrorMessage.NormalizedError)" -sev Warning -LogData $ErrorMessage + } + + if (-not $DefaultLocalizationExists) { + $AddedHeaders = @{'Accept-Language' = 0 } + $defaultBrandingBody = '{"usernameHintText":null,"signInPageText":null,"backgroundColor":null,"customPrivacyAndCookiesText":null,"customCannotAccessYourAccountText":null,"customForgotMyPasswordText":null,"customTermsOfUseText":null,"loginPageLayoutConfiguration":{"layoutTemplateType":"default","isFooterShown":true,"isHeaderShown":false},"loginPageTextVisibilitySettings":{"hideAccountResetCredentials":false,"hideTermsOfUse":true,"hidePrivacyAndCookies":true},"contentCustomization":{"conditionalAccess":[],"attributeCollection":[]}}' + try { + New-GraphPostRequest -tenantid $tenant -Uri "https://graph.microsoft.com/beta/organization/$($TenantId.customerId)/branding/localizations/" -ContentType 'application/json' -AsApp $true -Type POST -Body $defaultBrandingBody -AddedHeaders $AddedHeaders + } catch { + $ErrorMessage = Get-CippException -Exception $_ + $GraphError = $null + try { $GraphError = $ErrorMessage.RawError | ConvertFrom-Json -ErrorAction Stop } catch {} + $IsDefaultLocalizationConflict = ($GraphError.error.code -eq 'Request_BadRequest') -and [bool]($GraphError.error.details | Where-Object { $_.code -eq 'ObjectConflict' -and $_.target -eq 'id' }) + + if ($IsDefaultLocalizationConflict) { + Write-LogMessage -API 'Standards' -tenant $tenant -message 'Default branding localization already exists; continuing with the existing localization.' -sev Info + } else { + Write-LogMessage -API 'Standards' -tenant $tenant -message "Failed to create default branding localization. Error: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage + } + } } } if ($currentBody -like "*$CSS*") { diff --git a/backend/Tests/Standards/Invoke-CIPPStandardPhishProtection.Tests.ps1 b/backend/Tests/Standards/Invoke-CIPPStandardPhishProtection.Tests.ps1 new file mode 100644 index 0000000000..6a58f860ab --- /dev/null +++ b/backend/Tests/Standards/Invoke-CIPPStandardPhishProtection.Tests.ps1 @@ -0,0 +1,144 @@ +# Pester tests for Invoke-CIPPStandardPhishProtection branding localization handling. +# +# An existing default localization can legitimately have no custom CSS. That state must not be +# mistaken for a missing localization: POSTing another default object produces ObjectConflict even +# though the subsequent customCSS PUT succeeds, leaving a misleading Error in the standards log. + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $StandardPath = Get-ChildItem -Path (Join-Path $RepoRoot 'Modules') -Recurse -Filter 'Invoke-CIPPStandardPhishProtection.ps1' -File -ErrorAction SilentlyContinue | + Select-Object -First 1 -ExpandProperty FullName + if (-not $StandardPath) { throw 'Could not locate Invoke-CIPPStandardPhishProtection.ps1 under Modules/' } + + function Test-CIPPStandardLicense { [CmdletBinding()] param($StandardName, $TenantFilter, $Preset, $RequiredCapabilities) } + function Get-Tenants { [CmdletBinding()] param($TenantFilter) } + function Get-CIPPTable { [CmdletBinding()] param($TableName) } + function Get-CIPPAzDataTableEntity { [CmdletBinding()] param($Table) } + function New-GraphGetRequest { [CmdletBinding()] param($Uri, $tenantid, $AsApp) } + function New-GraphPostRequest { [CmdletBinding()] param($tenantid, $Uri, $ContentType, $AsApp, $Type, $Body, $AddedHeaders) } + function Write-LogMessage { [CmdletBinding()] param($API, $tenant, $message, $sev, $LogData) } + function Get-CippException { [CmdletBinding()] param($Exception) [pscustomobject]@{ NormalizedError = ($Exception | Out-String); RawError = ($Exception.ErrorDetails.Message ?? '') } } + function Get-NormalizedError { [CmdletBinding()] param($Message) $Message } + function Write-StandardsAlert { [CmdletBinding()] param($message, $object, $tenant, $standardName, $standardId) } + function Add-CIPPBPAField { [CmdletBinding()] param($FieldName, $FieldValue, $StoreAs, $Tenant) } + function Set-CIPPStandardsCompareField { [CmdletBinding()] param($FieldName, $CurrentValue, $ExpectedValue, $Tenant) } + + . $StandardPath +} + +Describe 'Invoke-CIPPStandardPhishProtection localization handling' { + BeforeEach { + Mock Test-CIPPStandardLicense { $true } + Mock Get-Tenants { + [pscustomobject]@{ customerId = '11111111-1111-1111-1111-111111111111' } + } + Mock Get-CIPPTable { @{ Table = 'Config' } } + Mock Get-CIPPAzDataTableEntity { + @([pscustomobject]@{ RowKey = 'CIPPURL'; Value = 'cipp.example.com' }) + } + Mock Write-LogMessage { } + Mock Write-StandardsAlert { } + Mock Add-CIPPBPAField { } + Mock Set-CIPPStandardsCompareField { } + Mock New-GraphPostRequest { } + } + + It 'uses an existing default localization when custom CSS is empty' { + $script:GraphGetCalls = 0 + Mock New-GraphGetRequest { + $script:GraphGetCalls++ + if ($script:GraphGetCalls -eq 1) { return $null } + return @([pscustomobject]@{ id = '0' }) + } + + Invoke-CIPPStandardPhishProtection -Tenant 'contoso.onmicrosoft.com' -Settings ([pscustomobject]@{ + remediate = $true + alert = $false + report = $false + }) + + Should -Invoke New-GraphPostRequest -Times 0 -ParameterFilter { + $Type -eq 'POST' -and $Uri -like '*/branding/localizations/' + } + Should -Invoke New-GraphPostRequest -Times 1 -Exactly -ParameterFilter { + $Type -eq 'PUT' -and $Uri -like '*/branding/localizations/0/customCSS' + } + Should -Invoke Write-LogMessage -Times 0 -ParameterFilter { + $sev -eq 'Error' -and $message -like 'Failed to create default branding localization*' + } + } + + It 'creates the default localization when localization id zero is absent' { + Mock New-GraphGetRequest { return @() } + + Invoke-CIPPStandardPhishProtection -Tenant 'contoso.onmicrosoft.com' -Settings ([pscustomobject]@{ + remediate = $true + alert = $false + report = $false + }) + + Should -Invoke New-GraphPostRequest -Times 1 -Exactly -ParameterFilter { + $Type -eq 'POST' -and $Uri -like '*/branding/localizations/' + } + Should -Invoke New-GraphPostRequest -Times 1 -Exactly -ParameterFilter { + $Type -eq 'PUT' -and $Uri -like '*/branding/localizations/0/customCSS' + } + } + + It 'treats a create conflict as a recovered race when id zero appeared after the list' { + Mock New-GraphGetRequest { @() } + Mock New-GraphPostRequest { + param($tenantid, $Uri, $ContentType, $AsApp, $Type, $Body, $AddedHeaders) + if ($Type -eq 'POST') { throw 'Another object with the same value for property id already exists.' } + } + Mock Get-CippException { + [pscustomobject]@{ + NormalizedError = 'Another object with the same value for property id already exists.' + RawError = '{"error":{"code":"Request_BadRequest","details":[{"code":"ObjectConflict","target":"id"}]}}' + } + } + + Invoke-CIPPStandardPhishProtection -Tenant 'contoso.onmicrosoft.com' -Settings ([pscustomobject]@{ + remediate = $true + alert = $false + report = $false + }) + + Should -Invoke Write-LogMessage -Times 1 -Exactly -ParameterFilter { + $sev -eq 'Info' -and $message -like 'Default branding localization already exists*' + } + Should -Invoke Write-LogMessage -Times 0 -ParameterFilter { + $sev -eq 'Error' -and $message -like 'Failed to create default branding localization*' + } + Should -Invoke New-GraphPostRequest -Times 1 -Exactly -ParameterFilter { + $Type -eq 'PUT' -and $Uri -like '*/branding/localizations/0/customCSS' + } + } + + It 'keeps unexpected default localization creation failures at error severity' { + Mock New-GraphGetRequest { @() } + Mock New-GraphPostRequest { + param($tenantid, $Uri, $ContentType, $AsApp, $Type, $Body, $AddedHeaders) + if ($Type -eq 'POST') { throw 'Authorization_RequestDenied' } + } + Mock Get-CippException { + [pscustomobject]@{ + NormalizedError = 'Authorization_RequestDenied' + RawError = '{"error":{"code":"Authorization_RequestDenied"}}' + } + } + + Invoke-CIPPStandardPhishProtection -Tenant 'contoso.onmicrosoft.com' -Settings ([pscustomobject]@{ + remediate = $true + alert = $false + report = $false + }) + + Should -Invoke Write-LogMessage -Times 1 -Exactly -ParameterFilter { + $sev -eq 'Error' -and $message -like 'Failed to create default branding localization*Authorization_RequestDenied*' + } + Should -Invoke Write-LogMessage -Times 0 -ParameterFilter { + $sev -eq 'Info' -and $message -like 'Default branding localization already exists*' + } + } +} From 2344dbbf0daf032d6cf69a79bd1887084866ecc5 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Thu, 13 Aug 2026 23:06:54 -0400 Subject: [PATCH 034/226] Update CippDataTable.test.jsx --- frontend/tests/components/CippTable/CippDataTable.test.jsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/frontend/tests/components/CippTable/CippDataTable.test.jsx b/frontend/tests/components/CippTable/CippDataTable.test.jsx index 0307f75697..f8bdaa4ed1 100644 --- a/frontend/tests/components/CippTable/CippDataTable.test.jsx +++ b/frontend/tests/components/CippTable/CippDataTable.test.jsx @@ -357,8 +357,9 @@ describe('CippDataTable card view without an offCanvas', () => { await waitFor(() => expect(screen.getByText('Alice Smith')).toBeInTheDocument()) await user.click(screen.getByText('Alice Smith')) - // 'text' mode would flatten the boolean to the string "Yes"; the cell renderer uses an icon - await waitFor(() => expect(screen.getAllByText(/contoso\.com/).length).toBeGreaterThan(0)) + // 'text' mode would flatten the boolean to the string "Yes"; the cell renderer uses an icon. + // Anchored: unanchored, this would also pass on "notcontoso.com" — and CodeQL flags it. + await waitFor(() => expect(screen.getAllByText(/^contoso\.com$/).length).toBeGreaterThan(0)) expect(screen.queryByText('Yes')).toBeNull() }) @@ -425,7 +426,7 @@ describe('CippDataTable card view without an offCanvas', () => { await waitFor(() => expect(screen.getByText('User Details')).toBeInTheDocument()) // curated fields present, and the ones it left out are appended rather than dropped - expect(screen.getAllByText(/alice@contoso\.com/).length).toBeGreaterThan(0) + expect(screen.getAllByText(/^alice@contoso\.com$/).length).toBeGreaterThan(0) expect(screen.getAllByText(/Engineer/).length).toBeGreaterThan(0) expect(screen.getAllByText(/Seattle/).length).toBeGreaterThan(0) }) From 9e468219b48fb68c70568bef99b8a89e84cb30be Mon Sep 17 00:00:00 2001 From: k-grube Date: Thu, 13 Aug 2026 20:51:08 -0700 Subject: [PATCH 035/226] fix(mobile): cards->table toggle scroll and mobile nav drawer surface --- .../src/components/CippTable/CippDataTable.js | 28 ++++++---- frontend/src/layouts/mobile-nav.js | 3 +- .../CippTable/CippDataTable.test.jsx | 53 +++++++++++++++++++ 3 files changed, 72 insertions(+), 12 deletions(-) diff --git a/frontend/src/components/CippTable/CippDataTable.js b/frontend/src/components/CippTable/CippDataTable.js index d97fdb04a8..2a5359457c 100644 --- a/frontend/src/components/CippTable/CippDataTable.js +++ b/frontend/src/components/CippTable/CippDataTable.js @@ -85,20 +85,24 @@ const compareNullable = (aVal, bVal) => { return aVal > bVal ? 1 : -1 } -// walk up from the toggled node to the page's scrolling ancestor (LayoutContainer, -// overflowY auto) and reset it, so a narrow-table height measurement taken right after -// starts from a deterministic scroll position -const scrollScrollableAncestorToTop = (node) => { - let ancestor = node?.parentElement +// walk up from the card surface to the page's scrolling ancestor (LayoutContainer, +// overflowY auto) and align the surface with its top. the table flips in at the same +// page slot, so the height measurement taken right after reads a deterministic top +// and pages with content above the table keep the table in view +const scrollNodeToScrollableAncestorTop = (node) => { + if (!node) { + return + } + let ancestor = node.parentElement while (ancestor && ancestor !== document.body) { const overflowY = window.getComputedStyle(ancestor).overflowY if (overflowY === 'auto' || overflowY === 'scroll') { - ancestor.scrollTop = 0 + ancestor.scrollTop += node.getBoundingClientRect().top - ancestor.getBoundingClientRect().top return } ancestor = ancestor.parentElement } - window.scrollTo(0, 0) + window.scrollTo(0, window.scrollY + node.getBoundingClientRect().top) } // ── Module-level constants ────────────────────────────────────────────────── @@ -957,13 +961,13 @@ export const CippDataTable = (props) => { }, []) // the flipped table shows whatever columns are visible; horizontal scroll covers the width - const handleViewToggle = useCallback((event) => { + const cardViewSurfaceRef = useRef(null) + const handleViewToggle = useCallback(() => { const nextView = effectiveViewMode === 'table' ? 'cards' : 'table' setViewOverride(nextView) if (nextView === 'table' && isNarrowViewport) { - scrollScrollableAncestorToTop(event?.currentTarget) + scrollNodeToScrollableAncestorTop(cardViewSurfaceRef.current) } - // eslint-disable-next-line react-hooks/exhaustive-deps }, [effectiveViewMode, isNarrowViewport]) // Memoize renderRowActionMenuItems to avoid re-creating on each render. @@ -1186,7 +1190,8 @@ export const CippDataTable = (props) => { const footer = table.refs.bottomToolbarRef?.current?.offsetHeight ?? 0 // chrome between the paper's bottom edge and the page bottom (CardContent padding + page gap) const BELOW_PAPER_PX = 40 - // viewport-relative, so a scrolled page needs the toggle handler to reset scroll first + // viewport-relative, the toggle handler aligns the card surface with the scroll + // viewport top first so this reads a deterministic position const top = container.getBoundingClientRect().top let next = Math.max(240, Math.floor(window.innerHeight - top - footer - BELOW_PAPER_PX)) // 120 = minimal chrome allowance, keeps the table from claiming the full viewport @@ -1313,6 +1318,7 @@ export const CippDataTable = (props) => { {isCardView ? ( // same paper surface as the table path; overflow visible keeps the controls bar sticky { slotProps={{ transition: swipeClose.transitionProps }} PaperProps={{ sx: { + // desktop side-nav renders on background.default, keep the drawer on the same surface + backgroundColor: "background.default", width: MOBILE_NAV_WIDTH, // Column layout so the sponsor footer pins to the bottom and the menu scrolls // between it and the sticky header, rather than the footer riding the list. @@ -234,7 +236,6 @@ export const MobileNav = (props) => { flexShrink: 0, px: 2, pb: "calc(env(safe-area-inset-bottom) + 8px)", - bgcolor: "background.default", }} > diff --git a/frontend/tests/components/CippTable/CippDataTable.test.jsx b/frontend/tests/components/CippTable/CippDataTable.test.jsx index f8bdaa4ed1..67e439e1f1 100644 --- a/frontend/tests/components/CippTable/CippDataTable.test.jsx +++ b/frontend/tests/components/CippTable/CippDataTable.test.jsx @@ -604,3 +604,56 @@ describe('CippDataTable offcanvas row navigation', () => { expect(within(drawer).getByText('bob@contoso.com')).toBeInTheDocument() }) }) + +// the narrow-table height measurement reads viewport-relative positions, so the toggle +// aligns the card surface with the scrolling ancestor's top before the table flips in +describe('CippDataTable cards->table toggle scroll', () => { + const useMobileViewport = () => { + const cache = new Map() + window.matchMedia = (query) => { + if (!cache.has(query)) { + cache.set(query, { + matches: query.includes('max-width'), + media: query, + onchange: null, + addListener: () => {}, + removeListener: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false, + }) + } + return cache.get(query) + } + } + + beforeEach(() => { + useMobileViewport() + }) + + afterEach(() => { + delete window.matchMedia + }) + + it('keeps a mid-page table in view instead of yanking the page to the top', async () => { + const user = userEvent.setup() + renderWithProviders( +
    + +
    + ) + await waitFor(() => expect(screen.getByTestId('cipp-card-view')).toBeInTheDocument()) + + // surface sits below the scroller's viewport top, page already scrolled + const scroller = screen.getByTestId('scroller') + const surface = screen.getByTestId('cipp-card-view') + scroller.getBoundingClientRect = () => ({ top: 64 }) + surface.getBoundingClientRect = () => ({ top: 300 }) + scroller.scrollTop = 120 + + await user.click(screen.getByRole('button', { name: 'Toggle table view' })) + + // prior scroll plus the surface's offset from the scroller viewport top + expect(scroller.scrollTop).toBe(120 + (300 - 64)) + }) +}) From f41712d341f3dcb2c729f596e0c32155223185c5 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Fri, 14 Aug 2026 02:54:28 -0400 Subject: [PATCH 036/226] feat: custom role simple mode with include/exclude permission rules Custom roles are now canonically defined by PermissionRules ({Include, Exclude} -like glob arrays, same semantics as base roles in cipp-roles.json) and expanded against the live permission universe at read time, so wildcard roles automatically cover endpoints added in later releases. The flat Permissions map remains as a fail-safe snapshot for older backends. Legacy roles migrate losslessly to concrete-string rules (in memory on read, persisted by the roles list action). The role editor gains a Simple/Advanced toggle: simple mode is an include/exclude pattern builder with per-rule live match counts, zero-match warnings, grouped pattern autocomplete, an effective-permissions preview, and an automatic CIPP.Core.Read guard. Advanced mode still edits the per-category grid and now emits concrete-string rules on save. Also fixes the frontend matchPattern bug (only the first wildcard was expanded and dots were unescaped). Co-Authored-By: Claude Fable 5 --- .../ConvertTo-CippPermissionRules.ps1 | 42 ++ .../Get-CIPPRolePermissions.ps1 | 61 ++- .../CIPP/Settings/Invoke-ExecCustomRole.ps1 | 62 +++ .../CIPP/Settings/Invoke-ListCustomRole.ps1 | 42 +- .../Private/Get-CIPPRolePermissions.Tests.ps1 | 156 +++++++ .../CippSettings/CippRoleAddEdit.jsx | 394 +++++++++++++++--- .../src/components/CippSettings/CippRoles.jsx | 22 +- frontend/src/utils/permission-rules.js | 180 ++++++++ frontend/tests/utils/permission-rules.test.js | 151 +++++++ 9 files changed, 1032 insertions(+), 78 deletions(-) create mode 100644 backend/Modules/CIPPCore/Public/Authentication/ConvertTo-CippPermissionRules.ps1 create mode 100644 backend/Tests/Private/Get-CIPPRolePermissions.Tests.ps1 create mode 100644 frontend/src/utils/permission-rules.js create mode 100644 frontend/tests/utils/permission-rules.test.js diff --git a/backend/Modules/CIPPCore/Public/Authentication/ConvertTo-CippPermissionRules.ps1 b/backend/Modules/CIPPCore/Public/Authentication/ConvertTo-CippPermissionRules.ps1 new file mode 100644 index 0000000000..aacb9a08a3 --- /dev/null +++ b/backend/Modules/CIPPCore/Public/Authentication/ConvertTo-CippPermissionRules.ps1 @@ -0,0 +1,42 @@ +function ConvertTo-CippPermissionRules { + <# + .SYNOPSIS + Convert a legacy flat permission map to include/exclude rules. + .DESCRIPTION + A concrete permission string is a -like pattern that matches only itself, so + Include = the explicit non-None values is a behavior-preserving conversion. + .PARAMETER Permissions + The stored Permissions value: JSON string or object map of key -> 'Cat.Obj.Level'. + .EXAMPLE + ConvertTo-CippPermissionRules -Permissions $Role.Permissions + #> + [CmdletBinding()] + param($Permissions) + + if ($Permissions -is [string]) { + if ([string]::IsNullOrWhiteSpace($Permissions)) { + $Permissions = $null + } else { + try { + $Permissions = $Permissions | ConvertFrom-Json + } catch { + Write-Warning "ConvertTo-CippPermissionRules: could not parse permissions: $($_.Exception.Message)" + $Permissions = $null + } + } + } + + $Include = [System.Collections.Generic.List[string]]::new() + if ($Permissions) { + foreach ($Value in $Permissions.PSObject.Properties.Value) { + if ($Value -is [string] -and $Value -ne '' -and $Value -notmatch '\.None$' -and $Include -notcontains $Value) { + $Include.Add($Value) + } + } + } + + [PSCustomObject]@{ + Include = @($Include | Sort-Object) + Exclude = @() + } +} diff --git a/backend/Modules/CIPPCore/Public/Authentication/Get-CIPPRolePermissions.ps1 b/backend/Modules/CIPPCore/Public/Authentication/Get-CIPPRolePermissions.ps1 index 37f446347a..484df05cfe 100644 --- a/backend/Modules/CIPPCore/Public/Authentication/Get-CIPPRolePermissions.ps1 +++ b/backend/Modules/CIPPCore/Public/Authentication/Get-CIPPRolePermissions.ps1 @@ -2,6 +2,13 @@ function Get-CIPPRolePermissions { <# .SYNOPSIS Get the permissions associated with a role. + .DESCRIPTION + Roles are canonically defined by PermissionRules ({Include, Exclude} -like glob + arrays, same semantics as base roles in cipp-roles.json: exclude wins). Rules are + expanded against the live permission universe at read time, so wildcard roles + automatically cover endpoints added in later releases. Rows saved before the rules + format get behavior-preserving concrete-string rules synthesized in memory; the + roles list endpoint persists the migration. .PARAMETER RoleName The role to get the permissions for. .EXAMPLE @@ -17,31 +24,57 @@ function Get-CIPPRolePermissions { $Filter = "RowKey eq '$RoleName'" $Role = Get-CIPPAzDataTableEntity @Table -Filter $Filter if ($Role) { - $Permissions = ($Role.Permissions | ConvertFrom-Json).PSObject.Properties.Value - # Stored permissions can reference endpoints removed or renamed in later CIPP - # versions; drop those so stale entries don't inflate the role's permission set - # (e.g. failing the Test-CippApiClientRoleGrant subset check). Skip filtering if - # the valid-permission universe can't be resolved, rather than emptying the role. + $Rules = $null + if ($Role.PSObject.Properties.Name -contains 'PermissionRules' -and ![string]::IsNullOrWhiteSpace($Role.PermissionRules)) { + try { + $Rules = $Role.PermissionRules | ConvertFrom-Json + } catch { + Write-Warning "Unable to parse permission rules for role '$RoleName': $($_.Exception.Message)" + } + } + if (!$Rules -or @($Rules.Include).Count -eq 0) { + $Rules = ConvertTo-CippPermissionRules -Permissions $Role.Permissions + } + + $Permissions = $null try { - $ValidPermissions = Get-CippHttpPermissions - if (@($ValidPermissions).Count -gt 0) { - $ValidBases = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) - foreach ($ValidPermission in $ValidPermissions) { - $null = $ValidBases.Add(($ValidPermission -replace '\.(ReadWrite|Read)$', '')) + $Universe = Get-CippHttpPermissions + if (@($Universe).Count -gt 0) { + $Expanded = [System.Collections.Generic.List[string]]::new() + foreach ($Permission in $Universe) { + $Allowed = $false + foreach ($Include in $Rules.Include) { + if ($Permission -like $Include) { $Allowed = $true; break } + } + if ($Allowed) { + foreach ($Exclude in $Rules.Exclude) { + if ($Permission -like $Exclude) { $Allowed = $false; break } + } + } + if ($Allowed) { $Expanded.Add($Permission) } } - $Permissions = @($Permissions | Where-Object { - $ValidBases.Contains(($_ -replace '\.(ReadWrite|Read)$', '')) - }) + $Permissions = $Expanded } } catch { - Write-Warning "Unable to resolve valid permissions to filter role '$RoleName': $($_.Exception.Message)" + Write-Warning "Unable to expand permission rules for role '$RoleName': $($_.Exception.Message)" } + if ($null -eq $Permissions) { + # Universe unavailable: fall back to the stored snapshot rather than emptying + # the role. Never expand wildcards without a universe to bound them. + $Permissions = if (![string]::IsNullOrWhiteSpace($Role.Permissions)) { + ($Role.Permissions | ConvertFrom-Json).PSObject.Properties.Value | Where-Object { $_ -notmatch '\.None$' } + } else { + @() + } + } + $AllowedTenants = if ($Role.AllowedTenants) { $Role.AllowedTenants | ConvertFrom-Json } else { @() } $BlockedTenants = if ($Role.BlockedTenants) { $Role.BlockedTenants | ConvertFrom-Json } else { @() } $BlockedEndpoints = if ($Role.BlockedEndpoints) { $Role.BlockedEndpoints | ConvertFrom-Json } else { @() } [PSCustomObject]@{ Role = $Role.RowKey Permissions = @($Permissions) + PermissionRules = $Rules AllowedTenants = @($AllowedTenants) BlockedTenants = @($BlockedTenants) BlockedEndpoints = @($BlockedEndpoints) diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecCustomRole.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecCustomRole.ps1 index 37609dc2f7..31dfc61c50 100644 --- a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecCustomRole.ps1 +++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecCustomRole.ps1 @@ -46,10 +46,43 @@ function Invoke-ExecCustomRole { } if ($Request.Body.RoleName -notin $DefaultRoles.PSObject.Properties.Name) { + # PermissionRules ({Include, Exclude} -like globs) is the canonical + # format; older clients that only send the flat map get concrete-string + # rules synthesized from it. Invalid patterns are dropped, not saved. + $PermissionRules = $null + if ($Request.Body.PermissionRules) { + $PatternRegex = '^[A-Za-z0-9*]+(\.[A-Za-z0-9*]+){0,2}$' + $Include = [System.Collections.Generic.List[string]]::new() + $Exclude = [System.Collections.Generic.List[string]]::new() + foreach ($Pattern in @($Request.Body.PermissionRules.Include)) { + if ($Pattern -is [string] -and $Pattern -match $PatternRegex) { + if ($Include -notcontains $Pattern) { $Include.Add($Pattern) } + } elseif ($Pattern) { + $Results.Add("Ignored invalid include pattern '$Pattern'") + } + } + foreach ($Pattern in @($Request.Body.PermissionRules.Exclude)) { + if ($Pattern -is [string] -and $Pattern -match $PatternRegex) { + if ($Exclude -notcontains $Pattern) { $Exclude.Add($Pattern) } + } elseif ($Pattern) { + $Results.Add("Ignored invalid exclude pattern '$Pattern'") + } + } + if ($Include.Count -gt 0) { + $PermissionRules = [PSCustomObject]@{ + Include = @($Include) + Exclude = @($Exclude) + } + } + } + if (!$PermissionRules) { + $PermissionRules = ConvertTo-CippPermissionRules -Permissions $Request.Body.Permissions + } $Role = @{ 'PartitionKey' = 'CustomRoles' 'RowKey' = "$($Request.Body.RoleName.ToLower())" 'Permissions' = "$($Request.Body.Permissions | ConvertTo-Json -Compress)" + 'PermissionRules' = "$($PermissionRules | ConvertTo-Json -Compress -Depth 5)" 'AllowedTenants' = "$($Request.Body.AllowedTenants | ConvertTo-Json -Compress)" 'BlockedTenants' = "$($Request.Body.BlockedTenants | ConvertTo-Json -Compress)" 'BlockedEndpoints' = "$($Request.Body.BlockedEndpoints | ConvertTo-Json -Compress)" @@ -127,6 +160,7 @@ function Invoke-ExecCustomRole { 'PartitionKey' = 'CustomRoles' 'RowKey' = "$($Request.Body.NewRoleName.ToLower())" 'Permissions' = $ExistingRole.Permissions + 'PermissionRules' = "$($ExistingRole.PermissionRules)" 'AllowedTenants' = $ExistingRole.AllowedTenants 'BlockedTenants' = $ExistingRole.BlockedTenants 'BlockedEndpoints' = $ExistingRole.BlockedEndpoints @@ -185,12 +219,40 @@ function Invoke-ExecCustomRole { } ) } else { + # One-time migration: rows saved before the rules format gain concrete-string + # rules (behavior-preserving, Include = explicit values). Runs here because + # this superadmin-only list action is hit whenever roles are managed. + foreach ($Role in $Body) { + if ($Role.PSObject.Properties.Name -notcontains 'PermissionRules' -or [string]::IsNullOrWhiteSpace($Role.PermissionRules)) { + try { + $RulesJson = ConvertTo-CippPermissionRules -Permissions $Role.Permissions | ConvertTo-Json -Compress -Depth 5 + if ($Role.PSObject.Properties.Name -contains 'PermissionRules') { + $Role.PermissionRules = $RulesJson + } else { + $Role | Add-Member -NotePropertyName PermissionRules -NotePropertyValue $RulesJson + } + Add-CIPPAzDataTableEntity @Table -Entity $Role -Force | Out-Null + Write-LogMessage -headers $Request.Headers -API 'ExecCustomRole' -message "Migrated custom role $($Role.RowKey) to permission rules format" -Sev 'Info' + } catch { + Write-Warning "Failed to migrate custom role $($Role.RowKey) to permission rules: $($_.Exception.Message)" + } + } + } $CustomRoles = foreach ($Role in $Body) { try { $Role.Permissions = $Role.Permissions | ConvertFrom-Json } catch { $Role.Permissions = @() } + if ($Role.PermissionRules) { + try { + $Role.PermissionRules = $Role.PermissionRules | ConvertFrom-Json + } catch { + $Role.PermissionRules = $null + } + } else { + $Role | Add-Member -NotePropertyName PermissionRules -NotePropertyValue $null -Force + } if ($Role.AllowedTenants) { try { $Role.AllowedTenants = @($Role.AllowedTenants | ConvertFrom-Json) diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ListCustomRole.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ListCustomRole.ps1 index a57d615cad..e231cdcd08 100644 --- a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ListCustomRole.ps1 +++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ListCustomRole.ps1 @@ -13,6 +13,13 @@ function Invoke-ListCustomRole { $Table = Get-CippTable -tablename 'CustomRoles' $CustomRoles = Get-CIPPAzDataTableEntity @Table + $CippRolesJson = Join-Path -Path $env:CIPPRootPath -ChildPath 'Config\cipp-roles.json' + $BaseRoleConfig = if (Test-Path $CippRolesJson) { + [System.IO.File]::ReadAllText($CippRolesJson) | ConvertFrom-Json + } else { + $null + } + $AccessRoleGroupTable = Get-CippTable -tablename 'AccessRoleGroups' $RoleGroups = Get-CIPPAzDataTableEntity @AccessRoleGroupTable @@ -36,15 +43,25 @@ function Invoke-ListCustomRole { $IPRanges = @() } + $BaseRules = if ($BaseRoleConfig -and $BaseRoleConfig.$Role) { + [pscustomobject]@{ + Include = @($BaseRoleConfig.$Role.include) + Exclude = @($BaseRoleConfig.$Role.exclude) + } + } else { + $null + } + $RoleList.Add([pscustomobject]@{ - RoleName = $Role - Type = 'Built-In' - Permissions = '' - AllowedTenants = @('AllTenants') - BlockedTenants = @() - EntraGroup = $RoleGroup.GroupName ?? $null - EntraGroupId = $RoleGroup.GroupId ?? $null - IPRange = $IPRanges + RoleName = $Role + Type = 'Built-In' + Permissions = '' + PermissionRules = $BaseRules + AllowedTenants = @('AllTenants') + BlockedTenants = @() + EntraGroup = $RoleGroup.GroupName ?? $null + EntraGroupId = $RoleGroup.GroupId ?? $null + IPRange = $IPRanges }) } foreach ($Role in $CustomRoles) { @@ -58,6 +75,15 @@ function Invoke-ListCustomRole { $Role.Permissions = '' } } + if ($Role.PSObject.Properties.Name -contains 'PermissionRules' -and $Role.PermissionRules) { + try { + $Role.PermissionRules = $Role.PermissionRules | ConvertFrom-Json + } catch { + $Role.PermissionRules = $null + } + } else { + $Role | Add-Member -NotePropertyName PermissionRules -NotePropertyValue $null -Force + } if ($Role.AllowedTenants) { $RawAllowedTenants = $Role.AllowedTenants try { diff --git a/backend/Tests/Private/Get-CIPPRolePermissions.Tests.ps1 b/backend/Tests/Private/Get-CIPPRolePermissions.Tests.ps1 new file mode 100644 index 0000000000..d52bcf399d --- /dev/null +++ b/backend/Tests/Private/Get-CIPPRolePermissions.Tests.ps1 @@ -0,0 +1,156 @@ +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + + function Get-CippTable { param($tablename) @{} } + function Get-CIPPAzDataTableEntity { param($Filter, $Property) } + function Get-CippHttpPermissions { } + + . (Join-Path $RepoRoot 'Modules/CIPPCore/Public/Authentication/ConvertTo-CippPermissionRules.ps1') + . (Join-Path $RepoRoot 'Modules/CIPPCore/Public/Authentication/Get-CIPPRolePermissions.ps1') + + $script:Universe = @( + 'CIPP.Core.Read' + 'CIPP.Core.ReadWrite' + 'Identity.User.Read' + 'Identity.User.ReadWrite' + 'Identity.Device.Read' + 'Identity.Device.ReadWrite' + 'Exchange.Mailbox.Read' + 'Exchange.Mailbox.ReadWrite' + 'Tenant.Administration.Read' + 'Tenant.Administration.ReadWrite' + ) +} + +Describe 'ConvertTo-CippPermissionRules' { + It 'converts a flat map to sorted concrete includes, dropping None and duplicates' { + $Permissions = @{ + IdentityUser = 'Identity.User.ReadWrite' + IdentityDevice = 'Identity.Device.None' + CIPPCore = 'CIPP.Core.Read' + Duplicate = 'CIPP.Core.Read' + } | ConvertTo-Json + + $Rules = ConvertTo-CippPermissionRules -Permissions $Permissions + $Rules.Include | Should -Be @('CIPP.Core.Read', 'Identity.User.ReadWrite') + @($Rules.Exclude).Count | Should -Be 0 + } + + It 'returns empty rules for missing or unparsable input' { + (ConvertTo-CippPermissionRules -Permissions '').Include | Should -HaveCount 0 + (ConvertTo-CippPermissionRules -Permissions 'not-json{{').Include | Should -HaveCount 0 + (ConvertTo-CippPermissionRules -Permissions $null).Include | Should -HaveCount 0 + } +} + +Describe 'Get-CIPPRolePermissions' { + BeforeEach { + Mock Get-CippHttpPermissions { $script:Universe } + } + + It 'expands wildcard rules against the universe, exclude wins' { + Mock Get-CIPPAzDataTableEntity { + [PSCustomObject]@{ + RowKey = 'helpdesk' + Permissions = '{}' + PermissionRules = '{"Include":["Identity.*.Read"],"Exclude":["Identity.Device.*"]}' + } + } + + $Result = Get-CIPPRolePermissions -RoleName 'helpdesk' + $Result.Permissions | Should -Be @('Identity.User.Read') + $Result.PermissionRules.Include | Should -Be @('Identity.*.Read') + } + + It 'handles multi-wildcard patterns' { + Mock Get-CIPPAzDataTableEntity { + [PSCustomObject]@{ + RowKey = 'mailboxes' + Permissions = '{}' + PermissionRules = '{"Include":["*.Mailbox.*"],"Exclude":[]}' + } + } + + (Get-CIPPRolePermissions -RoleName 'mailboxes').Permissions | + Should -Be @('Exchange.Mailbox.Read', 'Exchange.Mailbox.ReadWrite') + } + + It 'wildcard roles pick up endpoints added to the universe without a re-save' { + Mock Get-CIPPAzDataTableEntity { + [PSCustomObject]@{ + RowKey = 'readers' + Permissions = '{}' + PermissionRules = '{"Include":["*.Read"],"Exclude":[]}' + } + } + + Mock Get-CippHttpPermissions { $script:Universe + 'NewFeature.Thing.Read' } + (Get-CIPPRolePermissions -RoleName 'readers').Permissions | Should -Contain 'NewFeature.Thing.Read' + } + + It 'migrated concrete-string roles stay frozen when the universe grows' { + Mock Get-CIPPAzDataTableEntity { + [PSCustomObject]@{ + RowKey = 'frozen' + Permissions = '{}' + PermissionRules = '{"Include":["Identity.User.Read"],"Exclude":[]}' + } + } + + Mock Get-CippHttpPermissions { $script:Universe + 'NewFeature.Thing.Read' } + (Get-CIPPRolePermissions -RoleName 'frozen').Permissions | Should -Be @('Identity.User.Read') + } + + Context 'legacy rows without PermissionRules' { + BeforeEach { + Mock Get-CIPPAzDataTableEntity { + [PSCustomObject]@{ + RowKey = 'legacy' + Permissions = '{"IdentityUser":"Identity.User.ReadWrite","IdentityDevice":"Identity.Device.None","CIPPCore":"CIPP.Core.Read","Stale":"Removed.Endpoint.Read"}' + } + } + } + + It 'synthesizes rules in memory and returns the same set the old code path produced' { + $Result = Get-CIPPRolePermissions -RoleName 'legacy' + # Old path: stored values filtered to the valid universe, None entries inert. + $Result.Permissions | Sort-Object | Should -Be @('CIPP.Core.Read', 'Identity.User.ReadWrite') + $Result.PermissionRules.Include | Should -Contain 'Identity.User.ReadWrite' + $Result.PermissionRules.Include | Should -Not -Contain 'Identity.Device.None' + } + } + + Context 'universe unavailable' { + It 'falls back to the stored snapshot instead of emptying or over-granting' { + Mock Get-CippHttpPermissions { throw 'cache offline' } + Mock Get-CIPPAzDataTableEntity { + [PSCustomObject]@{ + RowKey = 'wildcards' + Permissions = '{"IdentityUser":"Identity.User.Read","IdentityDevice":"Identity.Device.None"}' + PermissionRules = '{"Include":["*"],"Exclude":[]}' + } + } + + $Result = Get-CIPPRolePermissions -RoleName 'wildcards' + $Result.Permissions | Should -Be @('Identity.User.Read') + } + + It 'returns an empty set when there is no snapshot either' { + Mock Get-CippHttpPermissions { throw 'cache offline' } + Mock Get-CIPPAzDataTableEntity { + [PSCustomObject]@{ + RowKey = 'rulesonly' + Permissions = '' + PermissionRules = '{"Include":["*"],"Exclude":[]}' + } + } + + @((Get-CIPPRolePermissions -RoleName 'rulesonly').Permissions).Count | Should -Be 0 + } + } + + It 'throws for an unknown role' { + Mock Get-CIPPAzDataTableEntity { $null } + { Get-CIPPRolePermissions -RoleName 'missing' } | Should -Throw '*not found*' + } +} diff --git a/frontend/src/components/CippSettings/CippRoleAddEdit.jsx b/frontend/src/components/CippSettings/CippRoleAddEdit.jsx index 75192d3940..9b0a5d9ed1 100644 --- a/frontend/src/components/CippSettings/CippRoleAddEdit.jsx +++ b/frontend/src/components/CippSettings/CippRoleAddEdit.jsx @@ -1,9 +1,10 @@ -import React, { useEffect, useState } from "react"; +import React, { useEffect, useMemo, useState } from "react"; import { Box, Button, Alert, + Chip, Typography, Accordion, AccordionSummary, @@ -11,6 +12,8 @@ import { Stack, SvgIcon, Skeleton, + ToggleButton, + ToggleButtonGroup, } from "@mui/material"; import { Grid } from "@mui/system"; @@ -25,6 +28,15 @@ import { InformationCircleIcon } from "@heroicons/react/24/outline"; import { CippApiResults } from "../CippComponents/CippApiResults"; import cippRoles from "../../data/cipp-roles.json"; import { GroupHeader, GroupItems } from "../CippComponents/CippAutocompleteGrouping"; +import { + matchPattern, + flattenPermissionTree, + expandRules, + rulesToFlatMap, + flatMapToRules, + validateRulePattern, + buildRuleSuggestions, +} from "../../utils/permission-rules"; export const CippRoleAddEdit = ({ selectedRole }) => { const updatePermissions = ApiPostCall({ @@ -38,6 +50,11 @@ export const CippRoleAddEdit = ({ selectedRole }) => { const [updateDefaults, setUpdateDefaults] = useState(false); const [baseRolePermissions, setBaseRolePermissions] = useState({}); const [isBaseRole, setIsBaseRole] = useState(false); + // New roles start in simple (pattern) mode; existing roles pick their mode in the + // reset effect based on whether their stored rules contain wildcards. + const [permissionMode, setPermissionMode] = useState(selectedRole ? "advanced" : "simple"); + const [gridDiverged, setGridDiverged] = useState(false); + const [rulePreviewVisible, setRulePreviewVisible] = useState(false); const formControl = useForm({ mode: "onChange", @@ -47,6 +64,8 @@ export const CippRoleAddEdit = ({ selectedRole }) => { BlockedEndpoints: [], IPRange: [], Permissions: {}, + PermissionRulesInclude: [], + PermissionRulesExclude: [], }, }); @@ -76,6 +95,8 @@ export const CippRoleAddEdit = ({ selectedRole }) => { const selectedPermissions = useWatch({ control: formControl.control, name: "Permissions" }); const selectedEntraGroup = useWatch({ control: formControl.control, name: "EntraGroup" }); const ipRanges = useWatch({ control: formControl.control, name: "IPRange" }); + const includeRules = useWatch({ control: formControl.control, name: "PermissionRulesInclude" }); + const excludeRules = useWatch({ control: formControl.control, name: "PermissionRulesExclude" }); const { data: apiPermissions = [], @@ -105,9 +126,38 @@ export const CippRoleAddEdit = ({ selectedRole }) => { }); const tenants = pages[0] || []; - const matchPattern = (pattern, value) => { - const regex = new RegExp(`^${pattern.replace("*", ".*")}$`); - return regex.test(value); + const permissionUniverse = useMemo(() => flattenPermissionTree(apiPermissions), [apiPermissions]); + const ruleSuggestions = useMemo(() => buildRuleSuggestions(apiPermissions), [apiPermissions]); + const currentRules = useMemo( + () => ({ + Include: (includeRules || []).map((o) => o?.value || o).filter(Boolean), + Exclude: (excludeRules || []).map((o) => o?.value || o).filter(Boolean), + }), + [includeRules, excludeRules] + ); + const ruleExpansion = useMemo( + () => expandRules(currentRules, permissionUniverse), + [currentRules, permissionUniverse] + ); + // Login breaks without CIPP.Core.Read; save auto-adds it when rules miss it. + const coreCovered = ruleExpansion.matched.some((p) => p.startsWith("CIPP.Core.")); + + const handleModeChange = (_event, newMode) => { + if (!newMode || newMode === permissionMode) return; + if (newMode === "advanced") { + // Expand rules into the grid so the advanced view reflects the same role. + if (currentRules.Include.length > 0) { + formControl.setValue("Permissions", rulesToFlatMap(currentRules, apiPermissions)); + } + setGridDiverged(false); + } else { + const rulesGrid = rulesToFlatMap(currentRules, apiPermissions); + const diverged = + currentRules.Include.length > 0 && + Object.keys(rulesGrid).some((key) => (selectedPermissions?.[key] ?? null) !== rulesGrid[key]); + setGridDiverged(diverged); + } + setPermissionMode(newMode); }; const getFunctionDescriptionText = (description) => { @@ -277,6 +327,10 @@ export const CippRoleAddEdit = ({ selectedRole }) => { value: ip, })) || []; + const storedRules = currentPermissions?.PermissionRules; + const toRuleOptions = (list) => + Array.isArray(list) ? list.map((pattern) => ({ label: pattern, value: pattern })) : []; + formControl.reset({ Permissions: basePermissions && Object.keys(basePermissions).length > 0 @@ -288,7 +342,16 @@ export const CippRoleAddEdit = ({ selectedRole }) => { BlockedEndpoints: processedBlockedEndpoints, IPRange: processedIPRanges, EntraGroup: currentPermissions?.EntraGroup, + PermissionRulesInclude: toRuleOptions(storedRules?.Include), + PermissionRulesExclude: toRuleOptions(storedRules?.Exclude), }); + if (currentPermissions) { + // Wildcard roles open in simple mode; migrated concrete-string roles open in + // the grid, which is the friendlier view of an explicit list. + const hasWildcards = storedRules?.Include?.some((pattern) => pattern.includes("*")); + setPermissionMode(hasWildcards ? "simple" : "advanced"); + setGridDiverged(false); + } } }, [customRoleList, customRoleListSuccess, tenantsSuccess, baseRolePermissions]); @@ -383,11 +446,28 @@ export const CippRoleAddEdit = ({ selectedRole }) => { return ip?.value || ip; }) || []; + // PermissionRules is the canonical format for both modes: simple mode sends the + // authored patterns, advanced mode sends concrete strings derived from the grid. + // Permissions stays as a flat snapshot for older backends. + const activeRules = + permissionMode === "simple" + ? { + Include: + coreCovered || currentRules.Include.length === 0 + ? currentRules.Include + : [...currentRules.Include, "CIPP.Core.Read"], + Exclude: currentRules.Exclude, + } + : flatMapToRules(selectedPermissions); + const snapshotPermissions = + permissionMode === "simple" ? rulesToFlatMap(activeRules, apiPermissions) : selectedPermissions; + updatePermissions.mutate({ url: "/api/ExecCustomRole?Action=AddUpdate", data: { RoleName: values?.["RoleName"], - Permissions: selectedPermissions, + Permissions: snapshotPermissions, + PermissionRules: activeRules, EntraGroup: selectedEntraGroup, AllowedTenants: processedAllowedTenants, BlockedTenants: processedBlockedTenants, @@ -788,60 +868,240 @@ export const CippRoleAddEdit = ({ selectedRole }) => { API Permissions
    {!isBaseRole && ( - - Set All Permissions - - - + Simple (patterns) + Advanced (per-category) + + )} + {!isBaseRole && permissionMode === "simple" && ( + + + Simple mode works like CIPP's built-in roles: pick what to include, then carve + out exclusions. Wildcards (*) match anything, so rules automatically cover new + features added in future CIPP releases. + + {gridDiverged && ( + + Changes made in Advanced mode are not reflected in these patterns. Saving in + Simple mode will replace the role's permissions with the patterns below. + + )} + option.category} + renderGroup={(params) => ( +
  • + {params.group} + {params.children} +
  • + )} + helperText="Patterns match Category.Object.Level permission names. * matches anything." + /> + option.category} + renderGroup={(params) => ( +
  • + {params.group} + {params.children} +
  • + )} + helperText="Exclusions always win over inclusions, exactly like built-in roles." + /> + {[...currentRules.Include, ...currentRules.Exclude] + .filter((pattern) => !validateRulePattern(pattern)) + .map((pattern) => ( + + "{pattern}" is not a valid pattern. Use up to three dot-separated segments + of letters, numbers and *, e.g. Identity.User.Read or Exchange.*. + + ))} + + + Live result + + + {currentRules.Include.map((pattern) => ( + 0 ? "success" : "warning" + } + icon={ + (ruleExpansion.includeCounts[pattern] ?? 0) === 0 ? ( + + ) : undefined + } + /> + ))} + {currentRules.Exclude.map((pattern) => ( + 0 ? "error" : "warning" + } + icon={ + (ruleExpansion.excludeCounts[pattern] ?? 0) === 0 ? ( + + ) : undefined + } + /> + ))} + + {currentRules.Include.length === 0 ? ( + + Add at least one include pattern — a role with no inclusions grants no + access and cannot be saved. + + ) : ( + + + {ruleExpansion.matched.length} of{" "} + {permissionUniverse.length} permissions granted + + + + )} + {currentRules.Include.length > 0 && !coreCovered && ( + + CIPP.Core.Read is required to sign in and will be added automatically when + you save. + + )} + setRulePreviewVisible(false)} + title="Effective Permissions" + > + + + Permissions granted by the current patterns. Struck-through entries were + matched by an include pattern but removed by an exclusion. + + {ruleExpansion.matched.map((permission) => ( + + {permission} + + ))} + {Object.entries(ruleExpansion.excludedBy).map(([permission, pattern]) => ( + + {permission} (excluded by {pattern}) + + ))} + +
    )} - + {(isBaseRole || permissionMode === "advanced") && ( <> - {Object.keys(apiPermissions) - .sort() - .map((cat, catIndex) => ( - - }>{cat} - - {Object.keys(apiPermissions[cat]) - .sort() - .map((obj, index) => { - const readOnly = baseRolePermissions?.[cat] ? true : false; - return ( - - - - ); - })} - - - ))} + {!isBaseRole && ( + + Set All Permissions + + + + + + )} + + <> + {Object.keys(apiPermissions) + .sort() + .map((cat, catIndex) => ( + + }> + {cat} + + + {Object.keys(apiPermissions[cat]) + .sort() + .map((obj, index) => { + const readOnly = baseRolePermissions?.[cat] ? true : false; + return ( + + + + ); + })} + + + ))} + + - + )} )}
    @@ -898,7 +1158,27 @@ export const CippRoleAddEdit = ({ selectedRole }) => { )} - {selectedPermissions && apiPermissionSuccess && ( + {!isBaseRole && permissionMode === "simple" && currentRules.Include.length > 0 && ( + <> +
    Permission Rules
    +
      + {currentRules.Include.map((pattern) => ( +
    • + + {pattern} +
    • + ))} + {currentRules.Exclude.map((pattern) => ( +
    • + − {pattern} +
    • + ))} +
    + + {ruleExpansion.matched.length} permissions granted + + + )} + {(isBaseRole || permissionMode === "advanced") && selectedPermissions && apiPermissionSuccess && ( <>
    Selected Permissions
      @@ -931,7 +1211,13 @@ export const CippRoleAddEdit = ({ selectedRole }) => { customRoleListFetching || apiPermissionFetching || tenantsFetching || - !formState.isValid + !formState.isValid || + (!isBaseRole && + permissionMode === "simple" && + (currentRules.Include.length === 0 || + [...currentRules.Include, ...currentRules.Exclude].some( + (pattern) => !validateRulePattern(pattern) + ))) } startIcon={ diff --git a/frontend/src/components/CippSettings/CippRoles.jsx b/frontend/src/components/CippSettings/CippRoles.jsx index 66f637f747..96f3f37735 100644 --- a/frontend/src/components/CippSettings/CippRoles.jsx +++ b/frontend/src/components/CippSettings/CippRoles.jsx @@ -1,5 +1,5 @@ import React from "react"; -import { Box, Button, SvgIcon } from "@mui/material"; +import { Box, Button, Chip, SvgIcon } from "@mui/material"; import { CippDataTable } from "../CippTable/CippDataTable"; import { PencilIcon, TrashIcon, DocumentDuplicateIcon } from "@heroicons/react/24/outline"; import NextLink from "next/link"; @@ -81,9 +81,27 @@ const CippRoles = () => { } }); + const rules = data["PermissionRules"]; + const hasRules = Array.isArray(rules?.Include) && rules.Include.length > 0; + if (hasRules) { + properties.push({ + label: "Permission Rules", + value: ( + + {rules.Include.map((pattern, idx) => ( + + ))} + {(rules.Exclude || []).map((pattern, idx) => ( + + ))} + + ), + }); + } + if (data["Permissions"] && Object.keys(data["Permissions"]).length > 0) { properties.push({ - label: "Permissions", + label: hasRules ? "Effective Permissions (at last save)" : "Permissions", value: ( {Object.keys(data["Permissions"]) diff --git a/frontend/src/utils/permission-rules.js b/frontend/src/utils/permission-rules.js new file mode 100644 index 0000000000..56f8a6048a --- /dev/null +++ b/frontend/src/utils/permission-rules.js @@ -0,0 +1,180 @@ +/** + * Permission rule helpers for custom roles. + * + * Rules use the same include/exclude glob format as base roles (cipp-roles.json): + * patterns match against "Category.Object.Read|ReadWrite" strings, exclude wins. + * Matching mirrors PowerShell -like: * is the only wildcard, case-insensitive. + */ + +const escapeRegex = (str) => str.replace(/[.+?^${}()|[\]\\]/g, '\\$&') + +export const matchPattern = (pattern, value) => { + if (typeof pattern !== 'string' || typeof value !== 'string') return false + const regex = new RegExp( + `^${escapeRegex(pattern).replace(/\*/g, '.*')}$`, + 'i' + ) + return regex.test(value) +} + +// Flatten the ExecAPIPermissionList tree ({Cat: {Obj: {Read|ReadWrite: {...}}}}) +// into the sorted list of concrete permission strings. +export const flattenPermissionTree = (apiPermissions) => { + const universe = [] + if (!apiPermissions || typeof apiPermissions !== 'object') return universe + Object.keys(apiPermissions).forEach((cat) => { + Object.keys(apiPermissions[cat] || {}).forEach((obj) => { + Object.keys(apiPermissions[cat][obj] || {}).forEach((type) => { + universe.push(`${cat}.${obj}.${type}`) + }) + }) + }) + return universe.sort() +} + +const normalizeRuleList = (list) => + (Array.isArray(list) ? list : []) + .map((entry) => (typeof entry === 'string' ? entry : entry?.value)) + .filter((entry) => typeof entry === 'string' && entry.length > 0) + +/** + * Expand include/exclude rules over a permission universe. + * Returns the matched permissions plus per-pattern stats for the live preview: + * - includeCounts: pattern -> total universe matches + * - excludeCounts: pattern -> included permissions this pattern removed + * - excludedBy: permission -> first exclude pattern that removed it + */ +export const expandRules = (rules, universe) => { + const include = normalizeRuleList(rules?.Include) + const exclude = normalizeRuleList(rules?.Exclude) + const includeCounts = {} + const excludeCounts = {} + const excludedBy = {} + include.forEach((pattern) => (includeCounts[pattern] = 0)) + exclude.forEach((pattern) => (excludeCounts[pattern] = 0)) + + const matched = [] + ;(universe || []).forEach((permission) => { + let included = false + include.forEach((pattern) => { + if (matchPattern(pattern, permission)) { + includeCounts[pattern] += 1 + included = true + } + }) + if (!included) return + const excludedByPattern = exclude.find((pattern) => + matchPattern(pattern, permission) + ) + if (excludedByPattern !== undefined) { + excludeCounts[excludedByPattern] += 1 + excludedBy[permission] = excludedByPattern + return + } + matched.push(permission) + }) + + return { matched, includeCounts, excludeCounts, excludedBy } +} + +/** + * Convert rules into the flat editor/storage map: { "CatObj": "Cat.Obj.None|Read|ReadWrite" }. + * ReadWrite beats Read; CIPP.Core is floored at Read (login breaks without it). + */ +export const rulesToFlatMap = (rules, apiPermissions) => { + const flat = {} + if (!apiPermissions || typeof apiPermissions !== 'object') return flat + const include = normalizeRuleList(rules?.Include) + const exclude = normalizeRuleList(rules?.Exclude) + + const granted = (permission) => + include.some((pattern) => matchPattern(pattern, permission)) && + !exclude.some((pattern) => matchPattern(pattern, permission)) + + Object.keys(apiPermissions).forEach((cat) => { + Object.keys(apiPermissions[cat] || {}).forEach((obj) => { + let level = 'None' + if (granted(`${cat}.${obj}.ReadWrite`)) { + level = 'ReadWrite' + } else if (granted(`${cat}.${obj}.Read`)) { + level = 'Read' + } + if (cat === 'CIPP' && obj === 'Core' && level === 'None') { + level = 'Read' + } + flat[`${cat}${obj}`] = `${cat}.${obj}.${level}` + }) + }) + return flat +} + +// Convert the flat map back into concrete-string rules (the canonical storage +// format for advanced-mode roles): Include = explicit non-None values. +export const flatMapToRules = (flatMap) => { + const include = [ + ...new Set( + Object.values(flatMap || {}).filter( + (value) => + typeof value === 'string' && + value.length > 0 && + !value.endsWith('.None') + ) + ), + ].sort() + return { Include: include, Exclude: [] } +} + +// 1-3 dot-separated segments of letters/digits/wildcards, e.g. "*", "*.Read", +// "Identity.User.*", "Identity.User.ReadWrite". Same grammar the backend enforces. +export const validateRulePattern = (str) => + typeof str === 'string' && /^[A-Za-z0-9*]+(\.[A-Za-z0-9*]+){0,2}$/.test(str) + +// Suggestion options for the rule autocompletes, grouped for CippAutocompleteGrouping. +export const buildRuleSuggestions = (apiPermissions) => { + const suggestions = [ + { label: '* (everything)', value: '*', category: 'Global' }, + { label: '*.Read (all read-only)', value: '*.Read', category: 'Global' }, + { + label: '*.ReadWrite (all read/write)', + value: '*.ReadWrite', + category: 'Global', + }, + ] + if (!apiPermissions || typeof apiPermissions !== 'object') return suggestions + Object.keys(apiPermissions) + .sort() + .forEach((cat) => { + suggestions.push({ + label: `${cat}.* (entire category)`, + value: `${cat}.*`, + category: cat, + }) + suggestions.push({ + label: `${cat}.*.Read`, + value: `${cat}.*.Read`, + category: cat, + }) + suggestions.push({ + label: `${cat}.*.ReadWrite`, + value: `${cat}.*.ReadWrite`, + category: cat, + }) + Object.keys(apiPermissions[cat] || {}) + .sort() + .forEach((obj) => { + suggestions.push({ + label: `${cat}.${obj}.*`, + value: `${cat}.${obj}.*`, + category: cat, + }) + Object.keys(apiPermissions[cat][obj] || {}).forEach((type) => { + suggestions.push({ + label: `${cat}.${obj}.${type}`, + value: `${cat}.${obj}.${type}`, + category: cat, + }) + }) + }) + }) + return suggestions +} diff --git a/frontend/tests/utils/permission-rules.test.js b/frontend/tests/utils/permission-rules.test.js new file mode 100644 index 0000000000..a78e863be3 --- /dev/null +++ b/frontend/tests/utils/permission-rules.test.js @@ -0,0 +1,151 @@ +import { + matchPattern, + flattenPermissionTree, + expandRules, + rulesToFlatMap, + flatMapToRules, + validateRulePattern, + buildRuleSuggestions, +} from '../../src/utils/permission-rules' + +// Shape returned by /api/ExecAPIPermissionList: Cat -> Obj -> Read|ReadWrite -> functions +const apiPermissions = { + CIPP: { + Core: { Read: {}, ReadWrite: {} }, + }, + Identity: { + User: { Read: {}, ReadWrite: {} }, + Device: { Read: {}, ReadWrite: {} }, + }, + Exchange: { + Mailbox: { Read: {}, ReadWrite: {} }, + }, +} + +const universe = flattenPermissionTree(apiPermissions) + +describe('matchPattern', () => { + it('mirrors PowerShell -like: multiple wildcards all expand', () => { + // The old implementation only replaced the first *; this pattern needs both. + expect(matchPattern('CIPP.*.Read*', 'CIPP.Core.ReadWrite')).toBe(true) + expect(matchPattern('*.Mailbox.*', 'Exchange.Mailbox.Read')).toBe(true) + }) + + it('treats dots as literal separators, not regex wildcards', () => { + expect(matchPattern('Identity.User.Read', 'IdentityXUserXRead')).toBe(false) + expect(matchPattern('Identity.User.Read', 'Identity.User.Read')).toBe(true) + }) + + it('is case-insensitive like -like', () => { + expect(matchPattern('identity.user.*', 'Identity.User.ReadWrite')).toBe(true) + }) + + it('anchors the pattern to the whole string', () => { + expect(matchPattern('Identity.User', 'Identity.User.Read')).toBe(false) + expect(matchPattern('*.Read', 'Identity.User.ReadWrite')).toBe(false) + }) +}) + +describe('flattenPermissionTree', () => { + it('lists every Cat.Obj.Level string, sorted', () => { + expect(universe).toEqual([ + 'CIPP.Core.Read', + 'CIPP.Core.ReadWrite', + 'Exchange.Mailbox.Read', + 'Exchange.Mailbox.ReadWrite', + 'Identity.Device.Read', + 'Identity.Device.ReadWrite', + 'Identity.User.Read', + 'Identity.User.ReadWrite', + ]) + }) + + it('handles a missing tree', () => { + expect(flattenPermissionTree(undefined)).toEqual([]) + }) +}) + +describe('expandRules', () => { + it('grants includes minus excludes, exclude wins', () => { + const { matched, excludedBy } = expandRules( + { Include: ['Identity.*'], Exclude: ['Identity.Device.*'] }, + universe, + ) + expect(matched).toEqual(['Identity.User.Read', 'Identity.User.ReadWrite']) + expect(excludedBy['Identity.Device.Read']).toBe('Identity.Device.*') + }) + + it('reports per-pattern match counts for the live preview', () => { + const { includeCounts, excludeCounts } = expandRules( + { Include: ['*.Read', 'Identity.Uesr.*'], Exclude: ['CIPP.*'] }, + universe, + ) + expect(includeCounts['*.Read']).toBe(4) + // Typo'd pattern matches nothing — this is what powers the zero-match warning. + expect(includeCounts['Identity.Uesr.*']).toBe(0) + expect(excludeCounts['CIPP.*']).toBe(1) + }) + + it('accepts autocomplete option objects as rule entries', () => { + const { matched } = expandRules( + { Include: [{ label: 'Identity.User.Read', value: 'Identity.User.Read' }], Exclude: [] }, + universe, + ) + expect(matched).toEqual(['Identity.User.Read']) + }) +}) + +describe('rulesToFlatMap', () => { + it('produces the editor grid map with ReadWrite beating Read', () => { + const flat = rulesToFlatMap({ Include: ['Identity.User.*'], Exclude: [] }, apiPermissions) + expect(flat['IdentityUser']).toBe('Identity.User.ReadWrite') + expect(flat['IdentityDevice']).toBe('Identity.Device.None') + }) + + it('floors CIPP.Core at Read so a saved snapshot never locks out sign-in', () => { + const flat = rulesToFlatMap({ Include: ['Exchange.*'], Exclude: [] }, apiPermissions) + expect(flat['CIPPCore']).toBe('CIPP.Core.Read') + }) + + it('honours excludes', () => { + const flat = rulesToFlatMap( + { Include: ['Identity.*'], Exclude: ['Identity.User.ReadWrite'] }, + apiPermissions, + ) + expect(flat['IdentityUser']).toBe('Identity.User.Read') + }) +}) + +describe('flatMapToRules', () => { + it('converts a grid map to concrete-string rules, dropping None', () => { + expect( + flatMapToRules({ + IdentityUser: 'Identity.User.ReadWrite', + IdentityDevice: 'Identity.Device.None', + CIPPCore: 'CIPP.Core.Read', + }), + ).toEqual({ Include: ['CIPP.Core.Read', 'Identity.User.ReadWrite'], Exclude: [] }) + }) +}) + +describe('validateRulePattern', () => { + it.each(['*', '*.Read', 'Identity.*', 'Identity.User.*', 'Identity.User.ReadWrite'])( + 'accepts %s', + (pattern) => expect(validateRulePattern(pattern)).toBe(true), + ) + + it.each(['', 'Identity.User.Read.Extra', 'Identity User', 'Identity..Read', 'a.b.c;drop'])( + 'rejects %s', + (pattern) => expect(validateRulePattern(pattern)).toBe(false), + ) +}) + +describe('buildRuleSuggestions', () => { + it('offers global, category and concrete patterns', () => { + const values = buildRuleSuggestions(apiPermissions).map((o) => o.value) + expect(values).toContain('*') + expect(values).toContain('Identity.*') + expect(values).toContain('Identity.User.*') + expect(values).toContain('Identity.User.ReadWrite') + }) +}) From 2a61c0f4bcc128a9e055d5d93b6b1b4db12a1477 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Fri, 14 Aug 2026 20:05:11 +0800 Subject: [PATCH 037/226] perf(auditlog): performance improvements --- .../Webhooks/Push-AuditLogProcessingBatch.ps1 | 33 +- .../Webhooks/Push-AuditLogTenantProcess.ps1 | 23 +- .../Get-CippAuditLogPlannedWindows.ps1 | 18 +- .../Start-AuditLogIngestionV2.ps1 | 28 +- .../Start-AuditLogPlannerV2.ps1 | 4 +- .../Start-AuditLogSearchCreationV2.ps1 | 44 +- .../Public/Get-CIPPGeoIPLocationBatch.ps1 | 29 +- .../Get-CippAuditLogLegacyCacheRow.ps1 | 64 ++ .../Webhooks/Invoke-CIPPWebhookProcessing.ps1 | 93 ++- .../Webhooks/Push-AuditLogDownloadV2.ps1 | 51 +- .../Webhooks/Push-AuditLogProcessV2.ps1 | 12 +- .../Push-AuditLogProcessingBatchV2.ps1 | 112 ++-- .../Webhooks/Push-AuditLogTenantProcessV2.ps1 | 262 +++++--- .../Set-CippAuditLogWindowProcessed.ps1 | 42 ++ .../Webhooks/Test-CIPPAuditLogRules.ps1 | 606 +++++++++++++----- .../Get-CippAuditLogPlannedWindows.Tests.ps1 | 114 ++++ .../Invoke-CIPPWebhookProcessing.Tests.ps1 | 208 ++++++ .../Push-AuditLogDownloadV2.Tests.ps1 | 86 ++- .../Push-AuditLogProcessingBatchV2.Tests.ps1 | 168 +++++ .../Push-AuditLogTenantProcessV2.Tests.ps1 | 336 +++++++--- .../Webhooks/Test-CIPPAuditLogRules.Tests.ps1 | 177 ++++- 21 files changed, 2049 insertions(+), 461 deletions(-) create mode 100644 backend/Modules/CIPPCore/Public/Webhooks/Get-CippAuditLogLegacyCacheRow.ps1 create mode 100644 backend/Modules/CIPPCore/Public/Webhooks/Set-CippAuditLogWindowProcessed.ps1 create mode 100644 backend/Tests/AuditLogs/Get-CippAuditLogPlannedWindows.Tests.ps1 create mode 100644 backend/Tests/Webhooks/Invoke-CIPPWebhookProcessing.Tests.ps1 create mode 100644 backend/Tests/Webhooks/Push-AuditLogProcessingBatchV2.Tests.ps1 diff --git a/backend/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Webhooks/Push-AuditLogProcessingBatch.ps1 b/backend/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Webhooks/Push-AuditLogProcessingBatch.ps1 index da1a92b168..afd8378e92 100644 --- a/backend/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Webhooks/Push-AuditLogProcessingBatch.ps1 +++ b/backend/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Webhooks/Push-AuditLogProcessingBatch.ps1 @@ -44,15 +44,32 @@ function Push-AuditLogProcessingBatch { $Rows = @($TenantGroup.Group) $RowIds = @($Rows.RowKey) - # Claim these rows so subsequent timer runs skip them (UpsertMerge preserves JSON and other fields) - # The entity Timestamp is updated automatically on write and used for stale detection. - foreach ($Row in $Rows) { - $ClaimEntity = [PSCustomObject]@{ - PartitionKey = $Row.PartitionKey - RowKey = $Row.RowKey - CippProcessing = $true + # Claim these rows so subsequent timer runs skip them; the entity Timestamp is + # refreshed on write and used for stale detection. Claim by updating, never + # upserting: an upsert on a row a concurrent batch just deleted recreates it as + # an unparseable shell that re-enters every claim cycle. One deleted row fails + # its whole stamp chunk, so the fallback re-stamps row-by-row and lets the + # missing rows go. + $StampSize = 100 + for ($Offset = 0; $Offset -lt $Rows.Count; $Offset += $StampSize) { + $Stamps = @($Rows[$Offset..([Math]::Min($Offset + $StampSize - 1, $Rows.Count - 1))] | ForEach-Object { + [PSCustomObject]@{ + PartitionKey = $_.PartitionKey + RowKey = $_.RowKey + CippProcessing = $true + } + }) + try { + Update-CIPPAzDataTableEntity @WebhookCacheTable -Entity $Stamps + } catch { + foreach ($Stamp in $Stamps) { + try { + Update-CIPPAzDataTableEntity @WebhookCacheTable -Entity $Stamp + } catch { + Write-Information "AuditLogProcessingBatch: row $($Stamp.RowKey) for $TenantFilter vanished before it could be claimed; skipping" + } + } } - Add-CIPPAzDataTableEntity @WebhookCacheTable -Entity $ClaimEntity -OperationType UpsertMerge } $TotalRows += $RowIds.Count diff --git a/backend/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Webhooks/Push-AuditLogTenantProcess.ps1 b/backend/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Webhooks/Push-AuditLogTenantProcess.ps1 index f52eef6327..5037fb6927 100644 --- a/backend/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Webhooks/Push-AuditLogTenantProcess.ps1 +++ b/backend/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Webhooks/Push-AuditLogTenantProcess.ps1 @@ -9,13 +9,34 @@ function Push-AuditLogTenantProcess { # Get the CacheWebhooks table $CacheWebhooksTable = Get-CippTable -TableName 'CacheWebhooks' # we do it this way because the rows can grow extremely large, if we get them all it might just hang for minutes at a time. + $Poison = [System.Collections.Generic.List[object]]::new() $Rows = foreach ($RowId in $RowIds) { $CacheEntity = Get-CIPPAzDataTableEntity @CacheWebhooksTable -Filter "PartitionKey eq '$TenantFilter' and RowKey eq '$RowId'" if ($CacheEntity) { - $AuditData = $CacheEntity.JSON | ConvertFrom-Json -ErrorAction SilentlyContinue + # try/catch, not -ErrorAction: ConvertFrom-Json parse failures are terminating, + # so without the catch one garbled row aborts the whole batch via the outer catch. + try { + $AuditData = $CacheEntity.JSON | ConvertFrom-Json -ErrorAction Stop + } catch { + $AuditData = $null + } + if ($null -eq $AuditData) { + # A row whose JSON can never parse can never be drained; left in place it + # re-enters every claim cycle forever. Delete it. + Write-Information "Audit Logs: removing unparseable cache row $($CacheEntity.RowKey) ($TenantFilter)" + $Poison.Add([PSCustomObject]@{ PartitionKey = [string]$CacheEntity.PartitionKey; RowKey = [string]$CacheEntity.RowKey }) + continue + } $AuditData } } + if ($Poison.Count -gt 0) { + try { + $null = Remove-CIPPAzDataTableEntity -Force @CacheWebhooksTable -Entity $Poison.ToArray() + } catch { + Write-Information "Audit Logs: failed to remove $($Poison.Count) unparseable row(s) for ${TenantFilter}: $($_.Exception.Message)" + } + } if ($Rows.Count -gt 0) { Write-Information "Retrieved $($Rows.Count) rows from cache for processing" diff --git a/backend/Modules/CIPPCore/Public/AuditLogs/Get-CippAuditLogPlannedWindows.ps1 b/backend/Modules/CIPPCore/Public/AuditLogs/Get-CippAuditLogPlannedWindows.ps1 index a33fffb19a..bf90ef5889 100644 --- a/backend/Modules/CIPPCore/Public/AuditLogs/Get-CippAuditLogPlannedWindows.ps1 +++ b/backend/Modules/CIPPCore/Public/AuditLogs/Get-CippAuditLogPlannedWindows.ps1 @@ -5,11 +5,21 @@ function Get-CippAuditLogPlannedWindows { .DESCRIPTION Pure helper for the V2 audit-log pipeline. Windows are 35 minutes long on a 30-minute stride, so consecutive windows overlap by 5 minutes (covers boundary stragglers; alerting dedups by - record id). Window ENDS sit on the 30-minute grid minus the settle (i.e. :25 / :55), which is + record id). Window ENDS sit on the 30-minute grid minus the settle (i.e. :10 / :40), which is exactly `floor_to_30min(now) - settle`. With the planner timer firing at :00/:15/:30/:45 and a - 5-minute settle, a fresh window becomes creatable exactly at a :00/:30 tick - no tick delay - + 20-minute settle, a fresh window becomes creatable exactly at a :00/:30 tick - no tick delay - and the :15/:45 ticks naturally have no new window (they do retries + download/process). + The settle is what decides how long Microsoft has to publish an event before the window + covering it is searched. An event landing at the very end of a window gets exactly `settle` + minutes of grace; at 5 that was tight enough that routinely-delayed records were missed by + this path and only picked up hours later by the 12-hour reconciliation windows. At 20 the + grace is four times longer, at the cost of ~15 minutes of extra detection latency across the + board - a window that used to be searched at :00 is now searched at :30. + + The settle must stay BELOW the stride. At 30 or more, `floor_to_30min(now) - settle` stops + producing a fresh end at each :00/:30 tick and the no-tick-delay property breaks. + Backfill of older gaps is bounded by -HorizonHours and capped at -MaxPerRun per call (oldest first). A brand-new tenant is seeded with only the newest settled window. .PARAMETER ExistingRows @@ -26,7 +36,7 @@ function Get-CippAuditLogPlannedWindows { param( [object[]]$ExistingRows, [datetime]$Now = (Get-Date).ToUniversalTime(), - [int]$SettleMinutes = 5, + [int]$SettleMinutes = 20, [int]$WindowMinutes = 35, [int]$StrideMinutes = 30, [int]$HorizonHours = 24, @@ -35,7 +45,7 @@ function Get-CippAuditLogPlannedWindows { $Now = $Now.ToUniversalTime() - # Newest window end: floor to the 30-min grid, minus the settle (lands on :25 / :55). + # Newest window end: floor to the 30-min grid, minus the settle (lands on :10 / :40). $FloorMinute = $Now.Minute - ($Now.Minute % $StrideMinutes) $Floor = [datetime]::new($Now.Year, $Now.Month, $Now.Day, $Now.Hour, $FloorMinute, 0, [System.DateTimeKind]::Utc) $NewestEnd = $Floor.AddMinutes(-$SettleMinutes) diff --git a/backend/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-AuditLogIngestionV2.ps1 b/backend/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-AuditLogIngestionV2.ps1 index ca26d3e1ca..0087045235 100644 --- a/backend/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-AuditLogIngestionV2.ps1 +++ b/backend/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-AuditLogIngestionV2.ps1 @@ -27,18 +27,26 @@ function Start-AuditLogIngestionV2 { $Ledger = Get-CippTable -TableName 'AuditLogCoverage' $Now = (Get-Date).ToUniversalTime() - # --- Download tenants: searches awaiting download (State = Created, due) --- + # One projected pass over the ledger, split into both work sets. State is not a key, so + # this is a table scan whatever the predicate; what's controllable is paying it once + # instead of once per state, and reading three columns instead of whole rows. + # AuditLogCoverage is one row per window (not per record) and pruned at 7 days, so it + # stays bounded. A partition query per in-scope tenant would trade this for N round trips. $DownloadTenants = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) - foreach ($Row in @(Get-CIPPAzDataTableEntity @Ledger -Filter "State eq 'Created'" -Property @('PartitionKey', 'RowKey', 'NextAttemptUtc'))) { - if ($Row.NextAttemptUtc -and ([datetimeoffset]$Row.NextAttemptUtc).UtcDateTime -gt $Now) { continue } - if ($Row.PartitionKey) { [void]$DownloadTenants.Add([string]$Row.PartitionKey) } - } - - # --- Process-only tenants: rows pending in the webhook cache (downloaded, not yet processed) --- - $CacheTable = Get-CippTable -TableName 'CacheWebhooks' $CacheTenants = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) - foreach ($Row in @(Get-CIPPAzDataTableEntity @CacheTable -Property @('PartitionKey', 'RowKey'))) { - if ($Row.PartitionKey) { [void]$CacheTenants.Add([string]$Row.PartitionKey) } + + $Active = @(Get-CIPPAzDataTableEntity @Ledger ` + -Filter "State eq 'Created' or State eq 'Downloaded' or State eq 'Processing'" ` + -Property @('PartitionKey', 'State', 'NextAttemptUtc')) + + foreach ($Row in $Active) { + if (-not $Row.PartitionKey) { continue } + if ($Row.State -eq 'Created') { + if ($Row.NextAttemptUtc -and ([datetimeoffset]$Row.NextAttemptUtc).UtcDateTime -gt $Now) { continue } + [void]$DownloadTenants.Add([string]$Row.PartitionKey) + } else { + [void]$CacheTenants.Add([string]$Row.PartitionKey) + } } if ($DownloadTenants.Count -eq 0 -and $CacheTenants.Count -eq 0) { diff --git a/backend/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-AuditLogPlannerV2.ps1 b/backend/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-AuditLogPlannerV2.ps1 index 4e8ad995b1..30b3d8624c 100644 --- a/backend/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-AuditLogPlannerV2.ps1 +++ b/backend/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-AuditLogPlannerV2.ps1 @@ -7,8 +7,8 @@ function Start-AuditLogPlannerV2 { Replaces the separate Start-AuditLogSearchCreationV2 and Start-AuditLogIngestionV2 timers with one planner so the whole pipeline ticks together: - Stage 1 (create) - Start-AuditLogSearchCreationV2: seeds owed 35-min windows (5-min settle, - ends on the :25/:55 grid so a fresh window is creatable exactly at :00/:30 with no tick + Stage 1 (create) - Start-AuditLogSearchCreationV2: seeds owed 35-min windows (20-min settle, + ends on the :10/:40 grid so a fresh window is creatable exactly at :00/:30 with no tick delay) plus 12-hour reconciliation windows, then creates the oldest <= 6 due windows per tenant with auto-retry disabled and manual 429 back-off. diff --git a/backend/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-AuditLogSearchCreationV2.ps1 b/backend/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-AuditLogSearchCreationV2.ps1 index e31783940a..70cfedc883 100644 --- a/backend/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-AuditLogSearchCreationV2.ps1 +++ b/backend/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-AuditLogSearchCreationV2.ps1 @@ -7,7 +7,11 @@ function Start-AuditLogSearchCreationV2 { .DESCRIPTION Replaces Start-AuditLogSearchCreation. Tenant selection is unchanged (WebhookRules Webhookv2, minus excluded, minus auditing-disabled). The key differences: - * Windows are clock-aligned, 60 minutes, NON-overlapping (tracked in AuditLogCoverage). + * Windows are clock-aligned, 35 minutes on a 30-minute stride, so consecutive windows + OVERLAP by 5 minutes (tracked in AuditLogCoverage). The overlap is deliberate - it + covers records landing on a boundary - and is safe only because alerting de-duplicates + by record id, which it does in exactly one place: the claim-insert into AuditLogs in + Invoke-CippWebhookProcessing. Nothing downstream of that de-duplicates. * Failed creations are recorded as Planned/Retry ledger rows, so they are retried (and gaps backfilled) instead of being silently dropped. * "First check what tenants need searches created" - the timer scans the ledger once and @@ -51,12 +55,35 @@ function Start-AuditLogSearchCreationV2 { } } + # Hoisted into hash sets once per rule, rather than an array -contains per tenant per rule. + # -contains is a linear scan, so the original was tenants x rules x tenants-per-rule string + # comparisons every cycle - at a few hundred tenants and a handful of AllTenants rules that + # is millions of comparisons to answer a question that is a set membership test. + # AllTenants is resolved once here too, since it does not depend on the tenant being tested. + $RuleScopes = foreach ($ConfigEntry in $ConfigEntries) { + $Included = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($Name in @($ConfigEntry.ExpandedTenants)) { + if ($Name) { [void]$Included.Add([string]$Name) } + } + $Excluded = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($Name in @($ConfigEntry.excludedTenants.value)) { + if ($Name) { [void]$Excluded.Add([string]$Name) } + } + [PSCustomObject]@{ + Included = $Included + Excluded = $Excluded + AllTenants = $Included.Contains('AllTenants') + } + } + $RuleScopes = @($RuleScopes) + $InScope = foreach ($Tenant in $TenantList) { if ($AuditDisabledTenants.Contains($Tenant.defaultDomainName) -or $AuditDisabledTenants.Contains([string]$Tenant.customerId)) { continue } $Match = $false - foreach ($ConfigEntry in $ConfigEntries) { - if ($ConfigEntry.excludedTenants.value -contains $Tenant.defaultDomainName) { continue } - if ($ConfigEntry.ExpandedTenants -contains $Tenant.defaultDomainName -or $ConfigEntry.ExpandedTenants -contains 'AllTenants') { $Match = $true; break } + $DomainName = [string]$Tenant.defaultDomainName + foreach ($Scope in $RuleScopes) { + if ($Scope.Excluded.Contains($DomainName)) { continue } + if ($Scope.AllTenants -or $Scope.Included.Contains($DomainName)) { $Match = $true; break } } if ($Match) { $Tenant } } @@ -70,7 +97,14 @@ function Start-AuditLogSearchCreationV2 { $Ledger = Get-CippTable -TableName 'AuditLogCoverage' # Cover the reconciliation horizon (48h) plus slack so the fan-out check sees existing recon rows. $HorizonIso = (Get-Date).AddHours(-50).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') - $AllRows = Get-CIPPAzDataTableEntity @Ledger -Filter "Timestamp ge datetime'$HorizonIso'" + # Projected to the five columns actually consumed: PartitionKey to group by tenant, State + # and NextAttemptUtc for the due-retry test below, and RowKey plus WindowStart for the two + # window planners. Timestamp is not a key, so this is a cross-partition scan whatever the + # predicate - what is controllable is how much comes back over the wire. At a few hundred + # tenants this covers roughly 90 windows each across the 50-hour horizon, and it ran every + # cycle pulling every column of every one of them. + $AllRows = Get-CIPPAzDataTableEntity @Ledger -Filter "Timestamp ge datetime'$HorizonIso'" ` + -Property PartitionKey, RowKey, State, NextAttemptUtc, WindowStart $ByTenant = @{} foreach ($Row in $AllRows) { if (-not $ByTenant.ContainsKey($Row.PartitionKey)) { $ByTenant[$Row.PartitionKey] = [System.Collections.Generic.List[object]]::new() } diff --git a/backend/Modules/CIPPCore/Public/Get-CIPPGeoIPLocationBatch.ps1 b/backend/Modules/CIPPCore/Public/Get-CIPPGeoIPLocationBatch.ps1 index 6d60d71fe1..60285d005b 100644 --- a/backend/Modules/CIPPCore/Public/Get-CIPPGeoIPLocationBatch.ps1 +++ b/backend/Modules/CIPPCore/Public/Get-CIPPGeoIPLocationBatch.ps1 @@ -65,9 +65,30 @@ function Get-CIPPGeoIPLocationBatch { $LocationTable = Get-CIPPTable -TableName 'knownlocationdbv2' $ValidAfter = (Get-Date).AddDays(-90).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') - # 1) Seed from knownlocationdbv2 (fresh, non-Unknown entries); collect the misses + # In-process memo in front of the table. Despite the name, the seeding loop below issues one + # table read PER DISTINCT IP - measured at 2.2 ms each, so roughly 450 ms for a 200-IP window, + # and the audit rule engine calls this once per 500-record slice per tenant. Egress addresses + # repeat heavily both within a tenant and across them, so the same IPs were re-fetched over and + # over. Thirty minutes, far inside the table's own 90-day validity, so the memo can only + # shorten how long a cached answer is reused - never serve something the table would not. + if ($null -eq $script:GeoIpMemo) { $script:GeoIpMemo = @{} } + $MemoNow = [datetime]::UtcNow + $MemoExpiry = $MemoNow.AddMinutes(30) + # Bounded: sweep expired entries only once the memo is large, so the common path stays O(1). + if ($script:GeoIpMemo.Count -gt 20000) { + foreach ($MemoKey in @($script:GeoIpMemo.Keys)) { + if ($script:GeoIpMemo[$MemoKey].Expires -le $MemoNow) { $script:GeoIpMemo.Remove($MemoKey) } + } + } + + # 1) Seed from the memo, then knownlocationdbv2 (fresh, non-Unknown entries); collect the misses $ToResolve = [System.Collections.Generic.List[string]]::new() foreach ($ip in $Distinct) { + $Memoised = $script:GeoIpMemo[$ip] + if ($Memoised -and $Memoised.Expires -gt $MemoNow) { + $Result[$ip] = $Memoised.Location + continue + } $cached = Get-CIPPAzDataTableEntity @LocationTable -Filter "PartitionKey eq 'ip' and RowKey eq '$ip' and Timestamp ge datetime'$ValidAfter'" if ($cached -and $cached.CountryOrRegion -and $cached.CountryOrRegion -ne 'Unknown') { $Result[$ip] = [pscustomobject]@{ @@ -77,6 +98,7 @@ function Get-CIPPGeoIPLocationBatch { Hosting = $cached.Hosting ASName = $cached.ASName } + $script:GeoIpMemo[$ip] = [pscustomobject]@{ Expires = $MemoExpiry; Location = $Result[$ip] } } else { $ToResolve.Add($ip) } @@ -116,8 +138,11 @@ function Get-CIPPGeoIPLocationBatch { ASName = if ($r.asname) { $r.asname } else { 'Unknown' } } $Result[$ip] = $loc - # Only cache real results - never persist Unknown (no poisoning, matches single path) + # Only cache real results - never persist Unknown (no poisoning, matches single path). + # The memo follows the same rule, or an unresolvable address would be pinned as Unknown + # for the whole TTL instead of being retried. if ($loc.CountryOrRegion -ne 'Unknown') { + $script:GeoIpMemo[$ip] = [pscustomobject]@{ Expires = $MemoExpiry; Location = $loc } $KnownEntities.Add(@{ PartitionKey = 'ip' RowKey = $ip diff --git a/backend/Modules/CIPPCore/Public/Webhooks/Get-CippAuditLogLegacyCacheRow.ps1 b/backend/Modules/CIPPCore/Public/Webhooks/Get-CippAuditLogLegacyCacheRow.ps1 new file mode 100644 index 0000000000..e83bc4a222 --- /dev/null +++ b/backend/Modules/CIPPCore/Public/Webhooks/Get-CippAuditLogLegacyCacheRow.ps1 @@ -0,0 +1,64 @@ +function Get-CippAuditLogLegacyCacheRow { + <# + .SYNOPSIS + Read CacheWebhooks rows written before the per-search partitioning change. + .DESCRIPTION + Rows written by an older Push-AuditLogDownloadV2 live under PartitionKey = and are + addressable only by an OR-list of RowKeys - a partition scan, the exact pattern the new + layout removes. Quarantined here rather than left in the hot path. + + CacheWebhooks is transient, so this stops finding anything within a cycle or two. Delete it, + its caller branch, and the legacy pass in Push-AuditLogProcessingBatchV2 one release on. + .PARAMETER TenantFilter + Tenant whose legacy partition is being read. + .PARAMETER RowIds + Record ids to fetch. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)][string]$TenantFilter, + [Parameter(Mandatory = $true)][string[]]$RowIds + ) + + $CacheWebhooksTable = Get-CippTable -TableName 'CacheWebhooks' + $Results = [System.Collections.Generic.List[object]]::new() + + # Don't raise much above 100: each chunk builds one predicate per row, and an over-long filter + # is rejected (Azure ~520 predicates, Azurite ~250). + $ChunkSize = 100 + for ($Offset = 0; $Offset -lt $RowIds.Count; $Offset += $ChunkSize) { + $Slice = @($RowIds[$Offset..([Math]::Min($Offset + $ChunkSize - 1, $RowIds.Count - 1))]) + + # Raw cmdlet: the wrapper merges split parts and reports the logical RowKey. + $KeyFilter = "PartitionKey eq '$TenantFilter' and (" + + (($Slice | ForEach-Object { "RowKey eq '$_'" }) -join ' or ') + ')' + $Keys = @(Get-AzDataTableEntity @CacheWebhooksTable -Filter $KeyFilter ` + -Property 'PartitionKey', 'RowKey', 'OriginalEntityId') + if ($Keys.Count -eq 0) { continue } + + # Split records span X / X-part1 / X-part2 and only reassemble when every part arrives in + # one call, so select on OriginalEntityId rather than RowKey. + $Predicates = [System.Collections.Generic.List[string]]::new() + $SeenLogical = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($Key in $Keys) { + if ($Key.PSObject.Properties.Name -contains 'OriginalEntityId' -and $Key.OriginalEntityId) { + if ($SeenLogical.Add([string]$Key.OriginalEntityId)) { + $Predicates.Add("OriginalEntityId eq '$($Key.OriginalEntityId)'") + } + } else { + $Predicates.Add("RowKey eq '$($Key.RowKey)'") + } + } + if ($Predicates.Count -eq 0) { continue } + + # No -Property: a projection must list every JSON_Part* column or split rows come back empty. + $RowFilter = "PartitionKey eq '$TenantFilter' and (" + ($Predicates -join ' or ') + ')' + foreach ($Entity in @(Get-CIPPAzDataTableEntity @CacheWebhooksTable -Filter $RowFilter)) { + $Results.Add($Entity) + } + } + + return $Results.ToArray() +} diff --git a/backend/Modules/CIPPCore/Public/Webhooks/Invoke-CIPPWebhookProcessing.ps1 b/backend/Modules/CIPPCore/Public/Webhooks/Invoke-CIPPWebhookProcessing.ps1 index ddc0e54de1..aaefa7f488 100644 --- a/backend/Modules/CIPPCore/Public/Webhooks/Invoke-CIPPWebhookProcessing.ps1 +++ b/backend/Modules/CIPPCore/Public/Webhooks/Invoke-CIPPWebhookProcessing.ps1 @@ -8,19 +8,24 @@ function Invoke-CippWebhookProcessing { $CIPPURL, $AlertComment, $APIName = 'Process webhook', - $Headers + $Headers, + # Optional accumulator. When supplied, the completed audit-log row is added to it instead of + # being written here, so the caller can flush a batch of them in one transaction - they all + # share the tenant partition key. A list rather than a return value on purpose: this + # function's output stream already carries whatever Send-CIPPAlert returns, and adding to it + # would push that further up into Test-CIPPAuditLogRules' own output. + [System.Collections.Generic.List[object]]$PendingAuditLogWrites ) $AuditLogTable = Get-CIPPTable -TableName 'AuditLogs' - $AuditLog = Get-CIPPAzDataTableEntity @AuditLogTable -Filter "PartitionKey eq '$TenantFilter' and RowKey eq '$($Data.Id)'" - if ($AuditLog) { - Write-Host "Audit Log already exists for $($Data.Id). Skipping processing." - return - } - - # Immediately claim this event ID to prevent concurrent workers from processing the same event. - # Uses Insert (no -Force) so a 409 conflict means another worker already claimed it. + # Claim the event ID immediately, with no read first. The claim is an Insert without -Force, so + # a conflict already tells us another worker (or an earlier run) owns this event - the read that + # used to precede it answered the same question a round trip earlier and could not make the + # claim any safer, because a row could still appear between the two. It was one extra table read + # per MATCHED record, measured at 28% of the processing stage once rules actually fire. + # A duplicate now costs one failed insert instead of one read; a new event costs one insert + # instead of a read plus an insert. # -ErrorAction Stop ensures non-terminating errors enter the catch block. try { Add-CIPPAzDataTableEntity @AuditLogTable -Entity @{ @@ -30,16 +35,47 @@ function Invoke-CippWebhookProcessing { Tenant = $TenantFilter } -ErrorAction Stop } catch { - Write-Host "Audit log $($Data.Id) already claimed by another worker. Skipping." + Write-Host "Audit log $($Data.Id) already claimed or already processed. Skipping." return } - $Tenant = Get-Tenants -IncludeErrors | Where-Object { $_.defaultDomainName -eq $TenantFilter } + # Memoised per tenant. Get-Tenants does no in-process caching of its own: every call reads the + # tenants table twice, filters through the pipeline and sorts the whole list, measured at 26 ms + # against a 16-tenant list and growing with the tenant count. This function runs once per + # MATCHED audit record, so at a few hundred tenants each matching a handful of records per + # cycle, the pipeline spent minutes per cycle re-deriving an answer that is identical every + # time. Five minutes, because the tenant list is itself a cached table that turns over on the + # order of hours; a tenant onboarded mid-window resolves on the next cycle. + # A miss caches the null result too - an unknown tenant must not re-query per record either. + if ($null -eq $script:WebhookTenantCache) { + $script:WebhookTenantCache = @{} + } + $TenantCacheNow = [datetime]::UtcNow + $TenantEntry = $script:WebhookTenantCache[$TenantFilter] + if ($TenantEntry -and $TenantEntry.Expires -gt $TenantCacheNow) { + $Tenant = $TenantEntry.Tenant + } else { + foreach ($CachedTenant in @($script:WebhookTenantCache.Keys)) { + if ($script:WebhookTenantCache[$CachedTenant].Expires -le $TenantCacheNow) { + $script:WebhookTenantCache.Remove($CachedTenant) + } + } + $Tenant = Get-Tenants -IncludeErrors | Where-Object { $_.defaultDomainName -eq $TenantFilter } + $script:WebhookTenantCache[$TenantFilter] = [PSCustomObject]@{ + Expires = $TenantCacheNow.AddMinutes(5) + Tenant = $Tenant + } + } Write-Host "Received data. Our Action List is $($Data.CIPPAction)" $ActionList = ($Data.CIPPAction | ConvertFrom-Json -ErrorAction SilentlyContinue).value $ActionResults = foreach ($action in $ActionList) { - Write-Host "this is our action: $($action | ConvertTo-Json -Depth 15 -Compress)" + # Serialising every action at depth 15 just to print it, once per action per MATCHED + # record, is not worth paying for at alerting volume. Left in place rather than deleted + # because it is genuinely useful when working on a specific tenant's actions - uncomment + # it then. Write-Host targets the host stream, not the output stream, so this does not + # affect what $ActionResults collects. + #Write-Host "this is our action: $($action | ConvertTo-Json -Depth 15 -Compress)" switch ($action) { 'disableUser' { try { @@ -111,18 +147,21 @@ function Invoke-CippWebhookProcessing { AlertComment = $AlertComment } | ConvertTo-Json -Depth 15 -Compress - # Update the sentinel row claimed earlier with full audit log data - Add-CIPPAzDataTableEntity @AuditLogTable -Entity @{ + # Built here, written at the very bottom - after the alerts have gone out. See the note there. + $AuditLogRow = @{ PartitionKey = $TenantFilter RowKey = $Data.Id Title = $GenerateJSON.Title Data = [string]$JsonContent Tenant = $TenantFilter - } -Force + } $LogId = $Data.Id $AuditLogLink = '{0}/tenant/administration/audit-logs/log?logId={1}&tenantFilter={2}' -f $CIPPURL, $LogId, $Tenant.defaultDomainName - $GenerateEmail = New-CIPPAlertTemplate -format 'html' -data $Data -ActionResults $ActionResults -CIPPURL $CIPPURL -Tenant $Tenant.defaultDomainName -AuditLogLink $AuditLogLink -AlertComment $AlertComment -CustomSubject $Data.CIPPCustomSubject + # The html render is deferred to the generatemail branch below, which is its only consumer. + # Rendering it here meant every matched record paid for an email body whether or not any rule + # asked for one - two template renders per alert where one was needed, ~15% of the processing + # stage between them. # Derive the affected end-user from the audit record so PSA tickets can be linked to the # right HaloPSA contact when HaloPSA.LinkTicketsToUsers is enabled. The upstream GUID mapper @@ -160,6 +199,7 @@ function Invoke-CippWebhookProcessing { foreach ($action in $ActionList ) { switch ($action) { 'generatemail' { + $GenerateEmail = New-CIPPAlertTemplate -format 'html' -data $Data -ActionResults $ActionResults -CIPPURL $CIPPURL -Tenant $Tenant.defaultDomainName -AuditLogLink $AuditLogLink -AlertComment $AlertComment -CustomSubject $Data.CIPPCustomSubject $CIPPAlert = @{ Type = 'email' Title = $GenerateEmail.title @@ -198,5 +238,26 @@ function Invoke-CippWebhookProcessing { } } } + + # Written last, and optionally handed to the caller to batch. + # + # It used to be written before the alerts went out, which put the silent failure in the worst + # place: a crash between the write and the send left a row that looks complete for an alert + # nobody ever received, and the claim row makes a retry skip the record, so it is lost without + # trace. Writing after the send inverts that - a crash there means the alert HAS gone out and + # only the stored copy is missing, which is visible in the UI as a row still marked Processing. + # Nothing downstream de-duplicates: Send-CIPPAlert posts to email, webhook and PSA + # unconditionally, and its 'table' path even keys on a fresh guid per call. This claim row is + # the only thing standing between a retry and a second alert, which is why the claim stays + # where it is, before any work. + # + # When the caller supplies a list, the row is queued rather than written, so a batch of them + # goes out in one transaction - every row shares the tenant partition key. The caller is + # responsible for flushing, including on failure. + if ($null -ne $PendingAuditLogWrites) { + $null = $PendingAuditLogWrites.Add($AuditLogRow) + } else { + Add-CIPPAzDataTableEntity @AuditLogTable -Entity $AuditLogRow -Force + } } diff --git a/backend/Modules/CIPPCore/Public/Webhooks/Push-AuditLogDownloadV2.ps1 b/backend/Modules/CIPPCore/Public/Webhooks/Push-AuditLogDownloadV2.ps1 index ded32cbe20..cf8d95bfc5 100644 --- a/backend/Modules/CIPPCore/Public/Webhooks/Push-AuditLogDownloadV2.ps1 +++ b/backend/Modules/CIPPCore/Public/Webhooks/Push-AuditLogDownloadV2.ps1 @@ -21,6 +21,9 @@ function Push-AuditLogDownloadV2 { $MaxAttempts = 6 $StuckHours = 4 $Downloaded = 0 + # The Azure Table transaction limit. Records are written in batches of this size rather than + # one at a time; see the download branch below. + $CacheFlushSize = 100 try { $Ledger = Get-CippTable -TableName 'AuditLogCoverage' @@ -56,17 +59,49 @@ function Push-AuditLogDownloadV2 { try { # Streamed, not collected: each record is written to CacheWebhooks and # dropped, so the window never needs to be resident. + # tenant|search, not tenant: Azure Table only point-looks-up a single + # PartitionKey+RowKey pair, so an OR-list of RowKeys inside one big per-tenant + # partition scans it. Per-search partitions keep every processing read a point + # query. Records in the 5-min window overlap land in both partitions and are + # processed twice; alerting still fires once (Invoke-CippWebhookProcessing + # claim-inserts AuditLogs[tenant, id] without -Force). + $CachePartition = '{0}|{1}' -f $TenantFilter, $SearchId + + # Buffered rather than one write per record. The table service takes + # transactions of up to 100 entities that share a PartitionKey, and every record + # in a window shares tenant|searchId by construction, so the single-entity write + # was paying one round trip per record for nothing: 2,608 ms/1k against + # 181 ms/1k batched on identical payloads, ~70% of this stage's cost. + # The buffer lives inside the per-window branch, so a batch can never straddle + # two partitions - the service rejects a transaction that does. $WindowCount = 0 + $Buffer = [System.Collections.Generic.List[object]]::new() Get-CippAuditLogSearchResults -TenantFilter $TenantFilter -QueryId $SearchId | ForEach-Object { - Add-CIPPAzDataTableEntity @CacheTable -Entity @{ - RowKey = [string]$_.id - PartitionKey = [string]$TenantFilter - SearchId = $SearchId - JSON = [string]($_ | ConvertTo-Json -Depth 10 -Compress) - CippProcessing = $false - CippProcessingStarted = '' - } -Force + $Buffer.Add(@{ + RowKey = [string]$_.id + PartitionKey = [string]$CachePartition + TenantFilter = [string]$TenantFilter + SearchId = $SearchId + # -InputObject rather than a pipeline: same output, but it skips + # building a pipeline per record. Measured at 75 us against 43 us + # for a record of this shape, and this runs once per record. + JSON = [string](ConvertTo-Json -InputObject $_ -Depth 10 -Compress) + CippProcessing = $false + CippProcessingStarted = '' + }) $WindowCount++ + # Flushing mid-stream keeps peak memory at one batch rather than the whole + # window, and leaves a failure having made forward progress. Re-downloading + # after a retry rewrites flushed records, which is a no-op: the write is an + # upsert keyed by record id. + if ($Buffer.Count -ge $CacheFlushSize) { + Add-CIPPAzDataTableEntity @CacheTable -Entity $Buffer.ToArray() -Force + $Buffer.Clear() + } + } + if ($Buffer.Count -gt 0) { + Add-CIPPAzDataTableEntity @CacheTable -Entity $Buffer.ToArray() -Force + $Buffer.Clear() } $Downloaded += $WindowCount # Empty windows have nothing to process - mark them Processed directly so they diff --git a/backend/Modules/CIPPCore/Public/Webhooks/Push-AuditLogProcessV2.ps1 b/backend/Modules/CIPPCore/Public/Webhooks/Push-AuditLogProcessV2.ps1 index 7c52ccf09c..c6d4a36dba 100644 --- a/backend/Modules/CIPPCore/Public/Webhooks/Push-AuditLogProcessV2.ps1 +++ b/backend/Modules/CIPPCore/Public/Webhooks/Push-AuditLogProcessV2.ps1 @@ -28,14 +28,18 @@ function Push-AuditLogProcessV2 { # this cycle OR rows left behind by an earlier crash. Not gated on the download count, so a # crashed/partial processing run is retried on the next cycle. The batch builder is the # authoritative gate (claims claimable rows; returns nothing if there's truly no work). - $CacheTable = Get-CippTable -TableName 'CacheWebhooks' - $Pending = @(Get-CIPPAzDataTableEntity @CacheTable -Filter "PartitionKey eq '$TenantFilter'" -Property @('PartitionKey', 'RowKey')) + # Gate on the LEDGER, not the cache. CacheWebhooks is partitioned per search + # (tenant|searchId), so there is no single partition to count for a tenant, and a + # cross-partition scan just to answer "is there anything to do" would reintroduce exactly + # the cost the layout removes. The ledger tracks the same state and is keyed by tenant. + $Ledger = Get-CippTable -TableName 'AuditLogCoverage' + $Pending = @(Get-CIPPAzDataTableEntity @Ledger -Filter "PartitionKey eq '$TenantFilter' and (State eq 'Downloaded' or State eq 'Processing')" -Property @('PartitionKey', 'RowKey')) if ($Pending.Count -eq 0) { - Write-Information "AuditLogProcessV2: no pending cache rows for $TenantFilter; nothing to process" + Write-Information "AuditLogProcessV2: no searches awaiting processing for $TenantFilter" return @{ Success = $true; Processed = $false } } - Write-Information "AuditLogProcessV2: enqueueing processing for $TenantFilter ($($Pending.Count) pending cache row(s))" + Write-Information "AuditLogProcessV2: enqueueing processing for $TenantFilter ($($Pending.Count) search(es) pending)" $InputObject = [PSCustomObject]@{ OrchestratorName = "AuditLogProcessV2-$TenantFilter" QueueFunction = [PSCustomObject]@{ diff --git a/backend/Modules/CIPPCore/Public/Webhooks/Push-AuditLogProcessingBatchV2.ps1 b/backend/Modules/CIPPCore/Public/Webhooks/Push-AuditLogProcessingBatchV2.ps1 index fad2047ad5..2a1008f44c 100644 --- a/backend/Modules/CIPPCore/Public/Webhooks/Push-AuditLogProcessingBatchV2.ps1 +++ b/backend/Modules/CIPPCore/Public/Webhooks/Push-AuditLogProcessingBatchV2.ps1 @@ -1,15 +1,22 @@ function Push-AuditLogProcessingBatchV2 { <# .SYNOPSIS - QueueFunction for the per-tenant V2 processing orchestrator. Builds processing batches from a - single tenant's CacheWebhooks rows. + QueueFunction for the per-tenant V2 processing orchestrator. Emits one batch item per + downloaded search. .DESCRIPTION - Tenant-scoped variant of Push-AuditLogProcessingBatch. Pages the CacheWebhooks rows for the - tenant supplied via the QueueFunction Parameters, claims unclaimed (or stale > 2h) rows by - stamping CippProcessing = true, and returns 500-row batch items routed to the - AuditLogTenantProcessV2 activity (which runs Test-CIPPAuditLogRules and advances the ledger). - Scoping to one tenant avoids cross-tenant scans and claim races when many tenants process - concurrently. The 2h stale window lets a crashed processing run be re-claimed and retried. + Claims work at SEARCH granularity. Each batch item is one SearchId, which is also one + CacheWebhooks partition, so the consuming activity reads it with a partition query. + + This replaces a per-record claim that read every CacheWebhooks row for the tenant and + stamped each one - measured at 17.6s of bookkeeping per 5k records before a single record + was examined. Claiming the AuditLogCoverage row is one write per search instead, and the + ledger already had a state machine for it. + + A search left in Processing beyond the stale window is reclaimed so a crashed run retries. + + LEGACY: rows written before per-search partitioning live under PartitionKey = and + are picked up by a compatibility pass. CacheWebhooks is transient, so this stops finding + anything within a cycle or two and can be removed a release later. .FUNCTIONALITY Entrypoint #> @@ -21,46 +28,75 @@ function Push-AuditLogProcessingBatchV2 { Write-Information 'AuditLogProcessingBatchV2: no tenant filter; nothing to process' return @() } + $StaleHours = 2 - $WebhookCacheTable = Get-CippTable -TableName 'CacheWebhooks' - $AllBatchItems = [System.Collections.Generic.List[object]]::new() - $NowUtc = (Get-Date).ToUniversalTime() - $StaleThreshold = $NowUtc.AddHours(-2) + $Ledger = Get-CippTable -TableName 'AuditLogCoverage' + $Now = (Get-Date).ToUniversalTime() + $Stale = $Now.AddHours(-$StaleHours) - $Rows = @(Get-CIPPAzDataTableEntity @WebhookCacheTable -Filter "PartitionKey eq '$TenantFilter'" -Property @('PartitionKey', 'RowKey', 'ETag', 'Timestamp', 'CippProcessing')) + $Rows = @(Get-CIPPAzDataTableEntity @Ledger -Filter "PartitionKey eq '$TenantFilter'") $Claimable = @($Rows | Where-Object { - -not $_.CippProcessing -or ($_.Timestamp -and $_.Timestamp.UtcDateTime -lt $StaleThreshold) + $_.SearchId -and ( + $_.State -eq 'Downloaded' -or + ($_.State -eq 'Processing' -and $_.Timestamp -and $_.Timestamp.UtcDateTime -lt $Stale) + ) }) - if ($Claimable.Count -eq 0) { - Write-Information "AuditLogProcessingBatchV2: no claimable rows for $TenantFilter" - return @() - } - $RowIds = @($Claimable.RowKey) + $BatchItems = [System.Collections.Generic.List[object]]::new() + + # Claim by updating, never upserting: a window row removed by ledger retention while this loop + # runs must not be resurrected as a stateless shell that re-enters every cycle. foreach ($Row in $Claimable) { - Add-CIPPAzDataTableEntity @WebhookCacheTable -Entity ([PSCustomObject]@{ - PartitionKey = $TenantFilter - RowKey = $Row.RowKey - CippProcessing = $true - }) -OperationType UpsertMerge + try { + Update-CIPPAzDataTableEntity @Ledger -Entity ([PSCustomObject]@{ + PartitionKey = $TenantFilter + RowKey = $Row.RowKey + State = 'Processing' + }) + $BatchItems.Add([PSCustomObject]@{ + TenantFilter = $TenantFilter + SearchId = [string]$Row.SearchId + WindowRowKey = [string]$Row.RowKey + RecordCount = [int]$Row.RecordCount + FunctionName = 'AuditLogTenantProcessV2' + }) + } catch { + Write-Information "AuditLogProcessingBatchV2: window $($Row.RowKey) for $TenantFilter vanished before it could be claimed; skipping" + } } - for ($i = 0; $i -lt $RowIds.Count; $i += 500) { - $BatchRowIds = $RowIds[$i..([Math]::Min($i + 499, $RowIds.Count - 1))] - $AllBatchItems.Add([PSCustomObject]@{ - TenantFilter = $TenantFilter - RowIds = $BatchRowIds - FunctionName = 'AuditLogTenantProcessV2' - }) + # --- Legacy: rows still sitting in the old per-tenant partition --- + # Only reached while an upgrade drains; costs one keys-only read of a partition that is empty + # on any instance that has already cycled. + try { + $CacheTable = Get-CippTable -TableName 'CacheWebhooks' + $LegacyRows = @(Get-CIPPAzDataTableEntity @CacheTable -Filter "PartitionKey eq '$TenantFilter'" -Property PartitionKey, RowKey) + if ($LegacyRows.Count -gt 0) { + Write-Information "AuditLogProcessingBatchV2: $($LegacyRows.Count) legacy pre-partition row(s) for $TenantFilter" + $LegacyIds = @($LegacyRows.RowKey) + for ($i = 0; $i -lt $LegacyIds.Count; $i += 500) { + $BatchItems.Add([PSCustomObject]@{ + TenantFilter = $TenantFilter + LegacyRowIds = @($LegacyIds[$i..([Math]::Min($i + 499, $LegacyIds.Count - 1))]) + FunctionName = 'AuditLogTenantProcessV2' + }) + } + } + } catch { + Write-Information "AuditLogProcessingBatchV2: legacy sweep skipped for ${TenantFilter}: $($_.Exception.Message)" } - if ($AllBatchItems.Count -gt 0) { - $ProcessQueue = New-CippQueueEntry -Name "Audit Logs Process V2 - $TenantFilter" -Reference 'AuditLogsProcessV2' -TotalTasks $RowIds.Count - foreach ($BatchItem in $AllBatchItems) { - $BatchItem | Add-Member -MemberType NoteProperty -Name QueueId -Value $ProcessQueue.RowKey -Force - } - Write-Information "AuditLogProcessingBatchV2: $($AllBatchItems.Count) batch item(s) across $($RowIds.Count) row(s) for $TenantFilter" + if ($BatchItems.Count -eq 0) { + Write-Information "AuditLogProcessingBatchV2: nothing to process for $TenantFilter" + return @() + } + + $TotalRecords = ($Claimable | Measure-Object RecordCount -Sum).Sum + $ProcessQueue = New-CippQueueEntry -Name "Audit Logs Process V2 - $TenantFilter" -Reference 'AuditLogsProcessV2' -TotalTasks $BatchItems.Count + foreach ($BatchItem in $BatchItems) { + $BatchItem | Add-Member -MemberType NoteProperty -Name QueueId -Value $ProcessQueue.RowKey -Force } - return $AllBatchItems.ToArray() + Write-Information "AuditLogProcessingBatchV2: $($BatchItems.Count) batch item(s), ~$TotalRecords record(s) for $TenantFilter" + return $BatchItems.ToArray() } diff --git a/backend/Modules/CIPPCore/Public/Webhooks/Push-AuditLogTenantProcessV2.ps1 b/backend/Modules/CIPPCore/Public/Webhooks/Push-AuditLogTenantProcessV2.ps1 index 4dae1f69ce..30419a81b5 100644 --- a/backend/Modules/CIPPCore/Public/Webhooks/Push-AuditLogTenantProcessV2.ps1 +++ b/backend/Modules/CIPPCore/Public/Webhooks/Push-AuditLogTenantProcessV2.ps1 @@ -1,17 +1,23 @@ function Push-AuditLogTenantProcessV2 { <# .SYNOPSIS - Per-batch audit-log processing activity (V2). Processes a batch of cached rows via the - existing Test-CIPPAuditLogRules engine, then advances the AuditLogCoverage ledger to - 'Processed' for any SearchId whose rows are now fully drained from the cache. + Per-search audit-log processing activity (V2). Runs Test-CIPPAuditLogRules over one + search's cached records and settles its AuditLogCoverage window. .DESCRIPTION - Same processing as the V1 Push-AuditLogTenantProcess (reads the RowIds from CacheWebhooks - and runs Test-CIPPAuditLogRules, which removes processed rows). Additionally: - * captures the distinct SearchIds represented by this batch's rows - * after processing, for each of those SearchIds with zero remaining CacheWebhooks rows, - marks the matching ledger window State = 'Processed' (ProcessedUtc + MatchedCount) - Because the mark is gated on "no rows left for this SearchId", a search split across - multiple 500-row batches is only marked Processed when its final batch completes. + One batch item is one SearchId, which is one CacheWebhooks partition, so a search is read + with point-partition queries instead of "PartitionKey eq and (RowKey eq a or ...)" + - an OR-list cannot use the index and scans. Reading by partition also removes the second + pass that resolved OriginalEntityId, since every part of a split record shares the + partition and the read wrapper reassembles parts arriving in one call. + + The window is settled by point write rather than a "SearchId eq" lookup (also a scan), and + the old orphaned-window sweep is gone: it only existed because a record returned by two + overlapping windows had its SearchId overwritten inside a shared tenant partition. + + Overlap records now appear in two partitions and are processed twice; alerting still fires + once via Invoke-CippWebhookProcessing's claim-insert. + + Accepts LegacyRowIds for rows written before the partitioning change. .FUNCTIONALITY Entrypoint #> @@ -19,132 +25,182 @@ function Push-AuditLogTenantProcessV2 { param($Item) $TenantFilter = $Item.TenantFilter - $RowIds = $Item.RowIds + $SearchId = $Item.SearchId + $WindowRowKey = $Item.WindowRowKey + $LegacyRowIds = $Item.LegacyRowIds + + # Rules are re-read per call by Test-CIPPAuditLogRules, so slice large searches rather than + # calling it per record - and keep the slice at the old batch size so peak parsed memory is + # unchanged even though the read is now one partition. + $SliceSize = 500 try { $CacheWebhooksTable = Get-CippTable -TableName 'CacheWebhooks' - $SearchIds = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) - - # Chunked so peak memory tracks $ChunkSize, not batch size. Don't raise much above - # 100: each chunk builds one `RowKey eq ''` predicate per row, and an over-long - # filter is rejected (Azure ~520 predicates, Azurite ~250) and swallowed by the catch. - $ChunkSize = 100 $ProcessedCount = 0 $MatchedLogs = 0 - for ($Offset = 0; $Offset -lt $RowIds.Count; $Offset += $ChunkSize) { - $Slice = @($RowIds[$Offset..([Math]::Min($Offset + $ChunkSize - 1, $RowIds.Count - 1))]) - - # Raw cmdlet: the wrapper merges split parts and reports the logical RowKey. - $KeyFilter = "PartitionKey eq '$TenantFilter' and (" + - (($Slice | ForEach-Object { "RowKey eq '$_'" }) -join ' or ') + ')' - $Keys = @(Get-AzDataTableEntity @CacheWebhooksTable -Filter $KeyFilter ` - -Property 'PartitionKey', 'RowKey', 'OriginalEntityId') - if ($Keys.Count -eq 0) { continue } - - # Split records span X / X-part1 / X-part2 and only reassemble when every part - # arrives in one call, so select on OriginalEntityId rather than RowKey. - $Predicates = [System.Collections.Generic.List[string]]::new() - $SeenLogical = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) - foreach ($Key in $Keys) { - if ($Key.PSObject.Properties.Name -contains 'OriginalEntityId' -and $Key.OriginalEntityId) { - if ($SeenLogical.Add([string]$Key.OriginalEntityId)) { - $Predicates.Add("OriginalEntityId eq '$($Key.OriginalEntityId)'") - } - } else { - $Predicates.Add("RowKey eq '$($Key.RowKey)'") - } + if (-not $LegacyRowIds -and -not $SearchId) { + Write-Information "AuditLogV2: batch item for $TenantFilter has neither SearchId nor LegacyRowIds; nothing to do" + return $false + } + + $Partition = if ($LegacyRowIds) { $TenantFilter } else { '{0}|{1}' -f $TenantFilter, $SearchId } + $Poison = [System.Collections.Generic.List[object]]::new() + $Cursor = $null + $AnyRows = $false + # Set when paging stops because the cursor failed to advance. The partition sweep below is + # skipped in that case: rows may still be unprocessed, and sweeping would delete records + # that were never run through the rule engine. + $CursorStalled = $false + + # Paged so peak memory tracks the slice, not the whole search. `RowKey gt ` is + # still a point-partition range query. The cursor matters because the rule engine deletes + # rows as it goes: re-issuing the same query would reshuffle what "next page" means, + # whereas advancing past the highest RowKey seen is monotonic either way. + while ($true) { + if ($LegacyRowIds) { + # Legacy rows are addressed by id, not range - fetch once and exit after one pass. + $Entities = @(Get-CippAuditLogLegacyCacheRow -TenantFilter $TenantFilter -RowIds @($LegacyRowIds)) + } else { + $Filter = if ($Cursor) { "PartitionKey eq '$Partition' and RowKey gt '$Cursor'" } else { "PartitionKey eq '$Partition'" } + $Entities = @(Get-CIPPAzDataTableEntity @CacheWebhooksTable -Filter $Filter -First $SliceSize) } - if ($Predicates.Count -eq 0) { continue } + if ($Entities.Count -eq 0) { break } + $AnyRows = $true + $PageCount = $Entities.Count - # No -Property: a projection must list every JSON_Part* column or split rows - # come back empty. - $RowFilter = "PartitionKey eq '$TenantFilter' and (" + ($Predicates -join ' or ') + ')' - $Entities = @(Get-CIPPAzDataTableEntity @CacheWebhooksTable -Filter $RowFilter) + # Rows come back in RowKey order within a partition, but sort rather than assume it - + # a cursor that goes backwards would re-read forever. + $NextCursor = @($Entities.RowKey | Sort-Object)[-1] + + # Hard guard, checked BEFORE processing: if the cursor did not move, the range + # predicate was not honoured and this is the previous page served again. Processing it + # would double-count every record in it. Never rely on the backend alone to terminate a + # paging loop, and never assume a repeated page is new work. + if ($null -ne $Cursor -and [string]$NextCursor -le [string]$Cursor) { + Write-Information "AuditLogV2: cursor did not advance for $TenantFilter search $SearchId; stopping paging" + $CursorStalled = $true + break + } + $Cursor = $NextCursor $Chunk = [System.Collections.Generic.List[object]]::new() - foreach ($Entity in $Entities) { - if ($Entity.SearchId) { [void]$SearchIds.Add([string]$Entity.SearchId) } - $Parsed = $Entity.JSON | ConvertFrom-Json -ErrorAction SilentlyContinue + + for ($i = 0; $i -lt $Entities.Count; $i++) { + $Entity = $Entities[$i] + if ($null -eq $Entity) { continue } + # try/catch, not -ErrorAction: ConvertFrom-Json parse failures are terminating, so + # without the catch one garbled row aborts the whole batch via the outer catch. + try { + # -InputObject rather than a pipeline, once per record. A null JSON column binds + # as a terminating error here where the pipeline form simply emitted nothing, + # but both land on $Parsed = $null and the poison path below, so the row is + # treated identically. + $Parsed = ConvertFrom-Json -InputObject $Entity.JSON -ErrorAction Stop + } catch { + $Parsed = $null + } if ($null -eq $Parsed) { - Write-Information "AuditLogV2: unparseable cached JSON for RowKey $($Entity.RowKey) ($TenantFilter)" - continue + # A row whose JSON can never parse can never be drained; left in place it + # re-enters every claim cycle forever. Delete it. + Write-Information "AuditLogV2: removing unparseable cache row $($Entity.RowKey) ($TenantFilter)" + $Poison.Add([PSCustomObject]@{ PartitionKey = [string]$Entity.PartitionKey; RowKey = [string]$Entity.RowKey }) + } else { + $Chunk.Add($Parsed) } - $Chunk.Add($Parsed) + # Release the raw row as soon as it is parsed. The entity holds the JSON string and + # the parsed object holds an expanded copy of the same data; without this both are + # live at once for the whole page. + $Entities[$i] = $null } if ($Chunk.Count -gt 0) { - $Result = Test-CIPPAuditLogRules -TenantFilter $TenantFilter -Rows $Chunk + # The rule engine deletes the rows it processes, flushing mid-loop so a crash still + # makes forward progress. It therefore needs the partition those rows actually live + # in - the cache is keyed tenant|searchId, not tenant. Legacy batches keep the old + # tenant partition. + # -CallerSweepsCachePartition lets the engine drop processed rows with the plain + # delete instead of the part-aware one, which costs ~2.7x per row because it also + # removes the -partN rows of split entities. The sweep after this loop honours that + # guarantee. Legacy batches share the tenant partition with other searches and are + # never swept, so they keep the part-aware delete. + $SweepArgs = @{} + if (-not $LegacyRowIds) { $SweepArgs.CallerSweepsCachePartition = $true } + $Result = Test-CIPPAuditLogRules -TenantFilter $TenantFilter -Rows $Chunk -CachePartitionKey $Partition @SweepArgs $MatchedLogs += [int]($Result.MatchedLogs ?? 0) $ProcessedCount += $Chunk.Count } $Chunk.Clear() + $Chunk = $null $Entities = $null + + if ($LegacyRowIds) { break } + # A short page is the last page - asking again only costs a round trip. + if ($PageCount -lt $SliceSize) { break } } - if ($ProcessedCount -eq 0) { - Write-Information "AuditLogV2: no rows found in cache for the provided row IDs ($TenantFilter)" - return $false + if (-not $AnyRows) { + # Already drained - a retry after a crash between draining and settling. + if ($WindowRowKey) { Set-CippAuditLogWindowProcessed -TenantFilter $TenantFilter -WindowRowKey $WindowRowKey -MatchedCount 0 } + Write-Information "AuditLogV2: no cached rows for $TenantFilter search $SearchId" + return $true } - Write-Information "AuditLogV2: processed $ProcessedCount row(s) for $TenantFilter" - - # Advance the ledger to Processed for any SearchId now fully drained from the cache. - if ($SearchIds.Count -gt 0) { - $Ledger = Get-CippTable -TableName 'AuditLogCoverage' - $SingleSearch = ($SearchIds.Count -eq 1) - $Now = (Get-Date).ToUniversalTime() - foreach ($SearchId in $SearchIds) { - $Remaining = @(Get-CIPPAzDataTableEntity @CacheWebhooksTable -Filter "PartitionKey eq '$TenantFilter' and SearchId eq '$SearchId'" -Property PartitionKey, RowKey) - if ($Remaining.Count -gt 0) { continue } - - $LedgerRows = @(Get-CIPPAzDataTableEntity @Ledger -Filter "PartitionKey eq '$TenantFilter' and SearchId eq '$SearchId'") - foreach ($LedgerRow in $LedgerRows) { - $Update = @{ - PartitionKey = $TenantFilter - RowKey = $LedgerRow.RowKey - State = 'Processed' - ProcessedUtc = $Now - } - # Only attribute matched count when this batch was a single search (unambiguous). - if ($SingleSearch) { $Update.MatchedCount = $MatchedLogs } - Add-CIPPAzDataTableEntity @Ledger -Entity $Update -OperationType UpsertMerge - Write-Information "AuditLogV2: marked window $($LedgerRow.RowKey) Processed for $TenantFilter (search $SearchId)" + # Honour the -CallerSweepsCachePartition guarantee. The partition holds exactly this search, + # so a keys-only pass is a point-partition query and anything it finds belongs to this + # search alone: -partN rows orphaned by the plain delete, or rows the engine could not + # drain. Normally it finds nothing and costs one round trip per search. + # Raw Get-AzDataTableEntity, not the wrapper: the wrapper reassembles split entities and + # reports the orphaned parts as corrupt rather than returning them, which is the opposite + # of what a sweep needs - it wants the literal rows, parts included. + # Skipped when the cursor stalled, because rows may then be unprocessed. + if ($SearchId -and -not $LegacyRowIds -and -not $CursorStalled) { + try { + $Residue = @(Get-AzDataTableEntity @CacheWebhooksTable -Filter "PartitionKey eq '$Partition'" -Property PartitionKey, RowKey) + if ($Residue.Count -gt 0) { + $null = Remove-AzDataTableEntity -Force @CacheWebhooksTable -Entity @($Residue | ForEach-Object { + [PSCustomObject]@{ PartitionKey = [string]$_.PartitionKey; RowKey = [string]$_.RowKey } }) + Write-Information "AuditLogV2: swept $($Residue.Count) leftover row(s) from $Partition" } + } catch { + # Not fatal: leftovers are re-swept next cycle, and the window is already processed. + Write-Information "AuditLogV2: partition sweep failed for ${Partition}: $($_.Exception.Message)" } } - # Sweep orphaned Downloaded windows. Once this batch's rows are processed, re-scan every - # window left at 'Downloaded' for the tenant and cross-check it against the cache by SearchId. - # If no CacheWebhooks rows remain for that search, the records were already processed - often - # under an OVERLAPPING window's search, because CacheWebhooks is keyed by record id, so a 5-min - # window overlap (or a legacy 60-min window sharing record ids) overwrites the SearchId and the - # per-batch marking above never sees this window's id. Mark it Processed. Windows whose search - # still has cache rows are left as-is; they get picked up on the next process round. - try { - $SweepLedger = Get-CippTable -TableName 'AuditLogCoverage' - $SweepNow = (Get-Date).ToUniversalTime() - $DownloadedRows = @(Get-CIPPAzDataTableEntity @SweepLedger -Filter "PartitionKey eq '$TenantFilter' and State eq 'Downloaded'") - foreach ($DownRow in $DownloadedRows) { - $Sid = [string]$DownRow.SearchId - if (-not $Sid) { continue } - $Remaining = @(Get-CIPPAzDataTableEntity @CacheWebhooksTable -Filter "PartitionKey eq '$TenantFilter' and SearchId eq '$Sid'" -Property PartitionKey, RowKey) - if ($Remaining.Count -gt 0) { continue } - Add-CIPPAzDataTableEntity @SweepLedger -Entity @{ - PartitionKey = $TenantFilter - RowKey = $DownRow.RowKey - State = 'Processed' - ProcessedUtc = $SweepNow - MatchedCount = 0 - } -OperationType UpsertMerge - Write-Information "AuditLogV2: swept window $($DownRow.RowKey) to Processed for $TenantFilter (search $Sid drained, no cache rows)" + if ($Poison.Count -gt 0) { + try { + $null = Remove-CIPPAzDataTableEntity -Force @CacheWebhooksTable -Entity $Poison.ToArray() + } catch { + Write-Information "AuditLogV2: failed to remove $($Poison.Count) unparseable row(s) for ${TenantFilter}: $($_.Exception.Message)" } - } catch { - Write-Information ('Push-AuditLogTenantProcessV2 sweep error for {0}: {1}' -f $TenantFilter, $_.Exception.Message) + } + + Write-Information "AuditLogV2: processed $ProcessedCount row(s) for $TenantFilter$(if ($SearchId) { " search $SearchId" })" + + # Point write - the batch owns exactly one window, so there is nothing to search for. + if ($WindowRowKey) { + Set-CippAuditLogWindowProcessed -TenantFilter $TenantFilter -WindowRowKey $WindowRowKey -MatchedCount $MatchedLogs } return $true } catch { + # Back to Downloaded so the next cycle re-claims it; the stale-Processing reclaim in + # Push-AuditLogProcessingBatchV2 is the backstop if this write is what failed. + if ($WindowRowKey) { + try { + $Ledger = Get-CippTable -TableName 'AuditLogCoverage' + Add-CIPPAzDataTableEntity @Ledger -Entity @{ + PartitionKey = $TenantFilter + RowKey = $WindowRowKey + State = 'Downloaded' + LastError = [string]$_.Exception.Message + LastErrorUtc = (Get-Date).ToUniversalTime() + } -OperationType UpsertMerge + } catch { + Write-Information "AuditLogV2: could not reset window $WindowRowKey to Downloaded for ${TenantFilter}: $($_.Exception.Message)" + } + } Write-Information ('Push-AuditLogTenantProcessV2: Error {0} line {1} - {2}' -f $_.InvocationInfo.ScriptName, $_.InvocationInfo.ScriptLineNumber, $_.Exception.Message) return $false } diff --git a/backend/Modules/CIPPCore/Public/Webhooks/Set-CippAuditLogWindowProcessed.ps1 b/backend/Modules/CIPPCore/Public/Webhooks/Set-CippAuditLogWindowProcessed.ps1 new file mode 100644 index 0000000000..8d813392e7 --- /dev/null +++ b/backend/Modules/CIPPCore/Public/Webhooks/Set-CippAuditLogWindowProcessed.ps1 @@ -0,0 +1,42 @@ +function Set-CippAuditLogWindowProcessed { + <# + .SYNOPSIS + Mark one AuditLogCoverage window as Processed with a point write. + .DESCRIPTION + Replaces a "SearchId eq X" lookup. SearchId is not a key, so that scanned the tenant's + ledger partition once per search plus once per Downloaded window in the sweep behind it. + The batch item carries the window RowKey, so this addresses the row directly. + .PARAMETER TenantFilter + Tenant the window belongs to (the ledger PartitionKey). + .PARAMETER WindowRowKey + The AuditLogCoverage RowKey for the window. + .PARAMETER MatchedCount + Records that matched a rule in this window. + .FUNCTIONALITY + Internal + #> + [CmdletBinding(SupportsShouldProcess = $true)] + param( + [Parameter(Mandatory = $true)][string]$TenantFilter, + [Parameter(Mandatory = $true)][string]$WindowRowKey, + [int]$MatchedCount = 0 + ) + + if (-not $PSCmdlet.ShouldProcess($WindowRowKey, 'Mark audit log window Processed')) { return } + + $Ledger = Get-CippTable -TableName 'AuditLogCoverage' + try { + Add-CIPPAzDataTableEntity @Ledger -Entity @{ + PartitionKey = $TenantFilter + RowKey = $WindowRowKey + State = 'Processed' + ProcessedUtc = (Get-Date).ToUniversalTime() + MatchedCount = $MatchedCount + } -OperationType UpsertMerge + Write-Information "AuditLogV2: marked window $WindowRowKey Processed for $TenantFilter" + } catch { + # Not fatal: the records are already processed and deleted. The window stays in Processing + # and the stale reclaim picks it up, which costs a re-read of an empty partition. + Write-Information "AuditLogV2: could not mark window $WindowRowKey Processed for ${TenantFilter}: $($_.Exception.Message)" + } +} diff --git a/backend/Modules/CIPPCore/Public/Webhooks/Test-CIPPAuditLogRules.ps1 b/backend/Modules/CIPPCore/Public/Webhooks/Test-CIPPAuditLogRules.ps1 index 135297724e..66ff07f113 100644 --- a/backend/Modules/CIPPCore/Public/Webhooks/Test-CIPPAuditLogRules.ps1 +++ b/backend/Modules/CIPPCore/Public/Webhooks/Test-CIPPAuditLogRules.ps1 @@ -4,8 +4,21 @@ function Test-CIPPAuditLogRules { [Parameter(Mandatory = $true)] $TenantFilter, [Parameter(Mandatory = $true)] - $Rows + $Rows, + # Partition holding these rows in CacheWebhooks. V2 keys the cache per search + # (tenant|searchId); every row in one call comes from a single search, so one key covers + # the batch. Defaults to the tenant, leaving older callers unaffected. + [string]$CachePartitionKey, + # Remove processed rows with the plain delete instead of the part-aware one, on the + # caller's guarantee that it sweeps the whole cache partition afterwards. + # Remove-CIPPAzDataTableEntity also deletes the -partN rows of entities that were split for + # size, and that guarantee costs ~2.7x per row - measured at 37% of this stage, the single + # largest slice of it. A V2 caller owns one partition per search, so it can clear anything + # left behind in one keys-only pass and does not need it paid per record. Off by default: + # a caller that does not sweep must keep the part-aware delete or it orphans part rows. + [switch]$CallerSweepsCachePartition ) + if (-not $CachePartitionKey) { $CachePartitionKey = $TenantFilter } try { # Pre-compiled regex patterns for GUID matching (performance optimization) @@ -37,83 +50,102 @@ function Test-CIPPAuditLogRules { [string]$PropertyPrefix = '' ) - $DataObject.PSObject.Properties | ForEach-Object { - $propValue = $_.Value - - # Quick type check - skip if not string or empty - if ([string]::IsNullOrEmpty($propValue) -or $propValue -isnot [string]) { - return - } - - # Check for partner UPN format 1: user_@.onmicrosoft.com - $match = $script:PartnerUpnRegex.Match($propValue) - if ($match.Success) { - $hexId = $match.Groups[1].Value - $tenantDomain = $match.Groups[2].Value - if ($hexId.Length -eq 32) { - # Convert hex string to GUID format - $guid = "$($hexId.Substring(0,8))-$($hexId.Substring(8,4))-$($hexId.Substring(12,4))-$($hexId.Substring(16,4))-$($hexId.Substring(20,12))" - Write-Information "Found partner UPN format: $propValue with GUID: $guid and tenant: $tenantDomain" - - # O(1) hashtable lookup instead of O(n) loop - if ($PartnerUserLookup.ContainsKey($guid)) { - $PartnerUser = $PartnerUserLookup[$guid] - $DataObject | Add-Member -NotePropertyName "$PropertyPrefix$($_.Name)" -NotePropertyValue $PartnerUser.userPrincipalName -Force -ErrorAction SilentlyContinue - Write-Information "Mapped Partner User UPN: $($PartnerUser.userPrincipalName) to $PropertyPrefix$($_.Name)" - return - } - } - } + # foreach over a snapshot, not ForEach-Object over the live collection. This runs twice + # per record over every property, so the per-item cost of the pipeline dominated: it was + # the largest slice of the processing stage after the cache deletes. The snapshot is + # also what makes mutating $DataObject inside the loop safe. + foreach ($Property in @($DataObject.PSObject.Properties)) { + $PropValue = $Property.Value - # Check for partner exchange format: TenantName.onmicrosoft.com\tenant: , object: - $match = $script:PartnerExchangeRegex.Match($propValue) - if ($match.Success) { - $customerTenantDomain = $match.Groups[1].Value - $partnerTenantGuid = $match.Groups[2].Value - $objectGuid = $match.Groups[3].Value - Write-Information "Found partner exchange format: customer tenant $customerTenantDomain, partner tenant $partnerTenantGuid, object $objectGuid" - - # O(1) hashtable lookup - if ($PartnerUserLookup.ContainsKey($objectGuid)) { - $PartnerUser = $PartnerUserLookup[$objectGuid] - $DataObject | Add-Member -NotePropertyName "$PropertyPrefix$($_.Name)" -NotePropertyValue $PartnerUser.userPrincipalName -Force -ErrorAction SilentlyContinue - Write-Information "Mapped Partner User UPN: $($PartnerUser.userPrincipalName) to $PropertyPrefix$($_.Name)" - return - } + # Type first: [string]::IsNullOrEmpty on a non-string forces a conversion just to + # throw the result away. + if ($PropValue -isnot [string] -or $PropValue.Length -eq 0) { + continue } - # Check for standard GUID format - if ($script:StandardGuidRegex.IsMatch($propValue)) { - $guid = $propValue + # Which of the three patterns can possibly match is decided before the regex engine + # starts, rather than by running all three on every value: + # * StandardGuidRegex is anchored, so it only ever matches a string of exactly 36 + # characters - and no value of that length can hold either partner format. + # * Both partner patterns contain a mandatory literal ('user_', 'tenant:'), and an + # ordinal Contains is far cheaper than entering the regex engine to find out. + # Every skip below is a value the original would have run three regexes over and + # matched none of. + if ($PropValue.Length -eq 36) { + if (-not $script:StandardGuidRegex.IsMatch($PropValue)) { continue } + $Guid = $PropValue # O(1) hashtable lookups in priority order - if ($UserLookup.ContainsKey($guid)) { - $User = $UserLookup[$guid] - $DataObject | Add-Member -NotePropertyName "$PropertyPrefix$($_.Name)" -NotePropertyValue $User.userPrincipalName -Force -ErrorAction SilentlyContinue - Write-Information "Mapped User: $($User.userPrincipalName) to $PropertyPrefix$($_.Name)" - return + if ($UserLookup.ContainsKey($Guid)) { + $User = $UserLookup[$Guid] + $DataObject | Add-Member -NotePropertyName "$PropertyPrefix$($Property.Name)" -NotePropertyValue $User.userPrincipalName -Force -ErrorAction SilentlyContinue + Write-Information "Mapped User: $($User.userPrincipalName) to $PropertyPrefix$($Property.Name)" + continue } - if ($GroupLookup.ContainsKey($guid)) { - $Group = $GroupLookup[$guid] - $DataObject | Add-Member -NotePropertyName "$PropertyPrefix$($_.Name)" -NotePropertyValue $Group -Force -ErrorAction SilentlyContinue - Write-Information "Mapped Group: $($Group.displayName) to $PropertyPrefix$($_.Name)" - return + if ($GroupLookup.ContainsKey($Guid)) { + $Group = $GroupLookup[$Guid] + $DataObject | Add-Member -NotePropertyName "$PropertyPrefix$($Property.Name)" -NotePropertyValue $Group -Force -ErrorAction SilentlyContinue + Write-Information "Mapped Group: $($Group.displayName) to $PropertyPrefix$($Property.Name)" + continue } - if ($DeviceLookup.ContainsKey($guid)) { - $Device = $DeviceLookup[$guid] - $DataObject | Add-Member -NotePropertyName "$PropertyPrefix$($_.Name)" -NotePropertyValue $Device -Force -ErrorAction SilentlyContinue - Write-Information "Mapped Device: $($Device.displayName) to $PropertyPrefix$($_.Name)" - return + if ($DeviceLookup.ContainsKey($Guid)) { + $Device = $DeviceLookup[$Guid] + $DataObject | Add-Member -NotePropertyName "$PropertyPrefix$($Property.Name)" -NotePropertyValue $Device -Force -ErrorAction SilentlyContinue + Write-Information "Mapped Device: $($Device.displayName) to $PropertyPrefix$($Property.Name)" + continue } # ServicePrincipal indexed by both id and appId - if ($ServicePrincipalLookup.ContainsKey($guid)) { - $ServicePrincipal = $ServicePrincipalLookup[$guid] - $DataObject | Add-Member -NotePropertyName "$PropertyPrefix$($_.Name)" -NotePropertyValue $ServicePrincipal -Force -ErrorAction SilentlyContinue - Write-Information "Mapped Service Principal: $($ServicePrincipal.displayName) to $PropertyPrefix$($_.Name)" - return + if ($ServicePrincipalLookup.ContainsKey($Guid)) { + $ServicePrincipal = $ServicePrincipalLookup[$Guid] + $DataObject | Add-Member -NotePropertyName "$PropertyPrefix$($Property.Name)" -NotePropertyValue $ServicePrincipal -Force -ErrorAction SilentlyContinue + Write-Information "Mapped Service Principal: $($ServicePrincipal.displayName) to $PropertyPrefix$($Property.Name)" + continue + } + + continue + } + + # Partner UPN format: user_@.onmicrosoft.com + if ($PropValue.Contains('user_')) { + $Match = $script:PartnerUpnRegex.Match($PropValue) + if ($Match.Success) { + $HexId = $Match.Groups[1].Value + $TenantDomain = $Match.Groups[2].Value + if ($HexId.Length -eq 32) { + # Convert hex string to GUID format + $Guid = "$($HexId.Substring(0,8))-$($HexId.Substring(8,4))-$($HexId.Substring(12,4))-$($HexId.Substring(16,4))-$($HexId.Substring(20,12))" + Write-Information "Found partner UPN format: $PropValue with GUID: $Guid and tenant: $TenantDomain" + + # O(1) hashtable lookup instead of O(n) loop + if ($PartnerUserLookup.ContainsKey($Guid)) { + $PartnerUser = $PartnerUserLookup[$Guid] + $DataObject | Add-Member -NotePropertyName "$PropertyPrefix$($Property.Name)" -NotePropertyValue $PartnerUser.userPrincipalName -Force -ErrorAction SilentlyContinue + Write-Information "Mapped Partner User UPN: $($PartnerUser.userPrincipalName) to $PropertyPrefix$($Property.Name)" + continue + } + } + } + } + + # Partner exchange format: TenantName.onmicrosoft.com\tenant: , object: + if ($PropValue.Contains('tenant:')) { + $Match = $script:PartnerExchangeRegex.Match($PropValue) + if ($Match.Success) { + $CustomerTenantDomain = $Match.Groups[1].Value + $PartnerTenantGuid = $Match.Groups[2].Value + $ObjectGuid = $Match.Groups[3].Value + Write-Information "Found partner exchange format: customer tenant $CustomerTenantDomain, partner tenant $PartnerTenantGuid, object $ObjectGuid" + + # O(1) hashtable lookup + if ($PartnerUserLookup.ContainsKey($ObjectGuid)) { + $PartnerUser = $PartnerUserLookup[$ObjectGuid] + $DataObject | Add-Member -NotePropertyName "$PropertyPrefix$($Property.Name)" -NotePropertyValue $PartnerUser.userPrincipalName -Force -ErrorAction SilentlyContinue + Write-Information "Mapped Partner User UPN: $($PartnerUser.userPrincipalName) to $PropertyPrefix$($Property.Name)" + continue + } } } } @@ -138,41 +170,125 @@ function Test-CIPPAuditLogRules { 'Consent:Set' ) + # Properties the record loop assigns to later. They have to exist first: assigning to an + # absent property on a PSCustomObject throws. Built once and read by Add-Member per record. + # HasLocationData is in here too, so emitting the record is a plain assignment rather than a + # second Select-Object projection over the whole property bag. + $RecordPlaceholders = @{ + CIPPAction = $null + CIPPClause = $null + CIPPGeoLocation = $null + CIPPBadRepIP = $null + CIPPHostedIP = $null + CIPPIPDetected = $null + CIPPLocationInfo = $null + CIPPExtendedProperties = $null + CIPPDeviceProperties = $null + CIPPParameters = $null + CIPPModifiedProperties = $null + AuditRecord = $null + HasLocationData = $null + } + $TrustedIPTable = Get-CIPPTable -TableName 'trustedIps' $ConfigTable = Get-CIPPTable -TableName 'WebhookRules' - $ConfigEntries = Get-CIPPAzDataTableEntity @ConfigTable - $Configuration = foreach ($ConfigEntry in $ConfigEntries) { - if ([string]::IsNullOrEmpty($ConfigEntry.Tenants)) { - continue - } - $Tenants = $ConfigEntry.Tenants | ConvertFrom-Json -ErrorAction SilentlyContinue - if ($null -eq $Tenants) { - continue - } - # Expand tenant groups to get actual tenant list - $ExpandedTenants = Expand-CIPPTenantGroups -TenantFilter $Tenants - # Check if the TenantFilter matches any tenant in the expanded list or AllTenants - if ($ExpandedTenants.value -contains $TenantFilter -or $ExpandedTenants.value -contains 'AllTenants') { - # Expand tenant groups in exclusions the same way as inclusions - $ExcludedTenants = $ConfigEntry.excludedTenants | ConvertFrom-Json -ErrorAction SilentlyContinue - if ($ExcludedTenants) { - $ExcludedTenants = @(Expand-CIPPTenantGroups -TenantFilter $ExcludedTenants) - } - [pscustomobject]@{ - Tenants = $Tenants - Excluded = $ExcludedTenants - Conditions = $ConfigEntry.Conditions - Actions = $ConfigEntry.Actions - LogType = $ConfigEntry.Type - AlertComment = $ConfigEntry.AlertComment - CustomSubject = $ConfigEntry.CustomSubject + + # Per-tenant, in-process memo of the resolved rule set. Rebuilding it reads the whole + # WebhookRules table and calls Expand-CIPPTenantGroups for every entry that survives, which + # measured 179 ms on every invocation - and the engine is invoked once per slice, so a + # tenant with a large backlog paid it over and over for an answer that had not changed. + # + # The cost is a bounded staleness in when a rule edit takes effect. Two minutes is well + # inside the latency the pipeline already has: the ingestion timer runs every 15 minutes and + # the search window trails real time by longer than that, so this does not become the reason + # an alert is late. + $ConfigTtl = [TimeSpan]::FromMinutes(2) + if ($null -eq $script:AuditRuleConfigCache) { + $script:AuditRuleConfigCache = @{} + } + $Now = [datetime]::UtcNow + $ConfigCached = $script:AuditRuleConfigCache[$TenantFilter] + + if ($ConfigCached -and $ConfigCached.Expires -gt $Now) { + $Configuration = $ConfigCached.Configuration + Write-Information "Using cached rule configuration for $TenantFilter" + } else { + # Drop expired entries on a miss. Misses happen about once per TTL per tenant, so this + # is cheap, and it keeps the cache to tenants actually being processed rather than every + # tenant this worker has ever seen. + foreach ($Key in @($script:AuditRuleConfigCache.Keys)) { + if ($script:AuditRuleConfigCache[$Key].Expires -le $Now) { + $script:AuditRuleConfigCache.Remove($Key) } } + + $ConfigEntries = Get-CIPPAzDataTableEntity @ConfigTable + $Configuration = @(foreach ($ConfigEntry in $ConfigEntries) { + if ([string]::IsNullOrEmpty($ConfigEntry.Tenants)) { + continue + } + $Tenants = $ConfigEntry.Tenants | ConvertFrom-Json -ErrorAction SilentlyContinue + if ($null -eq $Tenants) { + continue + } + # Expand tenant groups to get actual tenant list + $ExpandedTenants = Expand-CIPPTenantGroups -TenantFilter $Tenants + # Check if the TenantFilter matches any tenant in the expanded list or AllTenants + if ($ExpandedTenants.value -contains $TenantFilter -or $ExpandedTenants.value -contains 'AllTenants') { + # Expand tenant groups in exclusions the same way as inclusions + $ExcludedTenants = $ConfigEntry.excludedTenants | ConvertFrom-Json -ErrorAction SilentlyContinue + if ($ExcludedTenants) { + $ExcludedTenants = @(Expand-CIPPTenantGroups -TenantFilter $ExcludedTenants) + } + [pscustomobject]@{ + Tenants = $Tenants + Excluded = $ExcludedTenants + Conditions = $ConfigEntry.Conditions + Actions = $ConfigEntry.Actions + LogType = $ConfigEntry.Type + AlertComment = $ConfigEntry.AlertComment + CustomSubject = $ConfigEntry.CustomSubject + } + } + }) + + $script:AuditRuleConfigCache[$TenantFilter] = [PSCustomObject]@{ + Expires = $Now.Add($ConfigTtl) + Configuration = $Configuration + } } $Table = Get-CIPPTable -tablename 'cacheauditloglookups' $1dayago = (Get-Date).AddDays(-1).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') - $Lookups = Get-CIPPAzDataTableEntity @Table -Filter "PartitionKey eq '$TenantFilter' and Timestamp gt datetime'$1dayago'" + + # In-process memo of the directory hash tables, on top of the cacheauditloglookups table + # that already holds them for a day. The table read is cheap; rebuilding the four hash + # tables from the cached JSON blobs on every call is not - measured at 95 ms per + # invocation. The engine runs once per 500-record slice, so a tenant with several windows + # in a cycle paid it repeatedly for identical data, and that multiplies by tenant count. + # Five minutes, well inside the table cache's own one-day life, so this only ever shortens + # how long a rebuilt set is reused - it cannot serve data the table layer would not. + $LookupsWarm = $false + if ($null -eq $script:AuditRuleLookupCache) { + $script:AuditRuleLookupCache = @{} + } + $LookupNow = [datetime]::UtcNow + $LookupEntry = $script:AuditRuleLookupCache[$TenantFilter] + if ($LookupEntry -and $LookupEntry.Expires -gt $LookupNow) { + $UserLookup = $LookupEntry.UserLookup + $GroupLookup = $LookupEntry.GroupLookup + $DeviceLookup = $LookupEntry.DeviceLookup + $ServicePrincipalLookup = $LookupEntry.ServicePrincipalLookup + $LookupsWarm = $true + $Lookups = $null + } else { + foreach ($CachedTenant in @($script:AuditRuleLookupCache.Keys)) { + if ($script:AuditRuleLookupCache[$CachedTenant].Expires -le $LookupNow) { + $script:AuditRuleLookupCache.Remove($CachedTenant) + } + } + $Lookups = Get-CIPPAzDataTableEntity @Table -Filter "PartitionKey eq '$TenantFilter' and Timestamp gt datetime'$1dayago'" + } # Check if cached data needs refresh (wrong format or corrupted) $NeedsRefresh = $false @@ -197,14 +313,22 @@ function Test-CIPPAuditLogRules { } } - if (!$Lookups -or $NeedsRefresh) { - # Try CippReportingDB first (pre-populated by timer, same pattern as Add-CIPPApplicationPermission) + if ($LookupsWarm) { + # Already restored from the in-process memo above; neither rebuild path applies. This + # arm exists so the memo can short-circuit without re-indenting the two branches below. + Write-Information "Using cached directory hashtable lookups for tenant $TenantFilter" + } elseif (!$Lookups -or $NeedsRefresh) { + # Try CippReportingDB first (pre-populated by timer, same pattern as Add-CIPPApplicationPermission). + # Get-CIPPTestData rather than New-CIPPDbRequest: the shared in-process cache lets + # concurrent batches reuse one copy of the directory data, and -Fields parses only + # what the mapping reads - unprojected per-batch loads OOM'd the container on + # large tenants. Write-Information "Checking CippReportingDB for directory data for tenant $TenantFilter" try { - $Users = @(New-CIPPDbRequest -TenantFilter $TenantFilter -Type 'Users') | Select-Object id, displayName, userPrincipalName, accountEnabled - $Groups = @(New-CIPPDbRequest -TenantFilter $TenantFilter -Type 'Groups') | Select-Object id, displayName, mailEnabled, securityEnabled - $Devices = @(New-CIPPDbRequest -TenantFilter $TenantFilter -Type 'Devices') | Select-Object id, displayName, deviceId - $ServicePrincipals = @(New-CIPPDbRequest -TenantFilter $TenantFilter -Type 'ServicePrincipals') | Select-Object id, appId, displayName, appDisplayName, accountEnabled, servicePrincipalType, tags + $Users = @(Get-CIPPTestData -TenantFilter $TenantFilter -Type 'Users' -Fields 'id', 'displayName', 'userPrincipalName', 'accountEnabled') + $Groups = @(Get-CIPPTestData -TenantFilter $TenantFilter -Type 'Groups' -Fields 'id', 'displayName', 'mailEnabled', 'securityEnabled') + $Devices = @(Get-CIPPTestData -TenantFilter $TenantFilter -Type 'Devices' -Fields 'id', 'displayName', 'deviceId') + $ServicePrincipals = @(Get-CIPPTestData -TenantFilter $TenantFilter -Type 'ServicePrincipals' -Fields 'id', 'appId', 'displayName', 'appDisplayName', 'accountEnabled', 'servicePrincipalType', 'tags') Write-Information "Loaded from CippReportingDB: $($Users.Count) users, $($Groups.Count) groups, $($Devices.Count) devices, $($ServicePrincipals.Count) service principals" } catch { Write-Information "CippReportingDB query failed for ${TenantFilter}: $($_.Exception.Message)" @@ -386,9 +510,39 @@ function Test-CIPPAuditLogRules { } } + # Store whichever branch built them, so the next slice for this tenant skips the rebuild. + if (-not $LookupsWarm) { + $script:AuditRuleLookupCache[$TenantFilter] = [PSCustomObject]@{ + Expires = $LookupNow.AddMinutes(5) + UserLookup = $UserLookup + GroupLookup = $GroupLookup + DeviceLookup = $DeviceLookup + ServicePrincipalLookup = $ServicePrincipalLookup + } + } + # Partner users - cache in cacheauditloglookups (PartitionKey '_partner') to avoid a fresh Graph fetch every invocation - $PartnerUsersCache = Get-CIPPAzDataTableEntity @Table -Filter "PartitionKey eq '_partner' and RowKey eq 'users' and Timestamp gt datetime'$1dayago'" - if ($PartnerUsersCache -and $PartnerUsersCache.Format -eq 'hashtable') { + # Process-wide, not per tenant: this row is keyed '_partner' and is the same answer for + # every tenant this worker handles, so a per-tenant memo would still re-read it once per + # tenant. It was read on every invocation. + if ($null -eq $script:PartnerUserMemo -or $script:PartnerUserMemo.Expires -le [datetime]::UtcNow) { + $script:PartnerUserMemo = [PSCustomObject]@{ + Expires = [datetime]::UtcNow.AddMinutes(5) + Lookup = $null + } + $PartnerUsersCache = Get-CIPPAzDataTableEntity @Table -Filter "PartitionKey eq '_partner' and RowKey eq 'users' and Timestamp gt datetime'$1dayago'" + } elseif ($null -ne $script:PartnerUserMemo.Lookup) { + $PartnerUserLookup = $script:PartnerUserMemo.Lookup + $PartnerUsersCache = $null + } else { + # Memo exists but holds nothing yet - the previous pass fell through to the Graph + # refresh below. Re-read rather than assume, so a concurrent refresh is picked up. + $PartnerUsersCache = Get-CIPPAzDataTableEntity @Table -Filter "PartitionKey eq '_partner' and RowKey eq 'users' and Timestamp gt datetime'$1dayago'" + } + + if ($null -ne $PartnerUserLookup -and $null -eq $PartnerUsersCache) { + Write-Information "Partner user hashtable served from memo: $($PartnerUserLookup.Count) partner users" + } elseif ($PartnerUsersCache -and $PartnerUsersCache.Format -eq 'hashtable') { Write-Information 'Loading partner user hashtable from cache' $PartnerUserLookup = ($PartnerUsersCache.Data | ConvertFrom-Json -ErrorAction SilentlyContinue -AsHashtable) ?? @{} } else { @@ -407,6 +561,7 @@ function Test-CIPPAuditLogRules { } -Force $PartnerUsers = $null } + $script:PartnerUserMemo.Lookup = $PartnerUserLookup Write-Information "Partner user hashtable: $($PartnerUserLookup.Count) partner users" Write-Warning '## Audit Log Configuration ##' @@ -425,10 +580,23 @@ function Test-CIPPAuditLogRules { throw $_ } + # Exclusions and trusted IPs join the same per-tenant memo as the rule set and the directory + # lookups. Both were read on every invocation - once per 500-record slice, per tenant - for + # data that changes when an operator edits a list, not between slices of one batch. $AuditLogUserExclusions = Get-CIPPTable -TableName 'AuditLogUserExclusions' - $ExcludedUsers = Get-CIPPAzDataTableEntity @AuditLogUserExclusions -Filter "PartitionKey eq '$TenantFilter'" - - if ($LogCount -gt 0) { + if ($null -eq $script:AuditRuleListCache) { $script:AuditRuleListCache = @{} } + $ListNow = [datetime]::UtcNow + $ListEntry = $script:AuditRuleListCache[$TenantFilter] + if ($ListEntry -and $ListEntry.Expires -gt $ListNow) { + $ExcludedUsers = $ListEntry.ExcludedUsers + $TrustedIPLookup = $ListEntry.TrustedIPLookup + } else { + foreach ($CachedTenant in @($script:AuditRuleListCache.Keys)) { + if ($script:AuditRuleListCache[$CachedTenant].Expires -le $ListNow) { + $script:AuditRuleListCache.Remove($CachedTenant) + } + } + $ExcludedUsers = Get-CIPPAzDataTableEntity @AuditLogUserExclusions -Filter "PartitionKey eq '$TenantFilter'" $TrustedIPEntries = Get-CIPPAzDataTableEntity @TrustedIPTable -Filter "((PartitionKey eq '$TenantFilter') or (PartitionKey eq 'AllTenants')) and state eq 'Trusted'" $TrustedIPLookup = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) foreach ($TrustedEntry in $TrustedIPEntries) { @@ -436,6 +604,14 @@ function Test-CIPPAuditLogRules { $null = $TrustedIPLookup.Add([string]$TrustedEntry.RowKey) } } + $script:AuditRuleListCache[$TenantFilter] = [PSCustomObject]@{ + Expires = $ListNow.AddMinutes(2) + ExcludedUsers = $ExcludedUsers + TrustedIPLookup = $TrustedIPLookup + } + } + + if ($LogCount -gt 0) { $GeoPrefetchIPs = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) foreach ($AuditRecord in $SearchResults) { @@ -460,14 +636,36 @@ function Test-CIPPAuditLogRules { # so the run always converges instead of looping on the same rows - and a batch # takes minutes, so that window is real. Per-record calls cost ~13x more, since # AzBobbyTables wraps each one in its own $batch transaction. - $DeleteFlushSize = 25 + # 100 is the table service's per-transaction maximum, so it is the largest flush still + # costing one round trip - 4x fewer than at 25, for a crash-replay window of 100 + # records rather than 25. + $DeleteFlushSize = 100 $PendingDeletes = [System.Collections.Generic.List[object]]::new() $ProcessedData = foreach ($AuditRecord in $SearchResults) { - $RecordStartTime = Get-Date - Write-Information "Processing RowKey $($AuditRecord.id) - $($TenantFilter)." $RootProperties = $AuditRecord - $Data = $AuditRecord.auditData | Select-Object *, CIPPAction, CIPPClause, CIPPGeoLocation, CIPPBadRepIP, CIPPHostedIP, CIPPIPDetected, CIPPLocationInfo, CIPPExtendedProperties, CIPPDeviceProperties, CIPPParameters, CIPPModifiedProperties, AuditRecord -ErrorAction SilentlyContinue + # A copy plus one Add-Member, not a Select-Object projection: the projection + # re-derives the whole property set per record, measured at 148 us / 61 KB against + # 51 us / 32 KB for the copy. No -Force, which matches what + # `Select-Object *, Dup -ErrorAction SilentlyContinue` did - a record already + # carrying one of these names keeps its own value rather than being nulled. + # + # ExtendedProperties / DeviceProperties / parameters are dropped here rather than + # excluded from the emitted object at the bottom of the loop. They are flattened + # onto CIPP* copies below, which now read them from the source record, so carrying + # them through only to strip them again was pure copying - and dropping them here + # also shortens both GUID-mapping passes over this object. + # + # Guarded, because a record with no auditData must still land in the per-record + # catch below the way it did before, not throw out of the whole batch. + $Data = $null + if ($null -ne $AuditRecord.auditData) { + $Data = $AuditRecord.auditData.PSObject.Copy() + $Data.PSObject.Properties.Remove('ExtendedProperties') + $Data.PSObject.Properties.Remove('DeviceProperties') + $Data.PSObject.Properties.Remove('parameters') + $Data | Add-Member -NotePropertyMembers $RecordPlaceholders -ErrorAction SilentlyContinue + } try { # Attempt to locate GUIDs in $Data and match them with their corresponding user, group, device, or service principal using O(1) hashtable lookups # Write-Information 'Checking Data for GUIDs to map to users, groups, devices, or service principals' @@ -479,50 +677,56 @@ function Test-CIPPAuditLogRules { # Flattened onto $Data so rules can match the property names directly. One # Add-Member per sub-object: per-property calls rebuild the property bag each time. - if ($Data.ExtendedProperties) { - $Data.CIPPExtendedProperties = ($Data.ExtendedProperties | ConvertTo-Json -Compress -Depth 10) - $Flattened = @{} - foreach ($Prop in $Data.ExtendedProperties) { + # One accumulated hash table and a single Add-Member, rather than one per + # sub-object. Each Add-Member extends the property bag, and four per record + # measured at 198 us / 132 KB against 125 us / 87 KB for one. Collision + # behaviour is unchanged: later sub-objects overwrote earlier keys through + # -Force before, and overwrite the same keys in the hash table now. + # ConvertTo-Json takes -InputObject rather than a pipeline, which skips setting + # up a pipeline per call for no change in output. + # The first three read from $Source, not $Data - they are deliberately no longer + # copied onto $Data. ModifiedProperties stays on $Data and is read from there. + $Source = $AuditRecord.auditData + $Flattened = @{} + if ($Source.ExtendedProperties) { + $Data.CIPPExtendedProperties = (ConvertTo-Json -InputObject $Source.ExtendedProperties -Compress -Depth 10) + foreach ($Prop in $Source.ExtendedProperties) { # Must be a real loop: `continue` inside ForEach-Object unwinds to the # enclosing foreach and drops the whole record. if ($Prop.Value -in $ExtendedPropertiesIgnoreList) { continue } if ([string]::IsNullOrEmpty($Prop.Name)) { continue } $Flattened[$Prop.Name] = $Prop.Value } - if ($Flattened.Count -gt 0) { $Data | Add-Member -NotePropertyMembers $Flattened -Force -ErrorAction SilentlyContinue } } - if ($Data.DeviceProperties) { - $Data.CIPPDeviceProperties = ($Data.DeviceProperties | ConvertTo-Json -Compress -Depth 10) - $Flattened = @{} - foreach ($Prop in $Data.DeviceProperties) { + if ($Source.DeviceProperties) { + $Data.CIPPDeviceProperties = (ConvertTo-Json -InputObject $Source.DeviceProperties -Compress -Depth 10) + foreach ($Prop in $Source.DeviceProperties) { if ([string]::IsNullOrEmpty($Prop.Name)) { continue } $Flattened[$Prop.Name] = $Prop.Value } - if ($Flattened.Count -gt 0) { $Data | Add-Member -NotePropertyMembers $Flattened -Force -ErrorAction SilentlyContinue } } - if ($Data.parameters) { - $Data.CIPPParameters = ($Data.parameters | ConvertTo-Json -Compress -Depth 10) - $Flattened = @{} - foreach ($Prop in $Data.parameters) { + if ($Source.parameters) { + $Data.CIPPParameters = (ConvertTo-Json -InputObject $Source.parameters -Compress -Depth 10) + foreach ($Prop in $Source.parameters) { if ([string]::IsNullOrEmpty($Prop.Name)) { continue } $Flattened[$Prop.Name] = $Prop.Value } - if ($Flattened.Count -gt 0) { $Data | Add-Member -NotePropertyMembers $Flattened -Force -ErrorAction SilentlyContinue } } if ($Data.ModifiedProperties) { - $Data.CIPPModifiedProperties = ($Data.ModifiedProperties | ConvertTo-Json -Compress -Depth 10) + $Data.CIPPModifiedProperties = (ConvertTo-Json -InputObject $Data.ModifiedProperties -Compress -Depth 10) try { - $Flattened = @{} foreach ($Prop in $Data.ModifiedProperties) { if ([string]::IsNullOrEmpty($Prop.Name)) { continue } $Flattened["$($Prop.Name)"] = "$($Prop.NewValue)" $Flattened["Previous_Value_$($Prop.Name)"] = "$($Prop.OldValue)" } - if ($Flattened.Count -gt 0) { $Data | Add-Member -NotePropertyMembers $Flattened -Force -ErrorAction SilentlyContinue } } catch { Write-Information "Error flattening ModifiedProperties for $($AuditRecord.id): $($_.Exception.Message)" } } + if ($Flattened.Count -gt 0) { + $Data | Add-Member -NotePropertyMembers $Flattened -Force -ErrorAction SilentlyContinue + } $HasLocationData = $false @@ -547,7 +751,7 @@ function Test-CIPPAuditLogRules { $Data.CIPPBadRepIP = $Loc.Proxy $Data.CIPPHostedIP = $Loc.Hosting $Data.CIPPIPDetected = [string]$Data.clientip - $Data.CIPPLocationInfo = ($Loc | ConvertTo-Json -Compress -Depth 10) + $Data.CIPPLocationInfo = (ConvertTo-Json -InputObject $Loc -Compress -Depth 10) $HasLocationData = $true } else { $Data.CIPPGeoLocation = 'Unknown' @@ -560,32 +764,46 @@ function Test-CIPPAuditLogRules { } } } - $Data.AuditRecord = [string]($RootProperties | ConvertTo-Json -Compress -Depth 10) - $Data | Select-Object *, - @{n = 'HasLocationData'; exp = { $HasLocationData } } -ExcludeProperty ExtendedProperties, DeviceProperties, parameters + $Data.AuditRecord = [string](ConvertTo-Json -InputObject $RootProperties -Compress -Depth 10) + # Two plain assignments and emit. This step used to be a second Select-Object + # over the whole property bag - a calculated property for HasLocationData plus + # -ExcludeProperty for three properties that are no longer copied onto $Data in + # the first place. Measured at 77 us / 54 KB for the projection against + # 16 us / 14 KB here. + $Data.HasLocationData = $HasLocationData + $Data } catch { #write-warning "Audit log: Error processing data: $($_.Exception.Message)`r`n$($_.InvocationInfo.PositionMessage)" Write-LogMessage -API 'Webhooks' -message 'Error Processing Audit Log Data' -LogData (Get-CippException -Exception $_) -sev Error -tenant $TenantFilter } $PendingDeletes.Add([PSCustomObject]@{ - PartitionKey = $TenantFilter + PartitionKey = $CachePartitionKey RowKey = [string]$AuditRecord.id }) if ($PendingDeletes.Count -ge $DeleteFlushSize) { try { - $null = Remove-CIPPAzDataTableEntity -Force @CacheWebhooksTable -Entity $PendingDeletes.ToArray() + if ($CallerSweepsCachePartition) { + $null = Remove-AzDataTableEntity -Force @CacheWebhooksTable -Entity $PendingDeletes.ToArray() + } else { + $null = Remove-CIPPAzDataTableEntity -Force @CacheWebhooksTable -Entity $PendingDeletes.ToArray() + } } catch { Write-Information "Error removing $($PendingDeletes.Count) processed row(s) from cache: $($_.Exception.Message)" } $PendingDeletes.Clear() } - $RecordEndTime = Get-Date - $RecordSeconds = ($RecordEndTime - $RecordStartTime).TotalSeconds - Write-Warning "Task took $RecordSeconds seconds for RowKey $($AuditRecord.id)" + # No per-record timing warning here. It cost two Get-Date calls, a string + # interpolation and a warning-stream write for every record - and at production + # volumes it emits one log line per audit record, which is noise that has to be + # paid for and then stored. Per-stage timings come from the benchmark harness. } - if ($PendingDeletes.Count -gt 0) { + # Trailing partial batch. When the caller sweeps its own partition it already reads that + # partition and deletes whatever is left, which is precisely this remainder - so paying + # for a separate round trip here would delete the same rows a moment earlier and no more. + # Without a sweep the tail has to go now, or those rows sit in the cache forever. + if ($PendingDeletes.Count -gt 0 -and -not $CallerSweepsCachePartition) { try { $null = Remove-CIPPAzDataTableEntity -Force @CacheWebhooksTable -Entity $PendingDeletes.ToArray() } catch { @@ -706,51 +924,105 @@ function Test-CIPPAuditLogRules { $CippConfigTable = Get-CippTable -tablename Config $CippConfig = Get-CIPPAzDataTableEntity @CippConfigTable -Filter "PartitionKey eq 'InstanceProperties' and RowKey eq 'CIPPURL'" $CIPPURL = 'https://{0}' -f $CippConfig.Value - foreach ($AuditLog in $DataToProcess) { - Write-Information "Processing $($AuditLog.operation)" - $Webhook = @{ - Data = $AuditLog - CIPPURL = [string]$CIPPURL - TenantFilter = $TenantFilter - AlertComment = $AuditLog.CIPPAlertComment - } + # Audit-log rows are batched rather than written one per alert. Every row shares the + # tenant partition key, so they go in one transaction: measured at 3.26 ms/row written + # singly against 0.57 ms/row at 100 per batch, and this is the single largest cost in + # the stage once rules actually fire. + # + # Flushed on count OR accumulated size. The size guard is not decorative - each row + # carries the whole serialised alert, including the shaped record and the raw audit + # record, which measured 3 KB for a plain event and 19 KB for one with 20 modified + # properties. A transaction is capped at 4 MB, so a fixed count of 100 would start + # failing whole batches on a tenant with verbose ModifiedProperties. + $AlertFlushCount = 100 + $AlertFlushBytes = 3MB + $PendingAlertRows = [System.Collections.Generic.List[object]]::new() + $PendingAlertBytes = 0 + $AuditLogTable = Get-CIPPTable -TableName 'AuditLogs' + + $FlushAlertRows = { + if ($PendingAlertRows.Count -eq 0) { return } try { - Invoke-CippWebhookProcessing @Webhook + Add-CIPPAzDataTableEntity @AuditLogTable -Entity $PendingAlertRows.ToArray() -Force } catch { - Write-Warning "Error sending final step of auditlog processing: $($_.Exception.Message)" - Write-Information $_.InvocationInfo.PositionMessage + # Not fatal: the alerts themselves have already been dispatched, and the claim + # rows still prevent a retry from sending them again. What is lost is the stored + # copy, which shows in the UI as a row stuck at 'Processing'. + Write-Warning "Could not store $($PendingAlertRows.Count) audit log row(s): $($_.Exception.Message)" + } + $PendingAlertRows.Clear() + } + + try { + foreach ($AuditLog in $DataToProcess) { + Write-Information "Processing $($AuditLog.operation)" + $Webhook = @{ + Data = $AuditLog + CIPPURL = [string]$CIPPURL + TenantFilter = $TenantFilter + AlertComment = $AuditLog.CIPPAlertComment + PendingAuditLogWrites = $PendingAlertRows + } + try { + Invoke-CippWebhookProcessing @Webhook + } catch { + Write-Warning "Error sending final step of auditlog processing: $($_.Exception.Message)" + Write-Information $_.InvocationInfo.PositionMessage + } + if ($PendingAlertRows.Count -gt 0) { + $PendingAlertBytes = 0 + foreach ($Row in $PendingAlertRows) { $PendingAlertBytes += $Row.Data.Length } + if ($PendingAlertRows.Count -ge $AlertFlushCount -or $PendingAlertBytes -ge $AlertFlushBytes) { + & $FlushAlertRows + } + } } + } finally { + # In a finally so an exception mid-loop still stores the rows for alerts that did + # go out; only a hard process kill loses them. + & $FlushAlertRows } } - try { - $RowIds = [System.Collections.Generic.HashSet[string]]::new([string[]]@($Rows.id | Where-Object { $_ })) - if ($RowIds.Count -gt 0) { + # Belt-and-braces pass: re-resolve this chunk's ids to physical rows and delete anything the + # per-record flush missed - in practice the parts of split records, since the flush deletes + # by logical id only. + # + # Skipped when the caller sweeps its own partition, because it is not free: each slice of 50 + # ids becomes a 100-predicate "RowKey eq X or OriginalEntityId eq X" filter, and an OR-list + # cannot be served from the Azure Table index - it scans the partition. That is ten scans per + # call to find, normally, nothing at all. The caller's single keys-only partition pass + # catches the same orphans for one point query. + if (-not $CallerSweepsCachePartition) { + try { + $RowIds = [System.Collections.Generic.HashSet[string]]::new([string[]]@($Rows.id | Where-Object { $_ })) + if ($RowIds.Count -gt 0) { # Only the rows being deleted, not a partition scan - this runs once per chunk. # Raw cmdlet and OriginalEntityId: the wrapper reports a split record's logical # RowKey, so deleting that left X-part1 / X-part2 orphaned. - $IdList = @($RowIds) - $FilterBatch = 50 - $RowsToRemove = [System.Collections.Generic.List[object]]::new() + $IdList = @($RowIds) + $FilterBatch = 50 + $RowsToRemove = [System.Collections.Generic.List[object]]::new() - for ($Start = 0; $Start -lt $IdList.Count; $Start += $FilterBatch) { - $Slice = @($IdList[$Start..([Math]::Min($Start + $FilterBatch - 1, $IdList.Count - 1))]) - $Predicate = ($Slice | ForEach-Object { "RowKey eq '$_' or OriginalEntityId eq '$_'" }) -join ' or ' - $Found = @(Get-AzDataTableEntity @CacheWebhooksTable ` - -Filter "PartitionKey eq '$TenantFilter' and ($Predicate)" ` - -Property 'PartitionKey', 'RowKey') - foreach ($Row in $Found) { - $RowsToRemove.Add([PSCustomObject]@{ PartitionKey = $Row.PartitionKey; RowKey = $Row.RowKey }) + for ($Start = 0; $Start -lt $IdList.Count; $Start += $FilterBatch) { + $Slice = @($IdList[$Start..([Math]::Min($Start + $FilterBatch - 1, $IdList.Count - 1))]) + $Predicate = ($Slice | ForEach-Object { "RowKey eq '$_' or OriginalEntityId eq '$_'" }) -join ' or ' + $Found = @(Get-AzDataTableEntity @CacheWebhooksTable ` + -Filter "PartitionKey eq '$CachePartitionKey' and ($Predicate)" ` + -Property 'PartitionKey', 'RowKey') + foreach ($Row in $Found) { + $RowsToRemove.Add([PSCustomObject]@{ PartitionKey = $Row.PartitionKey; RowKey = $Row.RowKey }) + } } - } - if ($RowsToRemove.Count -gt 0) { - Remove-CIPPAzDataTableEntity @CacheWebhooksTable -Entity $RowsToRemove -Force - Write-Information "Removed $($RowsToRemove.Count) processed rows from cache" + if ($RowsToRemove.Count -gt 0) { + Remove-CIPPAzDataTableEntity @CacheWebhooksTable -Entity $RowsToRemove -Force + Write-Information "Removed $($RowsToRemove.Count) processed rows from cache" + } } + } catch { + Write-Information "Error removing rows from cache: $($_.Exception.Message)" } - } catch { - Write-Information "Error removing rows from cache: $($_.Exception.Message)" } } catch { diff --git a/backend/Tests/AuditLogs/Get-CippAuditLogPlannedWindows.Tests.ps1 b/backend/Tests/AuditLogs/Get-CippAuditLogPlannedWindows.Tests.ps1 new file mode 100644 index 0000000000..2de8a1c336 --- /dev/null +++ b/backend/Tests/AuditLogs/Get-CippAuditLogPlannedWindows.Tests.ps1 @@ -0,0 +1,114 @@ +# Pester tests for Get-CippAuditLogPlannedWindows - the V2 audit-log window planner. +# +# The geometry here is load-bearing and was previously untested. Three properties matter: +# +# * 35-minute windows on a 30-minute stride, so consecutive windows OVERLAP by 5 minutes and +# coverage is continuous. The overlap is deliberate and is only safe because alerting +# de-duplicates by record id in Invoke-CippWebhookProcessing's claim-insert. +# * Window ends sit on `floor_to_30min(now) - settle`. With a 20-minute settle that is the +# :10/:40 grid, and with the planner firing at :00/:15/:30/:45 a fresh window becomes +# creatable exactly at a :00/:30 tick - no tick delay - while :15/:45 produce nothing new. +# * The settle is the grace Microsoft gets to publish an event before the window covering it is +# searched. Changing it moves the grid, which is why the tick behaviour is pinned here. + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + . (Join-Path $RepoRoot 'Modules/CIPPCore/Public/AuditLogs/Get-CippAuditLogPlannedWindows.ps1') + + function New-Row { + param([datetime]$Start) + [pscustomobject]@{ + RowKey = $Start.ToString('yyyyMMddHHmmss') + WindowStart = $Start + } + } + # [datetimeoffset], not [datetime]::Parse + SpecifyKind: Parse converts a 'Z' literal to LOCAL + # time and SpecifyKind then merely relabels it UTC without converting, so every assertion below + # would be off by this machine's UTC offset. + function Utc { param([string]$Text) ([datetimeoffset]$Text).UtcDateTime } +} + +Describe 'Get-CippAuditLogPlannedWindows' { + + Context 'window geometry' { + + It 'produces 35-minute windows' { + $Owed = @(Get-CippAuditLogPlannedWindows -ExistingRows @() -Now (Utc '2026-08-14T10:00:00Z')) + $Owed.Count | Should -Be 1 + ($Owed[0].WindowEnd - $Owed[0].WindowStart).TotalMinutes | Should -Be 35 + } + + It 'ends on the :10/:40 grid, being floor_to_30min(now) minus the 20-minute settle' { + (Get-CippAuditLogPlannedWindows -ExistingRows @() -Now (Utc '2026-08-14T10:00:00Z')).WindowEnd.ToString('HH:mm') | Should -Be '09:40' + (Get-CippAuditLogPlannedWindows -ExistingRows @() -Now (Utc '2026-08-14T10:29:59Z')).WindowEnd.ToString('HH:mm') | Should -Be '09:40' + (Get-CippAuditLogPlannedWindows -ExistingRows @() -Now (Utc '2026-08-14T10:30:00Z')).WindowEnd.ToString('HH:mm') | Should -Be '10:10' + } + + It 'advances on a 30-minute stride, so consecutive windows overlap by 5 minutes' { + # Feed the first window back as history and ask again half an hour later. + $First = Get-CippAuditLogPlannedWindows -ExistingRows @() -Now (Utc '2026-08-14T10:00:00Z') + $Second = Get-CippAuditLogPlannedWindows -ExistingRows @((New-Row $First.WindowStart)) -Now (Utc '2026-08-14T10:30:00Z') + + ($Second.WindowStart - $First.WindowStart).TotalMinutes | Should -Be 30 + # Overlap, not a gap: the second window starts before the first one ends. + $Second.WindowStart | Should -BeLessThan $First.WindowEnd + ($First.WindowEnd - $Second.WindowStart).TotalMinutes | Should -Be 5 + } + } + + Context 'tick behaviour' { + # The planner fires at :00/:15/:30/:45. A fresh window must be creatable at :00 and :30 + # with no delay, and the intermediate ticks must produce nothing - they exist to do + # retries and download/process work. + + BeforeEach { + # History through the window ending 09:40, i.e. the one the 10:00 tick would create. + $script:History = @((New-Row (Utc '2026-08-14T09:05:00Z'))) + } + + It 'offers nothing at the :15 tick when the :00 window already exists' { + @(Get-CippAuditLogPlannedWindows -ExistingRows $script:History -Now (Utc '2026-08-14T10:15:00Z')).Count | Should -Be 0 + } + + It 'offers nothing at the :45 tick either' { + $Later = $script:History + @((New-Row (Utc '2026-08-14T09:35:00Z'))) + @(Get-CippAuditLogPlannedWindows -ExistingRows $Later -Now (Utc '2026-08-14T10:45:00Z')).Count | Should -Be 0 + } + + It 'offers exactly one fresh window at the :30 tick' { + $Owed = @(Get-CippAuditLogPlannedWindows -ExistingRows $script:History -Now (Utc '2026-08-14T10:30:00Z')) + $Owed.Count | Should -Be 1 + $Owed[0].WindowStart.ToString('HH:mm') | Should -Be '09:35' + $Owed[0].WindowEnd.ToString('HH:mm') | Should -Be '10:10' + } + } + + Context 'seeding and backfill' { + + It 'seeds a brand-new tenant with only the newest settled window' { + # Not a 24-hour backfill on first sight of a tenant. + @(Get-CippAuditLogPlannedWindows -ExistingRows @() -Now (Utc '2026-08-14T10:00:00Z')).Count | Should -Be 1 + } + + It 'backfills gaps oldest-first and caps the run' { + # One ancient row, so the planner sees a long gap between it and now. + $Owed = @(Get-CippAuditLogPlannedWindows -ExistingRows @((New-Row (Utc '2026-08-14T00:05:00Z'))) -Now (Utc '2026-08-14T10:00:00Z')) + $Owed.Count | Should -Be 6 + # Oldest first, so historical gaps drain before they age out of the horizon. + $Owed[0].WindowStart | Should -BeLessThan $Owed[1].WindowStart + } + + It 'always includes the newest window when the backlog exceeds the cap' { + # Otherwise the live period would never be Planned while a backlog drains, and + # alerting would stall on current activity until history caught up. + $Owed = @(Get-CippAuditLogPlannedWindows -ExistingRows @((New-Row (Utc '2026-08-14T00:05:00Z'))) -Now (Utc '2026-08-14T10:00:00Z')) + $Owed[-1].WindowEnd.ToString('HH:mm') | Should -Be '09:40' + } + + It 'ignores reconciliation rows when finding gaps' { + # RECON-* rows are the 12-hour catch-all path and must not suppress a regular window. + $Recon = @([pscustomobject]@{ RowKey = 'RECON-20260814000000'; WindowStart = (Utc '2026-08-14T00:00:00Z') }) + @(Get-CippAuditLogPlannedWindows -ExistingRows $Recon -Now (Utc '2026-08-14T10:00:00Z')).Count | Should -Be 1 + } + } +} diff --git a/backend/Tests/Webhooks/Invoke-CIPPWebhookProcessing.Tests.ps1 b/backend/Tests/Webhooks/Invoke-CIPPWebhookProcessing.Tests.ps1 new file mode 100644 index 0000000000..8f6451488b --- /dev/null +++ b/backend/Tests/Webhooks/Invoke-CIPPWebhookProcessing.Tests.ps1 @@ -0,0 +1,208 @@ +# Pester tests for Invoke-CippWebhookProcessing - the alert dispatch and dedupe step. +# +# This runs once per MATCHED audit record, so anything it does per call is multiplied by the +# number of alerting records across every tenant in a fan-out. The tenant resolution it needs is +# identical for every record of a tenant, and Get-Tenants has no in-process cache of its own - +# it reads the tenants table twice, filters through the pipeline and sorts the whole list. These +# tests pin that it is resolved once per tenant rather than once per record, and that the memo +# never serves one tenant's entry to another. + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $FunctionPath = Join-Path $RepoRoot 'Modules/CIPPCore/Public/Webhooks/Invoke-CIPPWebhookProcessing.ps1' + + function Get-CIPPTable { param($TableName) } + function Get-CIPPAzDataTableEntity { param($TableName, $Context, $Filter, $Property, $First) } + function Add-CIPPAzDataTableEntity { param($TableName, $Context, $Entity, [switch]$Force, $OperationType) } + function Get-Tenants { param([switch]$IncludeErrors, [switch]$IncludeAll) } + function New-CIPPAlertTemplate { param($format, $data, $ActionResults, $CIPPURL, $AlertComment, $CustomSubject, $Tenant, $AuditLogLink) } + function Send-CIPPAlert { param($Type, $Title, $HTMLContent, $JSONContent, $TenantFilter, $APIName, $SchemaSource, $InvokingCommand, $AffectedUser) } + function Write-LogMessage { param($API, $tenant, $message, $sev, $LogData) } + + function New-WebhookData { + param([string]$Id = 'rec-1') + [pscustomobject]@{ + Id = $Id + CIPPAction = $null # no actions: keeps these tests on the dispatch path only + CIPPLocationInfo = $null + AuditRecord = '{}' + CIPPCustomSubject = $null + ClientIP = '20.190.144.12' + ObjectId = $null + UserId = 'user1@contoso.com' + Userkey = $null + } + } + + . $FunctionPath +} + +Describe 'Invoke-CippWebhookProcessing' { + + BeforeEach { + # The memo is per tenant and deliberately outlives a call, so it has to be cleared between + # tests or the second test onwards would never invoke its own Get-Tenants mock. + $script:WebhookTenantCache = @{} + $script:TenantCalls = 0 + $script:ClaimedRows = [System.Collections.Generic.List[object]]::new() + $script:ExistingAuditLog = @() + + Mock -CommandName Get-CIPPTable -MockWith { param($TableName) @{ Context = "ctx:$TableName" } } + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { $script:ExistingAuditLog } + Mock -CommandName Add-CIPPAzDataTableEntity -MockWith { + param($TableName, $Context, $Entity, [switch]$Force, $OperationType) + $script:ClaimedRows.Add($Entity) + } + Mock -CommandName Get-Tenants -MockWith { + $script:TenantCalls++ + @( + [pscustomobject]@{ defaultDomainName = 'contoso.com'; customerId = 'cid-contoso' } + [pscustomobject]@{ defaultDomainName = 'fabrikam.com'; customerId = 'cid-fabrikam' } + ) + } + Mock -CommandName New-CIPPAlertTemplate -MockWith { + # One 'Title' key serves both $GenerateJSON.Title and $GenerateEmail.title - property + # access is case-insensitive, and a hash literal rejects the pair as duplicates. + [pscustomobject]@{ + Title = 'alert'; ButtonUrl = 'https://example.invalid'; ButtonText = 'open' + htmlcontent = '

      alert

      ' + } + } + Mock -CommandName Send-CIPPAlert -MockWith { } + Mock -CommandName Write-LogMessage -MockWith { } + } + + Context 'tenant resolution memo' { + + It 'resolves the tenant once across many records for the same tenant' { + foreach ($i in 1..25) { + Invoke-CippWebhookProcessing -TenantFilter 'contoso.com' -Data (New-WebhookData -Id "rec-$i") -CIPPURL 'https://cipp.invalid' + } + $script:TenantCalls | Should -Be 1 + } + + It 'resolves each tenant separately' { + # Serving one tenant's entry to another would put the wrong domain into the alert + # title, body and audit-log link. + Invoke-CippWebhookProcessing -TenantFilter 'contoso.com' -Data (New-WebhookData -Id 'rec-1') -CIPPURL 'https://cipp.invalid' + Invoke-CippWebhookProcessing -TenantFilter 'fabrikam.com' -Data (New-WebhookData -Id 'rec-2') -CIPPURL 'https://cipp.invalid' + $script:TenantCalls | Should -Be 2 + $script:WebhookTenantCache['contoso.com'].Tenant.defaultDomainName | Should -Be 'contoso.com' + $script:WebhookTenantCache['fabrikam.com'].Tenant.defaultDomainName | Should -Be 'fabrikam.com' + } + + It 're-resolves once the entry has expired' { + Invoke-CippWebhookProcessing -TenantFilter 'contoso.com' -Data (New-WebhookData -Id 'rec-1') -CIPPURL 'https://cipp.invalid' + $script:WebhookTenantCache['contoso.com'].Expires = [datetime]::UtcNow.AddMinutes(-1) + Invoke-CippWebhookProcessing -TenantFilter 'contoso.com' -Data (New-WebhookData -Id 'rec-2') -CIPPURL 'https://cipp.invalid' + $script:TenantCalls | Should -Be 2 + } + + It 'caches a miss so an unknown tenant is not re-queried per record' { + foreach ($i in 1..10) { + Invoke-CippWebhookProcessing -TenantFilter 'unknown.com' -Data (New-WebhookData -Id "rec-$i") -CIPPURL 'https://cipp.invalid' + } + $script:TenantCalls | Should -Be 1 + $script:WebhookTenantCache['unknown.com'].Tenant | Should -BeNullOrEmpty + } + + It 'drops expired entries rather than growing per tenant seen' { + Invoke-CippWebhookProcessing -TenantFilter 'contoso.com' -Data (New-WebhookData -Id 'rec-1') -CIPPURL 'https://cipp.invalid' + $script:WebhookTenantCache['contoso.com'].Expires = [datetime]::UtcNow.AddMinutes(-1) + Invoke-CippWebhookProcessing -TenantFilter 'fabrikam.com' -Data (New-WebhookData -Id 'rec-2') -CIPPURL 'https://cipp.invalid' + $script:WebhookTenantCache.Keys | Should -Not -Contain 'contoso.com' + $script:WebhookTenantCache.Keys | Should -Contain 'fabrikam.com' + } + } + + Context 'dedupe' { + + It 'skips a record whose claim is refused' { + # The claim is an Insert without -Force, so a conflict IS the duplicate check. A record + # already claimed - by another worker or an earlier run - fails here and is dropped. + Mock -CommandName Add-CIPPAzDataTableEntity -MockWith { throw 'The specified entity already exists.' } + Invoke-CippWebhookProcessing -TenantFilter 'contoso.com' -Data (New-WebhookData -Id 'rec-1') -CIPPURL 'https://cipp.invalid' + # Returns before resolving the tenant, so a duplicate costs one failed insert and no more. + $script:TenantCalls | Should -Be 0 + Should -Invoke Send-CIPPAlert -Times 0 -Exactly + } + + It 'claims the event before dispatching' { + Invoke-CippWebhookProcessing -TenantFilter 'contoso.com' -Data (New-WebhookData -Id 'rec-1') -CIPPURL 'https://cipp.invalid' + @($script:ClaimedRows)[0].RowKey | Should -Be 'rec-1' + @($script:ClaimedRows)[0].Title | Should -Be 'Processing' + } + + It 'does not read the table before claiming' { + # The read that used to precede the claim answered the same question a round trip + # earlier and could not make it safer - a row can still appear between the two. It cost + # one extra table read per matched record, 28% of the processing stage under load. + Invoke-CippWebhookProcessing -TenantFilter 'contoso.com' -Data (New-WebhookData -Id 'rec-1') -CIPPURL 'https://cipp.invalid' + Should -Invoke Get-CIPPAzDataTableEntity -Times 0 -Exactly + } + } + + Context 'storing the audit log row' { + + It 'writes the row itself when no accumulator is supplied' { + Invoke-CippWebhookProcessing -TenantFilter 'contoso.com' -Data (New-WebhookData -Id 'rec-1') -CIPPURL 'https://cipp.invalid' + # Claim plus the completed row. + $script:ClaimedRows.Count | Should -Be 2 + (@($script:ClaimedRows)[-1]).RowKey | Should -Be 'rec-1' + (@($script:ClaimedRows)[-1]).Data | Should -Not -BeNullOrEmpty + } + + It 'queues the row instead of writing it when an accumulator is supplied' { + $Pending = [System.Collections.Generic.List[object]]::new() + Invoke-CippWebhookProcessing -TenantFilter 'contoso.com' -Data (New-WebhookData -Id 'rec-1') ` + -CIPPURL 'https://cipp.invalid' -PendingAuditLogWrites $Pending + $Pending.Count | Should -Be 1 + $Pending[0].RowKey | Should -Be 'rec-1' + $Pending[0].PartitionKey | Should -Be 'contoso.com' + # Only the claim was written directly. + $script:ClaimedRows.Count | Should -Be 1 + (@($script:ClaimedRows)[0]).Title | Should -Be 'Processing' + } + + It 'dispatches the alert before storing the row' { + # This ordering is the whole point. Storing first meant a crash between the write and + # the send left a complete-looking row for an alert nobody received - and the claim row + # makes a retry skip it, so it is lost silently. Sending first means a crash there + # leaves the alert delivered and only the stored copy missing. + $script:Sequence = [System.Collections.Generic.List[string]]::new() + Mock -CommandName Add-CIPPAzDataTableEntity -MockWith { + param($TableName, $Context, $Entity, [switch]$Force, $OperationType) + $script:Sequence.Add($(if ($Entity.Title -eq 'Processing') { 'claim' } else { 'store' })) + $script:ClaimedRows.Add($Entity) + } + Mock -CommandName Send-CIPPAlert -MockWith { $script:Sequence.Add('send') } + + $Data = New-WebhookData -Id 'rec-1' + $Data.CIPPAction = (ConvertTo-Json -Compress -InputObject @(@{ label = 'Send Webhook'; value = 'generateWebhook' })) + Invoke-CippWebhookProcessing -TenantFilter 'contoso.com' -Data $Data -CIPPURL 'https://cipp.invalid' + + ($script:Sequence -join ',') | Should -Be 'claim,send,store' + } + } + + Context 'alert template rendering' { + + It 'renders the email body only when an action asks for it' { + # Two renders per alert where one was needed: the html body was built for every matched + # record regardless of whether any rule wanted an email. + $Data = New-WebhookData -Id 'rec-1' + $Data.CIPPAction = (ConvertTo-Json -Compress -InputObject @(@{ label = 'Send Webhook'; value = 'generateWebhook' })) + Invoke-CippWebhookProcessing -TenantFilter 'contoso.com' -Data $Data -CIPPURL 'https://cipp.invalid' + Should -Invoke New-CIPPAlertTemplate -Times 1 -Exactly -ParameterFilter { $format -eq 'json' } + Should -Invoke New-CIPPAlertTemplate -Times 0 -Exactly -ParameterFilter { $format -eq 'html' } + } + + It 'still renders the email body when generatemail is requested' { + $Data = New-WebhookData -Id 'rec-1' + $Data.CIPPAction = (ConvertTo-Json -Compress -InputObject @(@{ label = 'Send Mail'; value = 'generatemail' })) + Invoke-CippWebhookProcessing -TenantFilter 'contoso.com' -Data $Data -CIPPURL 'https://cipp.invalid' + Should -Invoke New-CIPPAlertTemplate -Times 1 -Exactly -ParameterFilter { $format -eq 'html' } + Should -Invoke Send-CIPPAlert -Times 1 -Exactly -ParameterFilter { $Type -eq 'email' } + } + } +} diff --git a/backend/Tests/Webhooks/Push-AuditLogDownloadV2.Tests.ps1 b/backend/Tests/Webhooks/Push-AuditLogDownloadV2.Tests.ps1 index 4bdf680d12..a32185b2cb 100644 --- a/backend/Tests/Webhooks/Push-AuditLogDownloadV2.Tests.ps1 +++ b/backend/Tests/Webhooks/Push-AuditLogDownloadV2.Tests.ps1 @@ -33,6 +33,10 @@ Describe 'Push-AuditLogDownloadV2' { BeforeEach { $script:CacheWrites = [System.Collections.Generic.List[object]]::new() + # One entry per Add-CIPPAzDataTableEntity call, holding that call's entities. The download + # stage writes in batches, so the number of calls and the number of rows are different + # things and both are worth pinning: rows for correctness, calls for the batching itself. + $script:CacheWriteBatches = [System.Collections.Generic.List[object]]::new() $script:LedgerWrites = [System.Collections.Generic.List[object]]::new() $script:SearchResults = @() $script:SearchStatus = 'succeeded' @@ -60,8 +64,11 @@ Describe 'Push-AuditLogDownloadV2' { Mock -CommandName Add-CIPPAzDataTableEntity -MockWith { param($TableName, $Context, $Entity, $Force, $OperationType) - if ($TableName -eq 'CacheWebhooks') { $script:CacheWrites.Add($Entity) } - else { $script:LedgerWrites.Add($Entity) } + if ($TableName -eq 'CacheWebhooks') { + $Batch = @($Entity) + $script:CacheWriteBatches.Add($Batch) + foreach ($Row in $Batch) { $script:CacheWrites.Add($Row) } + } else { $script:LedgerWrites.Add($Entity) } } Mock -CommandName New-GraphBulkRequest -MockWith { @@ -90,12 +97,22 @@ Describe 'Push-AuditLogDownloadV2' { $null = Push-AuditLogDownloadV2 -Item @{ TenantFilter = 'contoso.com' } ($script:CacheWrites.RowKey | Sort-Object) | Should -Be @('rec-1', 'rec-2', 'rec-3') $first = $script:CacheWrites | Where-Object { $_.RowKey -eq 'rec-1' } - $first.PartitionKey | Should -Be 'contoso.com' $first.SearchId | Should -Be 'search-1' ($first.JSON | ConvertFrom-Json).id | Should -Be 'rec-1' $first.CippProcessing | Should -BeFalse } + It 'partitions the cache per search, not per tenant' { + # Azure Table only point-looks-up a single PartitionKey+RowKey pair, so a per-tenant + # partition forces the processing stage to select rows with an OR-list of RowKeys - + # which cannot use the index and scans. One partition per search keeps every read in + # the processing path a point-partition query. + $null = Push-AuditLogDownloadV2 -Item @{ TenantFilter = 'contoso.com' } + $first = $script:CacheWrites | Where-Object { $_.RowKey -eq 'rec-1' } + $first.PartitionKey | Should -Be 'contoso.com|search-1' + $first.TenantFilter | Should -Be 'contoso.com' + } + It 'advances the ledger to Downloaded with the record count' { $null = Push-AuditLogDownloadV2 -Item @{ TenantFilter = 'contoso.com' } $ledger = $script:LedgerWrites | Where-Object { $_.RowKey -eq 'window-1' } | Select-Object -Last 1 @@ -111,6 +128,69 @@ Describe 'Push-AuditLogDownloadV2' { } } + Context 'batched cache writes' { + # The stage used to issue one table round trip per record. Every record in a window shares + # the tenant|searchId partition, so they are batchable, and the table service caps a + # transaction at 100 entities sharing a PartitionKey. + + It 'writes a 250-record window in 3 calls, not 250' { + $script:SearchResults = @(1..250 | ForEach-Object { New-AuditRecord "rec-$_" }) + $null = Push-AuditLogDownloadV2 -Item @{ TenantFilter = 'contoso.com' } + $script:CacheWrites.Count | Should -Be 250 + $script:CacheWriteBatches.Count | Should -Be 3 + } + + It 'never exceeds the 100-entity transaction limit' { + $script:SearchResults = @(1..250 | ForEach-Object { New-AuditRecord "rec-$_" }) + $null = Push-AuditLogDownloadV2 -Item @{ TenantFilter = 'contoso.com' } + foreach ($Batch in $script:CacheWriteBatches) { @($Batch).Count | Should -BeLessOrEqual 100 } + } + + It 'flushes a trailing partial batch' { + # 120 records is one full batch plus a remainder; without the post-loop flush the last + # 20 would be counted as downloaded and the window marked Downloaded, but never written + # - the search would then settle with rows that do not exist. + $script:SearchResults = @(1..120 | ForEach-Object { New-AuditRecord "rec-$_" }) + $result = Push-AuditLogDownloadV2 -Item @{ TenantFilter = 'contoso.com' } + $script:CacheWrites.Count | Should -Be 120 + $script:CacheWriteBatches.Count | Should -Be 2 + $result.Downloaded | Should -Be 120 + ($script:CacheWrites.RowKey | Sort-Object -Unique).Count | Should -Be 120 + } + + It 'keeps every batch within a single partition' { + # A transaction spanning two PartitionKeys is rejected outright, so a buffer shared + # across windows would fail the whole download rather than degrade. + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { + @( + [pscustomobject]@{ PartitionKey = 'contoso.com'; RowKey = 'window-1'; State = 'Created'; SearchId = 'search-1'; CreatedUtc = (Get-Date).ToUniversalTime().AddMinutes(-5).ToString('o'); Attempts = 0; RetryCount = 0 } + [pscustomobject]@{ PartitionKey = 'contoso.com'; RowKey = 'window-2'; State = 'Created'; SearchId = 'search-2'; CreatedUtc = (Get-Date).ToUniversalTime().AddMinutes(-5).ToString('o'); Attempts = 0; RetryCount = 0 } + ) + } + Mock -CommandName New-GraphBulkRequest -MockWith { + @( + [pscustomobject]@{ body = [pscustomobject]@{ id = 'search-1'; status = 'succeeded' } } + [pscustomobject]@{ body = [pscustomobject]@{ id = 'search-2'; status = 'succeeded' } } + ) + } + # Deliberately not a multiple of the flush size, so window 1 ends mid-buffer and a + # buffer that outlived the window would carry rows into window 2's batch. + Mock -CommandName Get-CippAuditLogSearchResults -MockWith { + param($TenantFilter, $QueryId, [switch]$CountOnly) + 1..150 | ForEach-Object { New-AuditRecord "$QueryId-rec-$_" } + } + + $null = Push-AuditLogDownloadV2 -Item @{ TenantFilter = 'contoso.com' } + + $script:CacheWrites.Count | Should -Be 300 + foreach ($Batch in $script:CacheWriteBatches) { + (@($Batch).PartitionKey | Sort-Object -Unique).Count | Should -Be 1 + } + (@($script:CacheWrites | Where-Object { $_.PartitionKey -eq 'contoso.com|search-1' }).Count) | Should -Be 150 + (@($script:CacheWrites | Where-Object { $_.PartitionKey -eq 'contoso.com|search-2' }).Count) | Should -Be 150 + } + } + Context 'succeeded search with no records' { BeforeEach { $script:SearchResults = @() } diff --git a/backend/Tests/Webhooks/Push-AuditLogProcessingBatchV2.Tests.ps1 b/backend/Tests/Webhooks/Push-AuditLogProcessingBatchV2.Tests.ps1 new file mode 100644 index 0000000000..e2287075ce --- /dev/null +++ b/backend/Tests/Webhooks/Push-AuditLogProcessingBatchV2.Tests.ps1 @@ -0,0 +1,168 @@ +# Pester tests for Push-AuditLogProcessingBatchV2 - the audit log V2 batch builder. +# +# Pins the claim semantics, which now operate at SEARCH granularity. The builder claims +# AuditLogCoverage rows, not CacheWebhooks rows: the old per-record claim read the tenant's entire +# cache partition and wrote once per record, which on a 50k-record tenant cost minutes of +# bookkeeping before a single record was examined. One write per search does the same job. +# +# The stamp must still UPDATE, never upsert. An upsert on a row that ledger retention removed +# mid-loop recreates it as a stateless shell that re-enters every cycle forever. + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $FunctionPath = Join-Path $RepoRoot 'Modules/CIPPCore/Public/Webhooks/Push-AuditLogProcessingBatchV2.ps1' + + function Get-CippTable { param($TableName) } + function Get-CIPPAzDataTableEntity { param($Context, $Filter, $Property) } + function Update-CIPPAzDataTableEntity { param($Context, $Entity, $OperationType, [switch]$Force) } + function Add-CIPPAzDataTableEntity { param($Context, $Entity, [switch]$Force, $OperationType) } + function New-CippQueueEntry { param($Name, $Reference, $TotalTasks) } + + . $FunctionPath +} + +Describe 'Push-AuditLogProcessingBatchV2' { + + BeforeEach { + $script:Stamped = [System.Collections.Generic.List[string]]::new() + $script:VanishedRowKey = $null + $script:CacheReadFilters = [System.Collections.Generic.List[string]]::new() + + $script:LedgerRows = @( + [PSCustomObject]@{ PartitionKey = 'contoso.com'; RowKey = 'window-1'; SearchId = 'search-1'; State = 'Downloaded'; RecordCount = 10; Timestamp = [DateTimeOffset]::UtcNow } + [PSCustomObject]@{ PartitionKey = 'contoso.com'; RowKey = 'window-2'; SearchId = 'search-2'; State = 'Downloaded'; RecordCount = 20; Timestamp = [DateTimeOffset]::UtcNow } + ) + # Legacy pre-partitioning rows; empty on any instance that has cycled once. + $script:LegacyCacheRows = @() + + Mock -CommandName Get-CippTable -MockWith { param($TableName) @{ Context = "ctx:$TableName" } } + + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { + param($Context, $Filter, $Property) + if ($Context -like '*AuditLogCoverage*') { return $script:LedgerRows } + $script:CacheReadFilters.Add([string]$Filter) + return $script:LegacyCacheRows + } + + # Mirrors the table service: an update on a row that no longer exists fails. + Mock -CommandName Update-CIPPAzDataTableEntity -MockWith { + param($Context, $Entity, $OperationType, [switch]$Force) + foreach ($Stamp in @($Entity)) { + if ($script:VanishedRowKey -and $Stamp.RowKey -eq $script:VanishedRowKey) { + throw 'The specified resource does not exist.' + } + $script:Stamped.Add([string]$Stamp.RowKey) + } + } + + Mock -CommandName Add-CIPPAzDataTableEntity -MockWith { + throw 'Add-CIPPAzDataTableEntity must not be used for claim stamps - an upsert resurrects deleted rows' + } + + Mock -CommandName New-CippQueueEntry -MockWith { [PSCustomObject]@{ RowKey = 'queue-1' } } + } + + Context 'claim granularity' { + + It 'claims the ledger, never the individual cache records' { + # The change this file exists to protect: one write per search, not per record. + $null = Push-AuditLogProcessingBatchV2 -Item @{ TenantFilter = 'contoso.com' } + ($script:Stamped | Sort-Object) | Should -Be @('window-1', 'window-2') + Should -Invoke Add-CIPPAzDataTableEntity -Times 0 + } + + It 'writes once per search regardless of how many records it holds' { + $script:LedgerRows = @( + [PSCustomObject]@{ PartitionKey = 'contoso.com'; RowKey = 'window-1'; SearchId = 'search-1'; State = 'Downloaded'; RecordCount = 50000; Timestamp = [DateTimeOffset]::UtcNow } + ) + $null = Push-AuditLogProcessingBatchV2 -Item @{ TenantFilter = 'contoso.com' } + Should -Invoke Update-CIPPAzDataTableEntity -Times 1 -Exactly + } + + It 'marks claimed searches as Processing' { + $null = Push-AuditLogProcessingBatchV2 -Item @{ TenantFilter = 'contoso.com' } + Should -Invoke Update-CIPPAzDataTableEntity -Times 2 -Exactly -ParameterFilter { + $Entity.State -eq 'Processing' + } + } + + It 'skips freshly claimed searches and reclaims stale ones' { + $script:LedgerRows = @( + [PSCustomObject]@{ PartitionKey = 'contoso.com'; RowKey = 'fresh'; SearchId = 's-fresh'; State = 'Processing'; RecordCount = 1; Timestamp = [DateTimeOffset]::UtcNow } + [PSCustomObject]@{ PartitionKey = 'contoso.com'; RowKey = 'stale'; SearchId = 's-stale'; State = 'Processing'; RecordCount = 1; Timestamp = [DateTimeOffset]::UtcNow.AddHours(-3) } + [PSCustomObject]@{ PartitionKey = 'contoso.com'; RowKey = 'ready'; SearchId = 's-ready'; State = 'Downloaded'; RecordCount = 1; Timestamp = [DateTimeOffset]::UtcNow } + ) + $Batches = @(Push-AuditLogProcessingBatchV2 -Item @{ TenantFilter = 'contoso.com' }) + ($Batches.WindowRowKey | Sort-Object) | Should -Be @('ready', 'stale') + $script:Stamped | Should -Not -Contain 'fresh' + } + + It 'ignores states that are not ready for processing' { + $script:LedgerRows = @( + [PSCustomObject]@{ PartitionKey = 'contoso.com'; RowKey = 'planned'; SearchId = 's1'; State = 'Planned'; RecordCount = 0; Timestamp = [DateTimeOffset]::UtcNow } + [PSCustomObject]@{ PartitionKey = 'contoso.com'; RowKey = 'created'; SearchId = 's2'; State = 'Created'; RecordCount = 0; Timestamp = [DateTimeOffset]::UtcNow } + [PSCustomObject]@{ PartitionKey = 'contoso.com'; RowKey = 'done'; SearchId = 's3'; State = 'Processed'; RecordCount = 5; Timestamp = [DateTimeOffset]::UtcNow } + ) + @(Push-AuditLogProcessingBatchV2 -Item @{ TenantFilter = 'contoso.com' }).Count | Should -Be 0 + Should -Invoke Update-CIPPAzDataTableEntity -Times 0 + } + + It 'skips a window with no SearchId rather than emitting an unusable batch item' { + $script:LedgerRows = @( + [PSCustomObject]@{ PartitionKey = 'contoso.com'; RowKey = 'no-search'; SearchId = $null; State = 'Downloaded'; RecordCount = 0; Timestamp = [DateTimeOffset]::UtcNow } + ) + @(Push-AuditLogProcessingBatchV2 -Item @{ TenantFilter = 'contoso.com' }).Count | Should -Be 0 + } + + It 'keeps claiming when one window vanishes mid-loop' { + $script:VanishedRowKey = 'window-1' + $Batches = @(Push-AuditLogProcessingBatchV2 -Item @{ TenantFilter = 'contoso.com' }) + # The vanished row fails quietly and is not batched; the survivor still is. + $Batches.WindowRowKey | Should -Be @('window-2') + Should -Invoke Add-CIPPAzDataTableEntity -Times 0 + } + } + + Context 'batch construction' { + + It 'emits one batch item per search, carrying the ids the activity needs' { + $Batches = @(Push-AuditLogProcessingBatchV2 -Item @{ TenantFilter = 'contoso.com' }) + @($Batches).Count | Should -Be 2 + ($Batches.SearchId | Sort-Object) | Should -Be @('search-1', 'search-2') + $Batches.FunctionName | Should -Be @('AuditLogTenantProcessV2', 'AuditLogTenantProcessV2') + $Batches.QueueId | Should -Be @('queue-1', 'queue-1') + } + + It 'resolves the tenant from Parameters when nested' { + $Batches = @(Push-AuditLogProcessingBatchV2 -Item ([PSCustomObject]@{ Parameters = @{ TenantFilter = 'contoso.com' } })) + @($Batches).Count | Should -Be 2 + $Batches[0].TenantFilter | Should -Be 'contoso.com' + } + + It 'returns nothing when no searches are claimable' { + $script:LedgerRows = @() + @(Push-AuditLogProcessingBatchV2 -Item @{ TenantFilter = 'contoso.com' }).Count | Should -Be 0 + Should -Invoke Update-CIPPAzDataTableEntity -Times 0 + } + } + + Context 'legacy rows written before per-search partitioning' { + + It 'batches leftover rows from the old tenant partition' { + $script:LedgerRows = @() + $script:LegacyCacheRows = @( + 1..750 | ForEach-Object { [PSCustomObject]@{ PartitionKey = 'contoso.com'; RowKey = ('old-{0:D3}' -f $_) } } + ) + $Batches = @(Push-AuditLogProcessingBatchV2 -Item @{ TenantFilter = 'contoso.com' }) + @($Batches).Count | Should -Be 2 + @($Batches[0].LegacyRowIds).Count | Should -Be 500 + @($Batches[1].LegacyRowIds).Count | Should -Be 250 + } + + It 'reads the legacy partition by key only, never pulling JSON payloads' { + $script:LegacyCacheRows = @([PSCustomObject]@{ PartitionKey = 'contoso.com'; RowKey = 'old-1' }) + $null = Push-AuditLogProcessingBatchV2 -Item @{ TenantFilter = 'contoso.com' } + $script:CacheReadFilters | Should -Contain "PartitionKey eq 'contoso.com'" + } + } +} diff --git a/backend/Tests/Webhooks/Push-AuditLogTenantProcessV2.Tests.ps1 b/backend/Tests/Webhooks/Push-AuditLogTenantProcessV2.Tests.ps1 index 6bb87ac4c2..770e893a87 100644 --- a/backend/Tests/Webhooks/Push-AuditLogTenantProcessV2.Tests.ps1 +++ b/backend/Tests/Webhooks/Push-AuditLogTenantProcessV2.Tests.ps1 @@ -1,18 +1,27 @@ # Pester tests for Push-AuditLogTenantProcessV2 - the audit log V2 processing stage. # -# Pins the ledger transitions and the rows handed to the rules engine. The cases that -# matter are split records, stored across rows X / X-part1 / X-part2 and only reassembled -# when every part is fetched in one call. +# One batch item is one SearchId, which is one CacheWebhooks partition (tenant|searchId). The +# invariant these tests exist to protect is that the read stays a SINGLE PARTITION QUERY: an +# OR-list of RowKeys, or a filter on the non-key SearchId column, cannot be served from the Azure +# Table index and degenerates into a partition scan, which is what made processing cost scale with +# the tenant's total backlog rather than the search's own size. +# +# Split records - stored across rows X / X-part1 / X-part2 and only reassembled when every part is +# fetched in one call - still matter, but a partition read gets every part by construction. BeforeAll { $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) - $FunctionPath = Join-Path $RepoRoot 'Modules/CIPPCore/Public/Webhooks/Push-AuditLogTenantProcessV2.ps1' + $WebhookDir = Join-Path $RepoRoot 'Modules/CIPPCore/Public/Webhooks' function Get-CippTable { param($TableName) } function Get-AzDataTableEntity { param($Context, $Filter, $Property, $First) } function Get-CIPPAzDataTableEntity { param($Context, $Filter, $Property, $First) } function Add-CIPPAzDataTableEntity { param($Context, $Entity, [switch]$Force, $OperationType) } - function Test-CIPPAuditLogRules { param($TenantFilter, $Rows) } + function Remove-CIPPAzDataTableEntity { param($Context, $Entity, [switch]$Force) } + # The plain delete, used by the post-loop partition sweep. Distinct from the CIPP wrapper: the + # wrapper also removes the -partN rows of split entities, which is what the sweep replaces. + function Remove-AzDataTableEntity { param($Context, $Entity, [switch]$Force) } + function Test-CIPPAuditLogRules { param($TenantFilter, $Rows, $CachePartitionKey, [switch]$CallerSweepsCachePartition) } function Get-WantedFromFilter { param([string]$Filter) @@ -20,16 +29,24 @@ BeforeAll { if ($ids) { @($ids) } else { $null } } - . $FunctionPath + # Real helpers, not mocks: the point-write settle and the quarantined legacy read are part of + # what these tests are pinning. + . (Join-Path $WebhookDir 'Set-CippAuditLogWindowProcessed.ps1') + . (Join-Path $WebhookDir 'Get-CippAuditLogLegacyCacheRow.ps1') + . (Join-Path $WebhookDir 'Push-AuditLogTenantProcessV2.ps1') } Describe 'Push-AuditLogTenantProcessV2' { BeforeEach { $script:RulesRows = $null - # AllRulesRows accumulates across chunks; RulesRows holds only the last chunk. + # AllRulesRows accumulates across slices; RulesRows holds only the last slice. $script:AllRulesRows = [System.Collections.Generic.List[object]]::new() $script:SeenFilters = [System.Collections.Generic.List[string]]::new() + # Reads issued by the paging loop only, so assertions about how the search is read are not + # perturbed by the post-loop sweep, which legitimately queries the same partition again. + $script:PagingFilters = [System.Collections.Generic.List[string]]::new() + $script:SweptRows = [System.Collections.Generic.List[object]]::new() $script:LedgerWrites = [System.Collections.Generic.List[object]]::new() $script:MatchedLogs = 2 @@ -41,8 +58,7 @@ Describe 'Push-AuditLogTenantProcessV2' { $script:LedgerRows = @( [PSCustomObject]@{ PartitionKey = 'contoso.com'; RowKey = 'window-1'; SearchId = 'search-1'; State = 'Downloaded' } ) - $script:RemainingAfterProcess = @() - $script:DownloadedSweepRows = @() + $script:RemovedRows = [System.Collections.Generic.List[object]]::new() # Real Get-CippTable returns only @{ Context = ... }; mirror that so splatting @Table # behaves as it does in production. @@ -52,27 +68,26 @@ Describe 'Push-AuditLogTenantProcessV2' { param($Context, $Filter, $Property, $First) $script:SeenFilters.Add([string]$Filter) $rows = $script:CacheRows - if ($Filter -match "RowKey gt '([^']*)'") { $rows = @($rows | Where-Object { $_.RowKey -gt $Matches[1] }) } $wanted = Get-WantedFromFilter -Filter $Filter if ($wanted) { $rows = @($rows | Where-Object { $wanted -contains $_.RowKey }) } - $rows = @($rows | Sort-Object RowKey) - if ($First) { $rows = @($rows | Select-Object -First $First) } - $rows | ForEach-Object { - [PSCustomObject]@{ PartitionKey = 'contoso.com'; RowKey = $_.RowKey; OriginalEntityId = $_.OriginalEntityId } + # Echo back the partition that was queried rather than a fixed one. This mock serves + # both the legacy read (tenant partition) and the post-loop sweep (tenant|searchId), and + # the caller deletes using the PartitionKey it gets back - so a hardcoded value would + # have the sweep issuing deletes against the wrong partition and still passing. + $partition = if ($Filter -match "PartitionKey eq '([^']*)'") { $Matches[1] } else { 'contoso.com' } + @($rows | Sort-Object RowKey) | ForEach-Object { + [PSCustomObject]@{ PartitionKey = $partition; RowKey = $_.RowKey; OriginalEntityId = $_.OriginalEntityId } } } Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { param($Context, $Filter, $Property, $First) - if ($Context -like '*AuditLogCoverage*') { - if ($Filter -match "State eq 'Downloaded'") { return $script:DownloadedSweepRows } - return $script:LedgerRows - } - # CacheWebhooks. A SearchId query is the "is this search drained yet" probe. - if ($Filter -match "SearchId eq") { return $script:RemainingAfterProcess } + $script:SeenFilters.Add([string]$Filter) + if ($Context -like '*AuditLogCoverage*') { return $script:LedgerRows } + $script:PagingFilters.Add([string]$Filter) - # Selected by RowKey (simple records) or OriginalEntityId (every part of a split - # record, regardless of which parts this batch claimed). + # CacheWebhooks. A partition query carries no RowKey/OriginalEntityId predicates and + # therefore returns the whole partition - which is the point of the layout. $wantedRow = Get-WantedFromFilter -Filter $Filter $wantedOrig = @([regex]::Matches($Filter, "OriginalEntityId eq '([^']*)'") | ForEach-Object { $_.Groups[1].Value }) $rows = if ($wantedRow -or $wantedOrig) { @@ -84,15 +99,27 @@ Describe 'Push-AuditLogTenantProcessV2' { @($script:CacheRows) } # rejoin parts sharing a logical id, exactly like the real wrapper - $rows | Group-Object { if ($_.OriginalEntityId) { $_.OriginalEntityId } else { $_.RowKey } } | ForEach-Object { - $ordered = @($_.Group | Sort-Object RowKey) - [PSCustomObject]@{ - PartitionKey = 'contoso.com' - RowKey = $_.Name - SearchId = $ordered[0].SearchId - JSON = ($ordered.JSON -join '') - } + $merged = @($rows | Group-Object { if ($_.OriginalEntityId) { $_.OriginalEntityId } else { $_.RowKey } } | ForEach-Object { + $ordered = @($_.Group | Sort-Object RowKey) + [PSCustomObject]@{ + PartitionKey = 'contoso.com|search-1' + RowKey = $_.Name + SearchId = $ordered[0].SearchId + JSON = ($ordered.JSON -join '') + } + }) + + # Honour the paging contract, or the multi-page test silently exercises nothing: + # rows come back in RowKey order, `RowKey gt` skips what the cursor has passed, and + # -First caps the page. A mock that ignores these makes an infinite paging loop look + # like a passing test. + $merged = @($merged | Sort-Object RowKey) + if ($Filter -match "RowKey gt '([^']*)'") { + $after = $Matches[1] + $merged = @($merged | Where-Object { $_.RowKey -gt $after }) } + if ($First) { $merged = @($merged | Select-Object -First $First) } + $merged } Mock -CommandName Add-CIPPAzDataTableEntity -MockWith { @@ -100,57 +127,140 @@ Describe 'Push-AuditLogTenantProcessV2' { $script:LedgerWrites.Add($Entity) } + Mock -CommandName Remove-CIPPAzDataTableEntity -MockWith { + param($Context, $Entity, [switch]$Force) + foreach ($Removed in @($Entity)) { $script:RemovedRows.Add($Removed) } + } + + Mock -CommandName Remove-AzDataTableEntity -MockWith { + param($Context, $Entity, [switch]$Force) + foreach ($Removed in @($Entity)) { $script:SweptRows.Add($Removed) } + } + Mock -CommandName Test-CIPPAuditLogRules -MockWith { param($TenantFilter, $Rows) $script:RulesRows = @($Rows) foreach ($r in @($Rows)) { $script:AllRulesRows.Add($r) } [PSCustomObject]@{ MatchedLogs = $script:MatchedLogs } } + + $script:Item = @{ TenantFilter = 'contoso.com'; SearchId = 'search-1'; WindowRowKey = 'window-1' } } - Context 'simple single-row records' { + Context 'reading one search' { It 'passes every cached record to the rules engine' { - $null = Push-AuditLogTenantProcessV2 -Item @{ TenantFilter = 'contoso.com'; RowIds = @('rec-1', 'rec-2') } + $null = Push-AuditLogTenantProcessV2 -Item $script:Item @($script:RulesRows).Count | Should -Be 2 ($script:RulesRows.id | Sort-Object) | Should -Be @('rec-1', 'rec-2') } It 'deserialises the cached JSON before handing it over' { - $null = Push-AuditLogTenantProcessV2 -Item @{ TenantFilter = 'contoso.com'; RowIds = @('rec-1') } - @($script:RulesRows)[0].id | Should -Be 'rec-1' + $null = Push-AuditLogTenantProcessV2 -Item $script:Item + @($script:RulesRows)[0].id | Should -Not -BeNullOrEmpty } It 'returns true on success' { - Push-AuditLogTenantProcessV2 -Item @{ TenantFilter = 'contoso.com'; RowIds = @('rec-1', 'rec-2') } | Should -BeTrue + Push-AuditLogTenantProcessV2 -Item $script:Item | Should -BeTrue } - It 'marks the ledger window Processed once the search is drained' { - $null = Push-AuditLogTenantProcessV2 -Item @{ TenantFilter = 'contoso.com'; RowIds = @('rec-1', 'rec-2') } + It 'reads the search partition, never an OR-list of RowKeys' { + # The whole point of the layout. An OR-list cannot use the index and scans the + # partition, which is what made cost scale with the tenant's backlog. + $null = Push-AuditLogTenantProcessV2 -Item $script:Item + $cacheFilters = @($script:SeenFilters | Where-Object { $_ -notmatch 'State eq' }) + $cacheFilters | Should -Contain "PartitionKey eq 'contoso.com|search-1'" + ($cacheFilters -join ' ') | Should -Not -Match "RowKey eq '" + } + + It 'never probes the cache by SearchId' { + # SearchId is not a key; filtering on it is a partition scan. The batch item carries + # the window RowKey precisely so this lookup is unnecessary. + $null = Push-AuditLogTenantProcessV2 -Item $script:Item + ($script:SeenFilters -join ' ') | Should -Not -Match 'SearchId eq' + } + } + + Context 'settling the ledger window' { + + It 'marks the window Processed with the matched count' { + $null = Push-AuditLogTenantProcessV2 -Item $script:Item $processed = $script:LedgerWrites | Where-Object { $_.State -eq 'Processed' -and $_.RowKey -eq 'window-1' } $processed | Should -Not -BeNullOrEmpty $processed.MatchedCount | Should -Be 2 } - It 'leaves the window alone while cache rows for the search remain' { - $script:RemainingAfterProcess = @([PSCustomObject]@{ PartitionKey = 'contoso.com'; RowKey = 'rec-9' }) - $null = Push-AuditLogTenantProcessV2 -Item @{ TenantFilter = 'contoso.com'; RowIds = @('rec-1') } - ($script:LedgerWrites | Where-Object { $_.RowKey -eq 'window-1' }) | Should -BeNullOrEmpty + It 'addresses the window by RowKey rather than searching for it' { + $null = Push-AuditLogTenantProcessV2 -Item $script:Item + $processed = @($script:LedgerWrites | Where-Object { $_.RowKey -eq 'window-1' }) + $processed.Count | Should -Be 1 + $processed[0].PartitionKey | Should -Be 'contoso.com' + } + + It 'settles an already-drained search instead of reporting failure' { + # A retry after a crash between draining the rows and settling the window. The work + # is done, so this is success, not an error to be retried forever. + $script:CacheRows = @() + Push-AuditLogTenantProcessV2 -Item $script:Item | Should -BeTrue + ($script:LedgerWrites | Where-Object { $_.RowKey -eq 'window-1' -and $_.State -eq 'Processed' }) | + Should -Not -BeNullOrEmpty + Should -Invoke Test-CIPPAuditLogRules -Times 0 + } + + It 'returns the window to Downloaded when processing throws' { + Mock -CommandName Test-CIPPAuditLogRules -MockWith { throw 'boom' } + Push-AuditLogTenantProcessV2 -Item $script:Item | Should -BeFalse + ($script:LedgerWrites | Where-Object { $_.RowKey -eq 'window-1' -and $_.State -eq 'Downloaded' }) | + Should -Not -BeNullOrEmpty + } + } + + Context 'a malformed batch item' { + It 'returns false when given neither a SearchId nor legacy row ids' { + Push-AuditLogTenantProcessV2 -Item @{ TenantFilter = 'contoso.com' } | Should -BeFalse + Should -Invoke Test-CIPPAuditLogRules -Times 0 } } - Context 'no rows found for the batch' { - BeforeEach { $script:CacheRows = @() } + Context 'unparseable cache rows (ghosts)' { + BeforeEach { + # A ghost row: a shell with no JSON, as left behind by an upserting claim stamp racing + # a delete. It must be removed, not skipped in place - skipped rows re-enter every + # claim cycle and keep the tenant's processing loop alive forever. + $script:CacheRows = @( + [PSCustomObject]@{ RowKey = 'rec-1'; OriginalEntityId = $null; SearchId = 'search-1'; JSON = '{"id":"rec-1"}' } + [PSCustomObject]@{ RowKey = 'ghost-1'; OriginalEntityId = $null; SearchId = $null; JSON = $null } + [PSCustomObject]@{ RowKey = 'garbled-1'; OriginalEntityId = $null; SearchId = 'search-1'; JSON = '{not json' } + ) + } + + It 'deletes rows whose JSON cannot be parsed' { + $null = Push-AuditLogTenantProcessV2 -Item $script:Item + ($script:RemovedRows.RowKey | Sort-Object) | Should -Be @('garbled-1', 'ghost-1') + } - It 'returns false without invoking the rules engine' { - Push-AuditLogTenantProcessV2 -Item @{ TenantFilter = 'contoso.com'; RowIds = @('gone-1') } | Should -BeFalse + It 'still processes the parseable rows of the same search' { + $null = Push-AuditLogTenantProcessV2 -Item $script:Item + @($script:RulesRows).Count | Should -Be 1 + @($script:RulesRows)[0].id | Should -Be 'rec-1' + } + + It 'deletes ghosts even when every row is unparseable' { + $script:CacheRows = @( + [PSCustomObject]@{ RowKey = 'ghost-1'; OriginalEntityId = $null; SearchId = $null; JSON = $null } + [PSCustomObject]@{ RowKey = 'ghost-2'; OriginalEntityId = $null; SearchId = $null; JSON = $null } + ) + $null = Push-AuditLogTenantProcessV2 -Item $script:Item + ($script:RemovedRows.RowKey | Sort-Object) | Should -Be @('ghost-1', 'ghost-2') Should -Invoke Test-CIPPAuditLogRules -Times 0 } } Context 'a record split across multiple rows' { BeforeEach { - # One logical record stored across three physical rows. + # One logical record stored across three physical rows. All parts share the search's + # partition, so a partition read gets every part in one call by construction - the + # two-phase OriginalEntityId lookup the old per-RowKey read needed is unnecessary. $script:CacheRows = @( [PSCustomObject]@{ RowKey = 'rec-1'; OriginalEntityId = $null; SearchId = 'search-1'; JSON = '{"id":"rec-1"}' } [PSCustomObject]@{ RowKey = 'big'; OriginalEntityId = 'big'; SearchId = 'search-1'; JSON = '{"id":"big","pad":"AAA' } @@ -160,74 +270,136 @@ Describe 'Push-AuditLogTenantProcessV2' { } It 'reassembles the split record into valid JSON' { - # Fetching one RowKey at a time leaves the reassembler with a fragment. - $null = Push-AuditLogTenantProcessV2 -Item @{ TenantFilter = 'contoso.com'; RowIds = @('rec-1', 'big', 'big-part1', 'big-part2') } + $null = Push-AuditLogTenantProcessV2 -Item $script:Item $big = @($script:RulesRows) | Where-Object { $_.id -eq 'big' } $big | Should -Not -BeNullOrEmpty $big.pad | Should -Be 'AAABBBCCC' } It 'yields the split record exactly once, not once per physical row' { - $null = Push-AuditLogTenantProcessV2 -Item @{ TenantFilter = 'contoso.com'; RowIds = @('rec-1', 'big', 'big-part1', 'big-part2') } + $null = Push-AuditLogTenantProcessV2 -Item $script:Item @($script:RulesRows).Count | Should -Be 2 @($script:RulesRows | Where-Object { $_.id -eq 'big' }).Count | Should -Be 1 } } - Context 'a batch larger than one chunk' { + Context 'a search larger than one slice' { BeforeEach { - # 250 rows against a chunk size of 100 forces three passes. Smaller fixtures only + # 1200 rows against a slice size of 500 forces three passes. Smaller fixtures only # execute the loop body once, hiding any off-by-one in the slice arithmetic. $script:CacheRows = @( - 1..250 | ForEach-Object { + 1..1200 | ForEach-Object { [PSCustomObject]@{ - RowKey = ('rec-{0:D3}' -f $_) + RowKey = ('rec-{0:D4}' -f $_) OriginalEntityId = $null SearchId = 'search-1' - JSON = ('{{"id":"rec-{0:D3}"}}' -f $_) + JSON = ('{{"id":"rec-{0:D4}"}}' -f $_) } } ) } - It 'processes every row across all chunks exactly once' { - $ids = @(1..250 | ForEach-Object { 'rec-{0:D3}' -f $_ }) - $null = Push-AuditLogTenantProcessV2 -Item @{ TenantFilter = 'contoso.com'; RowIds = $ids } - @($script:AllRulesRows).Count | Should -Be 250 - (@($script:AllRulesRows).id | Sort-Object -Unique).Count | Should -Be 250 + It 'processes every row across all slices exactly once' { + $null = Push-AuditLogTenantProcessV2 -Item $script:Item + @($script:AllRulesRows).Count | Should -Be 1200 + (@($script:AllRulesRows).id | Sort-Object -Unique).Count | Should -Be 1200 } - It 'invokes the rules engine once per chunk, not once per batch' { - $ids = @(1..250 | ForEach-Object { 'rec-{0:D3}' -f $_ }) - $null = Push-AuditLogTenantProcessV2 -Item @{ TenantFilter = 'contoso.com'; RowIds = $ids } - # 250 / 100 = 3 calls. This is what bounds peak memory - the whole batch is - # never resident at once. + It 'invokes the rules engine once per slice, not once per record' { + # 1200 / 500 = 3 calls. Slicing is what bounds peak parsed memory; calling per record + # would instead re-read the rule configuration 1200 times. + $null = Push-AuditLogTenantProcessV2 -Item $script:Item Should -Invoke Test-CIPPAuditLogRules -Times 3 -Exactly } - It 'never builds a filter long enough to trip the request size limit' { - # Azure rejects ~27kb of filter with HTTP 414 and Azurite ~13kb with HTTP 431, - # and the outer catch swallows both. Stay well inside the stricter one. - $ids = @(1..250 | ForEach-Object { 'rec-{0:D3}' -f $_ }) - $null = Push-AuditLogTenantProcessV2 -Item @{ TenantFilter = 'contoso.com'; RowIds = $ids } - $worst = ($script:SeenFilters | Measure-Object -Property Length -Maximum).Maximum - $worst | Should -BeLessThan 11000 + It 'still reads the search with a single query regardless of size' { + # Paging reads only. The post-loop sweep queries the same partition again by design, + # and counting it here would hide a genuine re-read of page one. + $null = Push-AuditLogTenantProcessV2 -Item $script:Item + @($script:PagingFilters | Where-Object { $_ -eq "PartitionKey eq 'contoso.com|search-1'" }).Count | + Should -Be 1 } } - Context 'orphaned Downloaded windows' { - BeforeEach { - $script:DownloadedSweepRows = @( - [PSCustomObject]@{ PartitionKey = 'contoso.com'; RowKey = 'orphan-1'; SearchId = 'search-orphan'; State = 'Downloaded' } - ) + Context 'partition sweep after processing' { + # The rule engine is told the caller sweeps, which lets it drop processed rows with the + # plain delete rather than the part-aware one - ~2.7x cheaper per row, and 37% of this + # stage. That trade is only sound if the sweep actually runs and actually clears the + # partition, so both halves are pinned here. + + It 'tells the rules engine the caller sweeps' { + $null = Push-AuditLogTenantProcessV2 -Item $script:Item + Should -Invoke Test-CIPPAuditLogRules -Times 1 -Exactly -ParameterFilter { + $CallerSweepsCachePartition -eq $true -and $CachePartitionKey -eq 'contoso.com|search-1' + } + } + + It 'removes whatever is left in the partition afterwards' { + # The engine is mocked and deletes nothing, so every seeded row is still there when the + # sweep runs - standing in for the -partN rows the plain delete leaves behind. + $null = Push-AuditLogTenantProcessV2 -Item $script:Item + ($script:SweptRows.RowKey | Sort-Object) | Should -Be @('rec-1', 'rec-2') } - It 'sweeps a Downloaded window whose search has no cache rows left' { - $null = Push-AuditLogTenantProcessV2 -Item @{ TenantFilter = 'contoso.com'; RowIds = @('rec-1') } - $swept = $script:LedgerWrites | Where-Object { $_.RowKey -eq 'orphan-1' } - $swept | Should -Not -BeNullOrEmpty - $swept.State | Should -Be 'Processed' - $swept.MatchedCount | Should -Be 0 + It 'sweeps the search partition, not the tenant partition' { + $null = Push-AuditLogTenantProcessV2 -Item $script:Item + ($script:SweptRows.PartitionKey | Sort-Object -Unique) | Should -Be @('contoso.com|search-1') + } + + It 'does not sweep for legacy batches' { + # Legacy rows share the tenant partition with every other search, so a sweep there + # would delete records belonging to searches this batch never processed. + $null = Push-AuditLogTenantProcessV2 -Item @{ + TenantFilter = 'contoso.com'; LegacyRowIds = @('rec-1', 'rec-2') + } + $script:SweptRows.Count | Should -Be 0 + } + + It 'does not sweep when paging stopped because the cursor stalled' { + # A stalled cursor means the range predicate was not honoured and rows may never have + # reached the rule engine. Deleting them would drop records with no alert ever fired - + # strictly worse than leaving them for the next cycle. + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { + param($Context, $Filter, $Property, $First) + $script:SeenFilters.Add([string]$Filter) + if ($Context -like '*AuditLogCoverage*') { return $script:LedgerRows } + # Always the same page, whatever the cursor says. A FULL page (500 = the slice + # size) is required to reach the guard at all: a short page is treated as the last + # one and the loop exits normally before any cursor check happens. + @(1..500 | ForEach-Object { + [PSCustomObject]@{ + PartitionKey = 'contoso.com|search-1'; RowKey = ('rec-{0:D4}' -f $_) + SearchId = 'search-1'; JSON = ('{{"id":"rec-{0:D4}"}}' -f $_) + } + }) + } + $null = Push-AuditLogTenantProcessV2 -Item $script:Item + $script:SweptRows.Count | Should -Be 0 + } + } + + Context 'legacy rows written before per-search partitioning' { + It 'still processes rows addressed by id from the old tenant partition' { + $null = Push-AuditLogTenantProcessV2 -Item @{ + TenantFilter = 'contoso.com'; LegacyRowIds = @('rec-1', 'rec-2') + } + @($script:RulesRows).Count | Should -Be 2 + } + + It 'keeps the legacy filter short enough to stay inside the request size limit' { + # Azure rejects ~27kb of filter with HTTP 414 and Azurite ~13kb with HTTP 431, and the + # outer catch swallows both. Only the legacy path builds filters this way. + $script:CacheRows = @( + 1..250 | ForEach-Object { + [PSCustomObject]@{ + RowKey = ('rec-{0:D3}' -f $_); OriginalEntityId = $null + SearchId = 'search-1'; JSON = ('{{"id":"rec-{0:D3}"}}' -f $_) + } + } + ) + $ids = @(1..250 | ForEach-Object { 'rec-{0:D3}' -f $_ }) + $null = Push-AuditLogTenantProcessV2 -Item @{ TenantFilter = 'contoso.com'; LegacyRowIds = $ids } + ($script:SeenFilters | Measure-Object -Property Length -Maximum).Maximum | Should -BeLessThan 11000 } } } diff --git a/backend/Tests/Webhooks/Test-CIPPAuditLogRules.Tests.ps1 b/backend/Tests/Webhooks/Test-CIPPAuditLogRules.Tests.ps1 index 707ef2a6d3..e8670f782f 100644 --- a/backend/Tests/Webhooks/Test-CIPPAuditLogRules.Tests.ps1 +++ b/backend/Tests/Webhooks/Test-CIPPAuditLogRules.Tests.ps1 @@ -15,13 +15,15 @@ BeforeAll { function Get-AzDataTableEntity { param($TableName, $Context, $Filter, $Property, $First) } function Add-CIPPAzDataTableEntity { param($TableName, $Context, $Entity, [switch]$Force, $OperationType) } function Remove-CIPPAzDataTableEntity { param($TableName, $Context, $Entity, [switch]$Force) } + # The plain delete, taken when the caller guarantees it sweeps the cache partition itself. + function Remove-AzDataTableEntity { param($TableName, $Context, $Entity, [switch]$Force) } function Expand-CIPPTenantGroups { param($TenantFilter) } function Test-CIPPConditionFilter { param($Condition) } function Invoke-CippWebhookProcessing { param($Data, $CIPPURL, $TenantFilter, $AlertComment) } function Get-CIPPGeoIPLocationBatch { param($IPs) } function Write-LogMessage { param($API, $tenant, $message, $sev, $LogData) } function Get-CippException { param($Exception) [pscustomobject]@{ NormalizedError = "$Exception" } } - function New-CIPPDbRequest { param($TenantFilter, $Type, $Endpoint) } + function Get-CIPPTestData { param($TenantFilter, $Type, $Fields, [switch]$NoProjection) } function New-GraphBulkRequest { param($Requests, $AsApp, $TenantId) } function New-GraphGetRequest { param($uri, $tenantid, $AsApp, [switch]$Stream, $ComplexFilter, $NoPagination) } function Add-CIPPApplicationPermission { param($RequiredResourceAccess, $ApplicationId, $TenantFilter) } @@ -69,6 +71,14 @@ BeforeAll { Describe 'Test-CIPPAuditLogRules record shaping' { BeforeEach { + # Both memos are per tenant and outlive a single call by design. Every test here uses the + # same tenant, so without a reset the second test onwards would run against the first + # test's rules and directory data and never touch its own mocked reads. + $script:AuditRuleConfigCache = @{} + $script:AuditRuleLookupCache = @{} + $script:AuditRuleListCache = @{} + $script:PartnerUserMemo = $null + Mock -CommandName Get-CIPPTable -MockWith { param($TableName) @{ TableName = $TableName } @@ -139,7 +149,19 @@ Describe 'Test-CIPPAuditLogRules record shaping' { foreach ($e in @($Entity)) { $script:RemovedRows.Add($e) } } - Mock -CommandName Expand-CIPPTenantGroups -MockWith { [pscustomobject]@{ value = @('AllTenants') } } + $script:PlainRemovedRows = [System.Collections.Generic.List[object]]::new() + Mock -CommandName Remove-AzDataTableEntity -MockWith { + param($TableName, $Context, $Entity, [switch]$Force) + foreach ($e in @($Entity)) { $script:PlainRemovedRows.Add($e) } + } + + # Counted rather than asserted with -Times, so the memo tests compare against however many + # rule entries the fixture happens to have instead of hard-coding one. + $script:ExpandCalls = 0 + Mock -CommandName Expand-CIPPTenantGroups -MockWith { + $script:ExpandCalls++ + [pscustomobject]@{ value = @('AllTenants') } + } # Always-true predicate; rule matching is not what these tests cover. Mock -CommandName Test-CIPPConditionFilter -MockWith { '$_.Operation -eq ''Set-Mailbox''' } Mock -CommandName Invoke-CippWebhookProcessing -MockWith { } @@ -148,7 +170,7 @@ Describe 'Test-CIPPAuditLogRules record shaping' { # would win and silently capture nothing. Mock -CommandName Get-CIPPGeoIPLocationBatch -MockWith { @{} } Mock -CommandName Write-LogMessage -MockWith { } - Mock -CommandName New-CIPPDbRequest -MockWith { @() } + Mock -CommandName Get-CIPPTestData -MockWith { @() } Mock -CommandName New-GraphBulkRequest -MockWith { @() } Mock -CommandName New-GraphGetRequest -MockWith { @() } } @@ -262,6 +284,145 @@ Describe 'Test-CIPPAuditLogRules record shaping' { } } + Context 'rule configuration memo' { + # Resolving the rule set reads the whole WebhookRules table and expands tenant groups for + # every surviving entry - 179 ms per invocation, paid once per slice, for an answer that + # does not change between slices. + + It 'resolves the rule set once across repeated calls for a tenant' { + $null = Test-CIPPAuditLogRules -TenantFilter 'contoso.com' -Rows @(New-AuditRow -Id 'rec-1') + $AfterFirst = $script:ExpandCalls + $AfterFirst | Should -BeGreaterThan 0 + + $null = Test-CIPPAuditLogRules -TenantFilter 'contoso.com' -Rows @(New-AuditRow -Id 'rec-2') + $null = Test-CIPPAuditLogRules -TenantFilter 'contoso.com' -Rows @(New-AuditRow -Id 'rec-3') + $script:ExpandCalls | Should -Be $AfterFirst + } + + It 'resolves separately for a different tenant' { + # The rule set is filtered by tenant, so one tenant's answer must never be served to + # another - that would evaluate these records against a different customer's rules. + $null = Test-CIPPAuditLogRules -TenantFilter 'contoso.com' -Rows @(New-AuditRow -Id 'rec-1') + $AfterFirst = $script:ExpandCalls + $null = Test-CIPPAuditLogRules -TenantFilter 'fabrikam.com' -Rows @(New-AuditRow -Id 'rec-2') + $script:ExpandCalls | Should -BeGreaterThan $AfterFirst + } + + It 'rebuilds once the entry has expired' { + $null = Test-CIPPAuditLogRules -TenantFilter 'contoso.com' -Rows @(New-AuditRow -Id 'rec-1') + $AfterFirst = $script:ExpandCalls + $script:AuditRuleConfigCache['contoso.com'].Expires = [datetime]::UtcNow.AddMinutes(-1) + $null = Test-CIPPAuditLogRules -TenantFilter 'contoso.com' -Rows @(New-AuditRow -Id 'rec-2') + $script:ExpandCalls | Should -BeGreaterThan $AfterFirst + } + + It 'drops expired entries rather than growing per tenant seen' { + $null = Test-CIPPAuditLogRules -TenantFilter 'contoso.com' -Rows @(New-AuditRow -Id 'rec-1') + $script:AuditRuleConfigCache['contoso.com'].Expires = [datetime]::UtcNow.AddMinutes(-1) + $null = Test-CIPPAuditLogRules -TenantFilter 'fabrikam.com' -Rows @(New-AuditRow -Id 'rec-2') + $script:AuditRuleConfigCache.Keys | Should -Not -Contain 'contoso.com' + $script:AuditRuleConfigCache.Keys | Should -Contain 'fabrikam.com' + } + } + + Context 'directory lookup memo' { + # The four directory hash tables are rebuilt from cached JSON blobs on every call - 95 ms + # per invocation, and the engine runs once per 500-record slice. + + It 'rebuilds the hashtables once across repeated calls for a tenant' { + $null = Test-CIPPAuditLogRules -TenantFilter 'contoso.com' -Rows @(New-AuditRow -Id 'rec-1') + $script:AuditRuleLookupCache.Keys | Should -Contain 'contoso.com' + + # A second call must not re-read the lookups table for this tenant. + $script:LookupReads = 0 + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { + param($TableName, $Context, $Filter, $Property, $First) + if ($Filter -like "*PartitionKey eq 'contoso.com'*" -and $Filter -like '*Timestamp gt*') { $script:LookupReads++ } + @() + } + $null = Test-CIPPAuditLogRules -TenantFilter 'contoso.com' -Rows @(New-AuditRow -Id 'rec-2') + $script:LookupReads | Should -Be 0 + } + + It 'keeps each tenant''s directory data separate' { + # Serving one tenant's user/group/device map to another would resolve GUIDs to the + # wrong people and put their names into another customer's alerts. + $null = Test-CIPPAuditLogRules -TenantFilter 'contoso.com' -Rows @(New-AuditRow -Id 'rec-1') + $null = Test-CIPPAuditLogRules -TenantFilter 'fabrikam.com' -Rows @(New-AuditRow -Id 'rec-2') + $script:AuditRuleLookupCache.Keys | Should -Contain 'contoso.com' + $script:AuditRuleLookupCache.Keys | Should -Contain 'fabrikam.com' + } + + It 'rebuilds once the entry has expired' { + $null = Test-CIPPAuditLogRules -TenantFilter 'contoso.com' -Rows @(New-AuditRow -Id 'rec-1') + $script:AuditRuleLookupCache['contoso.com'].Expires = [datetime]::UtcNow.AddMinutes(-1) + + $script:LookupReads = 0 + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { + param($TableName, $Context, $Filter, $Property, $First) + if ($Filter -like "*PartitionKey eq 'contoso.com'*" -and $Filter -like '*Timestamp gt*') { $script:LookupReads++ } + @() + } + $null = Test-CIPPAuditLogRules -TenantFilter 'contoso.com' -Rows @(New-AuditRow -Id 'rec-2') + $script:LookupReads | Should -BeGreaterThan 0 + } + } + + Context 'cache cleanup when the caller sweeps the partition' { + # V2 owns one cache partition per search and clears it after processing, so the engine is + # told it may take the cheap route. Both halves of that are pinned: the plain delete for + # processed rows, and skipping the id-resolution pass entirely. + + It 'uses the plain delete, not the part-aware one' { + # Remove-CIPPAzDataTableEntity also removes the -partN rows of split entities and costs + # ~2.7x per row for it. The caller's sweep covers those instead. 150 rows so a full + # 100-row batch actually flushes. + $null = Test-CIPPAuditLogRules -TenantFilter 'contoso.com' ` + -Rows @(1..150 | ForEach-Object { New-AuditRow -Id "rec-$_" }) ` + -CachePartitionKey 'contoso.com|search-1' -CallerSweepsCachePartition + Should -Invoke Remove-AzDataTableEntity -Times 1 -Exactly + Should -Invoke Remove-CIPPAzDataTableEntity -Times 0 -Exactly + } + + It 'skips the OR-list resolution pass' { + # That pass builds "RowKey eq X or OriginalEntityId eq X" 50 ids at a time. An OR-list + # cannot use the table index, so each slice scans the partition - ten scans per call to + # find rows the flush has already deleted. + $null = Test-CIPPAuditLogRules -TenantFilter 'contoso.com' -Rows @(New-AuditRow -Id 'rec-1') ` + -CachePartitionKey 'contoso.com|search-1' -CallerSweepsCachePartition + Should -Invoke Get-AzDataTableEntity -Times 0 -Exactly + } + + It 'deletes each full batch as it fills' { + $null = Test-CIPPAuditLogRules -TenantFilter 'contoso.com' ` + -Rows @(1..150 | ForEach-Object { New-AuditRow -Id "rec-$_" }) ` + -CachePartitionKey 'contoso.com|search-1' -CallerSweepsCachePartition + @($script:PlainRemovedRows).Count | Should -Be 100 + @($script:PlainRemovedRows).RowKey | Should -Contain 'rec-1' + @($script:PlainRemovedRows).PartitionKey | Should -Contain 'contoso.com|search-1' + } + + It 'leaves the trailing partial batch to the sweep' { + # The remainder below the flush size is deliberately not deleted here. The caller reads + # its partition and removes whatever is left, so flushing the tail separately would be + # a round trip to delete rows the sweep is about to delete anyway. Without a sweep + # (the V1 path) the tail is still flushed - covered below. + $null = Test-CIPPAuditLogRules -TenantFilter 'contoso.com' -Rows @(New-AuditRow -Id 'rec-1') ` + -CachePartitionKey 'contoso.com|search-1' -CallerSweepsCachePartition + Should -Invoke Remove-AzDataTableEntity -Times 0 -Exactly + Should -Invoke Remove-CIPPAzDataTableEntity -Times 0 -Exactly + } + + It 'leaves the part-aware path in place for callers that do not sweep' { + # V1 shares one partition per tenant and never sweeps, so it must keep paying for the + # part-row guarantee. Twice, not once: the per-record flush and the id-resolution pass + # both delete, and both stay on the part-aware cmdlet. + $null = Test-CIPPAuditLogRules -TenantFilter 'contoso.com' -Rows @(New-AuditRow -Id 'rec-1') + Should -Invoke Remove-AzDataTableEntity -Times 0 -Exactly + Should -Invoke Remove-CIPPAzDataTableEntity -Times 2 -Exactly + } + } + Context 'cache cleanup after processing' { It 'reads only key columns, not the JSON payloads' { @@ -321,16 +482,16 @@ Describe 'Test-CIPPAuditLogRules record shaping' { # The reason deletes are not deferred to the end: if a record kills the worker, # everything already flushed is gone from the cache, so the retry starts further # in and the run converges instead of looping on the same rows forever. - $rows = @(1..60 | ForEach-Object { New-AuditRow -Id "rec-$_" }) + $rows = @(1..250 | ForEach-Object { New-AuditRow -Id "rec-$_" }) $script:PhysicalCacheRows = @( - 1..60 | ForEach-Object { [pscustomobject]@{ PartitionKey = 'contoso.com'; RowKey = "rec-$_"; OriginalEntityId = $null } } + 1..250 | ForEach-Object { [pscustomobject]@{ PartitionKey = 'contoso.com'; RowKey = "rec-$_"; OriginalEntityId = $null } } ) $null = Test-CIPPAuditLogRules -TenantFilter 'contoso.com' -Rows $rows - # 60 records at a flush size of 25: two mid-loop flushes, a remainder flush, - # and the sweep - not 60 individual calls. + # 250 records at a flush size of 100 (the table service's per-transaction maximum): + # two mid-loop flushes, a remainder flush, and the sweep - not 250 individual calls. Should -Invoke Remove-CIPPAzDataTableEntity -Times 4 -Exactly - @($script:RemovedRows).RowKey.Count | Should -Be 120 # 60 flushed + 60 swept + @($script:RemovedRows).RowKey.Count | Should -Be 500 # 250 flushed + 250 swept } It 'never removes a cached row belonging to another record' { From 8b5f033bd74ff074668b3511605e8dbc73641360 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Fri, 14 Aug 2026 10:13:10 -0400 Subject: [PATCH 038/226] feat: seed simple-mode rules from a built-in role template Co-Authored-By: Claude Fable 5 --- .../CippSettings/CippRoleAddEdit.jsx | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/frontend/src/components/CippSettings/CippRoleAddEdit.jsx b/frontend/src/components/CippSettings/CippRoleAddEdit.jsx index 9b0a5d9ed1..f853bc49a9 100644 --- a/frontend/src/components/CippSettings/CippRoleAddEdit.jsx +++ b/frontend/src/components/CippSettings/CippRoleAddEdit.jsx @@ -97,6 +97,18 @@ export const CippRoleAddEdit = ({ selectedRole }) => { const ipRanges = useWatch({ control: formControl.control, name: "IPRange" }); const includeRules = useWatch({ control: formControl.control, name: "PermissionRulesInclude" }); const excludeRules = useWatch({ control: formControl.control, name: "PermissionRulesExclude" }); + const baseRoleTemplate = useWatch({ control: formControl.control, name: "BaseRoleTemplate" }); + + // "Start from a built-in role": copy its patterns into the rule fields as an + // editable starting point, then clear the picker so it acts as a one-shot action. + useEffect(() => { + const roleName = baseRoleTemplate?.value; + if (!roleName || !cippRoles[roleName]) return; + const toOptions = (list) => (list || []).map((pattern) => ({ label: pattern, value: pattern })); + formControl.setValue("PermissionRulesInclude", toOptions(cippRoles[roleName].include)); + formControl.setValue("PermissionRulesExclude", toOptions(cippRoles[roleName].exclude)); + formControl.setValue("BaseRoleTemplate", null); + }, [baseRoleTemplate]); const { data: apiPermissions = [], @@ -892,6 +904,25 @@ export const CippRoleAddEdit = ({ selectedRole }) => { Simple mode will replace the role's permissions with the patterns below. )} + ({ + label: `${role} — include: ${cippRoles[role].include.join(", ") || "none"}${ + cippRoles[role].exclude.length + ? `, exclude: ${cippRoles[role].exclude.join(", ")}` + : "" + }`, + value: role, + }))} + formControl={formControl} + fullWidth={true} + multiple={false} + creatable={false} + helperText="Replaces the patterns below with the selected role's include/exclude rules — edit them freely afterwards." + /> Date: Fri, 14 Aug 2026 12:04:38 -0400 Subject: [PATCH 039/226] feat(auth): add role impersonation for testing cipp roles Allows superadmins to impersonate any role to preview CIPP as that role sees it. - Backend: `Resolve-CippImpersonation` validates and swaps the user context before all access checks; audit logged once per worker per user+role pair - Frontend: `impersonation.js` manages state in localStorage, injects `x-cipp-impersonate-role` header on every API call, and busts the Craft cache via `_imp` query param - `CippImpersonationBanner` renders a fixed warning bar with an Exit button that clears caches and reloads - Impersonate action added to the Roles table (superadmins only, superadmin role excluded) - Pester tests for `Resolve-CippImpersonation`; Vitest tests for `impersonation.js` --- .../Authentication/Get-CIPPAccessRole.ps1 | 6 +- .../Resolve-CippImpersonation.ps1 | 72 +++++++++++ .../Public/Authentication/Test-CIPPAccess.ps1 | 37 +++++- .../HTTP Functions/New-CippCoreRequest.ps1 | 12 +- .../Resolve-CippImpersonation.Tests.ps1 | 95 ++++++++++++++ frontend/src/api/ApiCall.jsx | 7 +- .../CippImpersonationBanner.jsx | 103 +++++++++++++++ .../src/components/CippSettings/CippRoles.jsx | 32 ++++- frontend/src/layouts/index.js | 2 + frontend/src/utils/cippVersion.js | 5 + frontend/src/utils/impersonation.js | 75 +++++++++++ frontend/tests/utils/impersonation.test.js | 119 ++++++++++++++++++ 12 files changed, 554 insertions(+), 11 deletions(-) create mode 100644 backend/Modules/CIPPCore/Public/Authentication/Resolve-CippImpersonation.ps1 create mode 100644 backend/Tests/Private/Resolve-CippImpersonation.Tests.ps1 create mode 100644 frontend/src/components/CippComponents/CippImpersonationBanner.jsx create mode 100644 frontend/src/utils/impersonation.js create mode 100644 frontend/tests/utils/impersonation.test.js diff --git a/backend/Modules/CIPPCore/Public/Authentication/Get-CIPPAccessRole.ps1 b/backend/Modules/CIPPCore/Public/Authentication/Get-CIPPAccessRole.ps1 index 799aae7c44..7440118bc1 100644 --- a/backend/Modules/CIPPCore/Public/Authentication/Get-CIPPAccessRole.ps1 +++ b/backend/Modules/CIPPCore/Public/Authentication/Get-CIPPAccessRole.ps1 @@ -28,8 +28,8 @@ function Get-CIPPAccessRole { $CachedRoles = Get-CIPPAzDataTableEntity @CacheAccessUserRoleTable -Filter "PartitionKey eq 'AccessUser' and RowKey eq '$Username'" | Select-Object -ExpandProperty Role | ConvertFrom-Json - Write-Information "SWA Roles: $($SwaRoles -join ', ')" - Write-Information "Cached Roles: $($CachedRoles -join ', ')" + Write-Debug "SWA Roles: $($SwaRoles -join ', ')" + Write-Debug "Cached Roles: $($CachedRoles -join ', ')" # Combine SWA roles and cached roles into a single deduplicated list $AllRoles = [System.Collections.Generic.List[string]]::new() @@ -47,6 +47,6 @@ function Get-CIPPAccessRole { $CombinedRoles = $AllRoles | Select-Object -Unique # For debugging - Write-Information "Combined Roles: $($CombinedRoles -join ', ')" + Write-Debug "Combined Roles: $($CombinedRoles -join ', ')" return $CombinedRoles } diff --git a/backend/Modules/CIPPCore/Public/Authentication/Resolve-CippImpersonation.ps1 b/backend/Modules/CIPPCore/Public/Authentication/Resolve-CippImpersonation.ps1 new file mode 100644 index 0000000000..e544deab23 --- /dev/null +++ b/backend/Modules/CIPPCore/Public/Authentication/Resolve-CippImpersonation.ps1 @@ -0,0 +1,72 @@ +function Resolve-CippImpersonation { + <# + .SYNOPSIS + Superadmin-only role impersonation for interactive requests. + .DESCRIPTION + When a real superadmin sends x-cipp-impersonate-role, returns a replacement user + object holding only that role, so every downstream check (IP ranges, /me, base and + custom role evaluation, tenant scoping) sees the impersonated role. Anyone else's + header is ignored, so the swap can only ever narrow privileges. Cheap and + deterministic on purpose: it runs up to three times per request. + .PARAMETER User + The decoded x-ms-client-principal user object. + .PARAMETER Request + The HTTP request (headers are read for the impersonation target and audit logging). + #> + [CmdletBinding()] + param($User, $Request) + + $Result = [pscustomobject]@{ + User = $User + Impersonating = $null + RealRoles = @($User.userRoles | Where-Object { $_ -notin @('anonymous', 'authenticated') }) + } + $Target = $Request.Headers.'x-cipp-impersonate-role' + if ([string]::IsNullOrWhiteSpace($Target)) { return $Result } + + # Only a real superadmin may impersonate; everyone else is a silent no-op. + if (@($User.userRoles) -notcontains 'superadmin') { + Write-Warning "Ignoring impersonation header from non-superadmin principal '$($User.userDetails)'" + return $Result + } + + # Role RowKeys are stored lowercased (Invoke-ExecCustomRole). + $Target = $Target.Trim().ToLower() + if ($Target -eq 'superadmin') { + throw 'Impersonating the superadmin role is not allowed' + } + + # Base roles skip the table read; custom roles must exist. Fail closed: a deleted role + # must not silently restore superadmin while the UI banner still claims impersonation. + if ($Target -notin @('readonly', 'editor', 'admin')) { + try { + $null = Get-CIPPRolePermissions -RoleName $Target + } catch { + throw "Impersonation target role '$Target' does not exist" + } + } + + # Build a FRESH object: Test-CIPPAccessUserRole caches the roles array per worker by + # reference, so mutating $User.userRoles would poison the real user's cached roles. + # authenticated/anonymous stay because downstream default-role filtering expects them. + $Result.User = [pscustomobject]@{ + identityProvider = $User.identityProvider + userId = $User.userId + userDetails = $User.userDetails + userRoles = @('authenticated', 'anonymous', $Target) + } + $Result.Impersonating = $Target + + # Audit once per worker per (user, role); per-request would flood CippLogs. Deliberately + # per-worker state - do not reset per request. Write-LogMessage resolves the REAL + # username from the principal headers regardless of the swap. + if (-not $script:CippImpersonationLogged) { $script:CippImpersonationLogged = @{} } + $AuditKey = '{0}|{1}' -f $User.userDetails, $Target + if (-not $script:CippImpersonationLogged.ContainsKey($AuditKey)) { + $script:CippImpersonationLogged[$AuditKey] = $true + Write-LogMessage -headers $Request.Headers -API 'Impersonation' -Sev 'Info' ` + -message "Superadmin '$($User.userDetails)' is impersonating role '$Target'" ` + -LogData @{ ImpersonatedRole = $Target; RealRoles = $Result.RealRoles } + } + return $Result +} diff --git a/backend/Modules/CIPPCore/Public/Authentication/Test-CIPPAccess.ps1 b/backend/Modules/CIPPCore/Public/Authentication/Test-CIPPAccess.ps1 index c85843371a..6b97add75c 100644 --- a/backend/Modules/CIPPCore/Public/Authentication/Test-CIPPAccess.ps1 +++ b/backend/Modules/CIPPCore/Public/Authentication/Test-CIPPAccess.ps1 @@ -8,6 +8,12 @@ function Test-CIPPAccess { $AccessTimings = @{} $AccessTotalSw = [System.Diagnostics.Stopwatch]::StartNew() + # Request-local identity context, read by New-CippCoreRequest for its per-request + # access log line. Reset here so a denied call never reports the previous caller. + $script:CippAccessUserContext = $null + # Request-local impersonation marker; reset so it never leaks between requests. + $script:CippImpersonation = $null + # Get function help $FunctionName = 'Invoke-{0}' -f $Request.Params.CIPPEndpoint @@ -76,6 +82,11 @@ function Test-CIPPAccess { $Client = Get-CippApiClient -AppId $Request.Headers.'x-ms-client-principal-name' if ($Client) { Write-Information "API Access: AppName=$($Client.AppName), AppId=$($Request.Headers.'x-ms-client-principal-name'), IP=$IPAddress" + # Set before the IP check so an IP-range denial is still attributed to the client + $script:CippAccessUserContext = [PSCustomObject]@{ + User = "$($Client.AppName) ($IPAddress)" + Roles = @($Client.Role ?? 'cipp-api') + } $IPMatched = $false if ($Client.IPRange -notcontains 'Any') { foreach ($Range in $Client.IPRange) { @@ -113,6 +124,10 @@ function Test-CIPPAccess { } else { $CustomRoles = @('cipp-api') Write-Information "API Access: AppId=$($Request.Headers.'x-ms-client-principal-name'), IP=$IPAddress" + $script:CippAccessUserContext = [PSCustomObject]@{ + User = "AppId $($Request.Headers.'x-ms-client-principal-name') ($IPAddress)" + Roles = @('cipp-api') + } } if ($Request.Params.CIPPEndpoint -eq 'me') { $Permissions = Get-CippAllowedPermissions -UserRoles $CustomRoles @@ -161,6 +176,21 @@ function Test-CIPPAccess { if (-not $User.userRoles) { throw 'Access denied: unable to resolve roles for the authenticated principal' } + + # Superadmin-only role impersonation; the swap sits before the IP check and the /me + # short-circuit deliberately, so the impersonated role's IP ranges apply and /me + # reports the impersonated permission set. Exit is client-side, so a role whose IP + # ranges lock the superadmin out is always escapable. + $Impersonation = Resolve-CippImpersonation -User $User -Request $Request + $User = $Impersonation.User + if ($Impersonation.Impersonating) { + $script:CippImpersonation = $Impersonation + } + + $script:CippAccessUserContext = [PSCustomObject]@{ + User = if ($Impersonation.Impersonating) { "$($User.userDetails) (impersonating $($Impersonation.Impersonating))" } else { $User.userDetails } + Roles = @($User.userRoles | Where-Object { $_ -notin @('anonymous', 'authenticated') }) + } $AllowedIPRanges = Get-CIPPRoleIPRanges -Roles $User.userRoles if ($AllowedIPRanges -notcontains 'Any') { @@ -224,6 +254,12 @@ function Test-CIPPAccess { 'clientPrincipal' = $User 'permissions' = @($Permissions) } + if ($script:CippImpersonation) { + # The frontend banner needs these to render the exit affordance even when + # the impersonated role has almost no permissions. + $MeResponse['impersonating'] = $script:CippImpersonation.Impersonating + $MeResponse['realUserRoles'] = @($script:CippImpersonation.RealRoles) + } # Hosted payment status checks — shown to all users (no permission gating) if ($env:cipp_hosted_subscription_ended) { @@ -334,7 +370,6 @@ function Test-CIPPAccess { # Check base role permissions before continuing to custom roles if ($null -ne $BaseRole) { - Write-Information "Base Role: $($BaseRole.Name)" $BaseRoleAllowed = $false foreach ($Include in $BaseRole.Value.include) { if ($APIRole -like $Include) { diff --git a/backend/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/New-CippCoreRequest.ps1 b/backend/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/New-CippCoreRequest.ps1 index 27d561b4b9..84e4198b18 100644 --- a/backend/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/New-CippCoreRequest.ps1 +++ b/backend/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/New-CippCoreRequest.ps1 @@ -27,7 +27,7 @@ function New-CippCoreRequest { } $FunctionName = 'Invoke-{0}' -f $Request.Params.CIPPEndpoint - Write-Information "API Endpoint: $($Request.Params.CIPPEndpoint) | Frontend Version: $($Request.Headers.'X-CIPP-Version' ?? 'Not specified')" + Write-Debug "API Endpoint: $($Request.Params.CIPPEndpoint) | Frontend Version: $($Request.Headers.'X-CIPP-Version' ?? 'Not specified')" # For now, while we're in read-only we force the role of the MCP API cred. # When we remove the feature flag, in NG, we move this to use the users role/ident. @@ -122,8 +122,14 @@ function New-CippCoreRequest { Write-Debug "#### HTTP Request Timings #### $($HttpTimingsRounded | ConvertTo-Json -Compress)" return $Access } + # One access line per real endpoint call; /me returns above and the + # scope lookups below stay silent. Context is set by Test-CIPPAccess. + if ($script:CippAccessUserContext) { + Write-Information "Access: $($script:CippAccessUserContext.User) [$($script:CippAccessUserContext.Roles -join ', ')] -> $($Request.Params.CIPPEndpoint)" + } } catch { - Write-Information "Access denied for $FunctionName : $($_.Exception.Message)" + $DeniedUser = if ($script:CippAccessUserContext) { " for user $($script:CippAccessUserContext.User) [$($script:CippAccessUserContext.Roles -join ', ')]" } else { '' } + Write-Information "Access denied for $FunctionName$($DeniedUser) : $($_.Exception.Message)" $HttpTotalStopwatch.Stop() $HttpTimings['Total'] = $HttpTotalStopwatch.Elapsed.TotalMilliseconds $HttpTimingsRounded = [ordered]@{} @@ -162,7 +168,7 @@ function New-CippCoreRequest { } try { - Write-Information "Access: $Access" + Write-Debug "Access: $Access" Write-LogMessage -headers $Headers -API $Request.Params.CIPPEndpoint -message 'Accessed this API' -Sev 'Debug' if ($Access) { # Prepare telemetry metadata for HTTP API call diff --git a/backend/Tests/Private/Resolve-CippImpersonation.Tests.ps1 b/backend/Tests/Private/Resolve-CippImpersonation.Tests.ps1 new file mode 100644 index 0000000000..18a5a8a0c4 --- /dev/null +++ b/backend/Tests/Private/Resolve-CippImpersonation.Tests.ps1 @@ -0,0 +1,95 @@ +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + + function Get-CIPPRolePermissions { param($RoleName) } + function Write-LogMessage { param($message, $tenant, $API, $headers, $sev, $LogData) } + + . (Join-Path $RepoRoot 'Modules/CIPPCore/Public/Authentication/Resolve-CippImpersonation.ps1') + + $script:MakeUser = { + param($Roles) + [pscustomobject]@{ + identityProvider = 'aad' + userId = '00000000-0000-0000-0000-000000000001' + userDetails = 'superadmin@test.local' + userRoles = $Roles + } + } + $script:MakeRequest = { + param($Role) + [pscustomobject]@{ Headers = [pscustomobject]@{ 'x-cipp-impersonate-role' = $Role } } + } +} + +Describe 'Resolve-CippImpersonation' { + BeforeEach { + Mock Write-LogMessage {} + Mock Get-CIPPRolePermissions { [pscustomobject]@{ Role = $RoleName } } + # per-worker audit dedupe must not leak between tests + $script:CippImpersonationLogged = $null + } + + It 'returns the original user when no header is present' { + $User = & $script:MakeUser @('anonymous', 'authenticated', 'superadmin') + $Result = Resolve-CippImpersonation -User $User -Request ([pscustomobject]@{ Headers = [pscustomobject]@{} }) + $Result.Impersonating | Should -BeNullOrEmpty + $Result.User | Should -Be $User + } + + It 'ignores the header for non-superadmins (no privilege change, no throw)' { + $User = & $script:MakeUser @('anonymous', 'authenticated', 'editor') + $Result = Resolve-CippImpersonation -User $User -Request (& $script:MakeRequest 'admin') -WarningAction SilentlyContinue + $Result.Impersonating | Should -BeNullOrEmpty + $Result.User.userRoles | Should -Be @('anonymous', 'authenticated', 'editor') + Should -Invoke Write-LogMessage -Times 0 + } + + It 'swaps a superadmin to a base role without touching the original object' { + $OriginalRoles = @('anonymous', 'authenticated', 'superadmin') + $User = & $script:MakeUser $OriginalRoles + $Result = Resolve-CippImpersonation -User $User -Request (& $script:MakeRequest 'readonly') + + $Result.Impersonating | Should -Be 'readonly' + $Result.User.userRoles | Should -Be @('authenticated', 'anonymous', 'readonly') + $Result.RealRoles | Should -Be @('superadmin') + # Reference safety: the cached roles array of the real user must be untouched. + $User.userRoles | Should -Be $OriginalRoles + # Base roles skip the table read entirely. + Should -Invoke Get-CIPPRolePermissions -Times 0 + } + + It 'throws when the target is superadmin' { + $User = & $script:MakeUser @('anonymous', 'authenticated', 'superadmin') + { Resolve-CippImpersonation -User $User -Request (& $script:MakeRequest 'superadmin') } | + Should -Throw '*not allowed*' + } + + It 'validates custom roles via Get-CIPPRolePermissions and swaps on success' { + $User = & $script:MakeUser @('anonymous', 'authenticated', 'superadmin') + $Result = Resolve-CippImpersonation -User $User -Request (& $script:MakeRequest 'helpdesk') + $Result.Impersonating | Should -Be 'helpdesk' + Should -Invoke Get-CIPPRolePermissions -Times 1 -ParameterFilter { $RoleName -eq 'helpdesk' } + } + + It 'fails closed for a nonexistent role' { + Mock Get-CIPPRolePermissions { throw 'Role nope not found.' } + $User = & $script:MakeUser @('anonymous', 'authenticated', 'superadmin') + { Resolve-CippImpersonation -User $User -Request (& $script:MakeRequest 'nope') } | + Should -Throw '*does not exist*' + } + + It 'normalizes case and whitespace in the target' { + $User = & $script:MakeUser @('anonymous', 'authenticated', 'superadmin') + $Result = Resolve-CippImpersonation -User $User -Request (& $script:MakeRequest ' Editor ') + $Result.Impersonating | Should -Be 'editor' + } + + It 'writes the audit row once per user+role, again for a different role' { + $User = & $script:MakeUser @('anonymous', 'authenticated', 'superadmin') + $null = Resolve-CippImpersonation -User $User -Request (& $script:MakeRequest 'readonly') + $null = Resolve-CippImpersonation -User $User -Request (& $script:MakeRequest 'readonly') + Should -Invoke Write-LogMessage -Times 1 + $null = Resolve-CippImpersonation -User $User -Request (& $script:MakeRequest 'editor') + Should -Invoke Write-LogMessage -Times 2 + } +} diff --git a/frontend/src/api/ApiCall.jsx b/frontend/src/api/ApiCall.jsx index 579161a822..1a74d97210 100644 --- a/frontend/src/api/ApiCall.jsx +++ b/frontend/src/api/ApiCall.jsx @@ -4,6 +4,7 @@ import { useDispatch } from "react-redux"; import { showToast } from "../store/toasts"; import { getCippError } from "../utils/get-cipp-error"; import { buildVersionedHeaders } from "../utils/cippVersion"; +import { impersonationCacheParams } from "../utils/impersonation"; const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const wildcardToRegExp = (pattern) => @@ -71,7 +72,7 @@ export function ApiGetCall(props) { const element = data[i]; const response = await axios.get(url, { signal: signal, - params: element, + params: { ...element, ...impersonationCacheParams() }, headers: await buildVersionedHeaders(), }); results.push(response.data); @@ -109,7 +110,7 @@ export function ApiGetCall(props) { } else { const response = await axios.get(url, { signal: url === "/api/tenantFilter" ? null : signal, - params: data, + params: { ...data, ...impersonationCacheParams() }, headers: await buildVersionedHeaders(), responseType: responseType, }); @@ -292,7 +293,7 @@ export function ApiGetCallWithPagination({ queryFn: async ({ pageParam = null, signal }) => { const response = await axios.get(url, { signal: signal, - params: { ...data, ...pageParam }, + params: { ...data, ...pageParam, ...impersonationCacheParams() }, headers: await buildVersionedHeaders(), }); return response.data; diff --git a/frontend/src/components/CippComponents/CippImpersonationBanner.jsx b/frontend/src/components/CippComponents/CippImpersonationBanner.jsx new file mode 100644 index 0000000000..44ec89ddd2 --- /dev/null +++ b/frontend/src/components/CippComponents/CippImpersonationBanner.jsx @@ -0,0 +1,103 @@ +import { useEffect, useRef } from 'react' +import { Box, Button, Stack, Typography } from '@mui/material' +import { alpha, useTheme } from '@mui/material/styles' +import { Logout, TheaterComedy } from '@mui/icons-material' +import { useQueryClient } from '@tanstack/react-query' +import { + exitImpersonation, + getImpersonatedRole, + subscribeImpersonation, +} from '../../utils/impersonation' +import { useSyncExternalStore } from 'react' + +/** + * Full-width impersonation notice, rendered above the top nav (same slot and height + * contract as CippMaintenanceBanner: publishes --cipp-banner-h so the fixed chrome + * offsets itself). Source of truth is the localStorage store, NOT /api/me - the banner + * and its Exit button must work even when the impersonated role can't load /me. + * Known limitation shared with the maintenance banner: --cipp-banner-h is a single + * global slot, so if both banners show at once the last writer wins. + */ +export const CippImpersonationBanner = () => { + const theme = useTheme() + const rootRef = useRef(null) + const queryClient = useQueryClient() + + const role = useSyncExternalStore(subscribeImpersonation, getImpersonatedRole, () => null) + const visible = Boolean(role) + + useEffect(() => { + const root = document.documentElement + const clear = () => root.style.setProperty('--cipp-banner-h', '0px') + + if (!visible || !rootRef.current) { + clear() + return undefined + } + + const element = rootRef.current + const publish = () => root.style.setProperty('--cipp-banner-h', `${element.offsetHeight}px`) + publish() + + if (typeof ResizeObserver === 'undefined') return clear + + const observer = new ResizeObserver(publish) + observer.observe(element) + return () => { + observer.disconnect() + clear() + } + }, [visible]) + + if (!visible) return null + + // Tinted like CippMaintenanceBanner's non-solid style: warning tint over an opaque + // surface with an accent bar, so text keeps normal contrast in both themes instead + // of white-on-orange. + const palette = theme.palette.warning + const isDark = theme.palette.mode === 'dark' + const tint = alpha(palette.main, isDark ? 0.16 : 0.12) + const foreground = palette[isDark ? 'light' : 'dark'] + + return ( + + + + + Impersonating {role} — you are seeing CIPP as this role sees it. API + access is enforced under this role until you exit. + + + + + ) +} diff --git a/frontend/src/components/CippSettings/CippRoles.jsx b/frontend/src/components/CippSettings/CippRoles.jsx index 96f3f37735..5441080d87 100644 --- a/frontend/src/components/CippSettings/CippRoles.jsx +++ b/frontend/src/components/CippSettings/CippRoles.jsx @@ -1,7 +1,10 @@ import React from "react"; import { Box, Button, Chip, SvgIcon } from "@mui/material"; +import { useQueryClient } from "@tanstack/react-query"; import { CippDataTable } from "../CippTable/CippDataTable"; -import { PencilIcon, TrashIcon, DocumentDuplicateIcon } from "@heroicons/react/24/outline"; +import { PencilIcon, TrashIcon, DocumentDuplicateIcon, EyeIcon } from "@heroicons/react/24/outline"; +import { usePermissions } from "../../hooks/use-permissions"; +import { enterImpersonation } from "../../utils/impersonation"; import NextLink from "next/link"; import { CippPropertyListCard } from "../../components/CippCards/CippPropertyListCard"; import { getCippTranslation } from "../../utils/get-cipp-translation"; @@ -10,7 +13,34 @@ import { Stack } from "@mui/system"; import { CippCopyToClipBoard } from "../CippComponents/CippCopyToClipboard"; const CippRoles = () => { + const queryClient = useQueryClient(); + const { userRoles } = usePermissions(); + // While impersonating, /me reports the impersonated roles, so this action disappears + // automatically — no nested impersonation; the only way back is the banner's Exit. + const isSuperAdmin = userRoles?.includes("superadmin"); + const actions = [ + ...(isSuperAdmin + ? [ + { + label: "Impersonate Role", + icon: ( + + + + ), + confirmText: + "Impersonate this role? CIPP will reload and behave as if you only hold this role — including its tenant and IP restrictions — until you click Exit in the banner at the top of the page.", + // Row-menu passes (row, action, formData); the offcanvas property card passes + // (item, data, {}) — resolve the row defensively. + customFunction: (a, b) => { + const row = a?.RoleName ? a : b; + if (row?.RoleName) enterImpersonation(row.RoleName, queryClient); + }, + condition: (row) => row?.RoleName?.toLowerCase() !== "superadmin", + }, + ] + : []), { label: "Edit", icon: ( diff --git a/frontend/src/layouts/index.js b/frontend/src/layouts/index.js index 510a2c62a4..a562c046ec 100644 --- a/frontend/src/layouts/index.js +++ b/frontend/src/layouts/index.js @@ -19,6 +19,7 @@ import { ForcedSsoMigrationDialog } from '../components/CippComponents/ForcedSso import { SubscriptionEndedDialog } from '../components/CippComponents/SubscriptionEndedDialog' import { FailedPaymentDialog } from '../components/CippComponents/FailedPaymentDialog' import { CippMaintenanceBanner } from '../components/CippComponents/CippMaintenanceBanner' +import { CippImpersonationBanner } from '../components/CippComponents/CippImpersonationBanner' import { BANNER_HEIGHT_VAR, @@ -303,6 +304,7 @@ export const Layout = (props) => { <> {/* Rendered outside the hideSidebar check - maintenance applies to chrome-less pages too. */} + {hideSidebar === false && ( <> diff --git a/frontend/src/utils/cippVersion.js b/frontend/src/utils/cippVersion.js index ed64050c07..4f20325c8d 100644 --- a/frontend/src/utils/cippVersion.js +++ b/frontend/src/utils/cippVersion.js @@ -26,12 +26,17 @@ export async function getCippVersion() { return fetchPromise; } +import { getImpersonatedRole } from "./impersonation"; + // Build headers including X-CIPP-Version. Accept extra headers to merge. export async function buildVersionedHeaders(extra = {}) { const version = await getCippVersion(); + // Backend honors this only for real superadmins; harmless for everyone else. + const impersonatedRole = getImpersonatedRole(); return { "Content-Type": "application/json", "X-CIPP-Version": version, + ...(impersonatedRole ? { "x-cipp-impersonate-role": impersonatedRole } : {}), ...extra, }; } diff --git a/frontend/src/utils/impersonation.js b/frontend/src/utils/impersonation.js new file mode 100644 index 0000000000..a9e7d9a75b --- /dev/null +++ b/frontend/src/utils/impersonation.js @@ -0,0 +1,75 @@ +/** + * Role impersonation state (superadmin-only feature). + * + * Lives in its own localStorage key - NOT app.settings, which round-trips to the server + * via ExecUserSettings and races on init - so it is readable synchronously from + * non-React code (buildVersionedHeaders) and via useSyncExternalStore in components. + * The backend only honors the header for real superadmins, so this state can never + * grant privileges; it only narrows them. + */ + +const KEY = 'cipp_impersonate_role' +const listeners = new Set() +const notify = () => listeners.forEach((listener) => listener()) + +// localStorage throws in locked-down browsers - never let that break the app. +export const getImpersonatedRole = () => { + if (typeof window === 'undefined') return null + try { + return window.localStorage.getItem(KEY) || null + } catch { + return null + } +} + +export const subscribeImpersonation = (listener) => { + listeners.add(listener) + return () => listeners.delete(listener) +} + +// Everything except authmecipp is persisted to localStorage (REACT_QUERY_OFFLINE_CACHE*), +// so both transitions must clear the persisted cache and hard-reload or role-scoped data +// from the other identity survives. Mirrors the "Clear Cache and Reload" speed-dial in +// _app.js. Never use queryClient.cancelQueries() here (permanent-abort race). +const clearCachesAndReload = (queryClient) => { + try { + queryClient?.clear() + } catch { + /* reload still gives a clean slate */ + } + try { + Object.keys(window.localStorage) + .filter((key) => key.startsWith('REACT_QUERY_OFFLINE_CACHE')) + .forEach((key) => window.localStorage.removeItem(key)) + } catch { + /* worst case: stale cache entries expire on their own */ + } + window.location.reload() +} + +export const enterImpersonation = (role, queryClient) => { + try { + window.localStorage.setItem(KEY, String(role).toLowerCase()) + } catch { + return + } + notify() + clearCachesAndReload(queryClient) +} + +export const exitImpersonation = (queryClient) => { + try { + window.localStorage.removeItem(KEY) + } catch { + /* fall through - reload clears in-memory state regardless */ + } + notify() + clearCachesAndReload(queryClient) +} + +// The Craft response cache keys on URL + params, not headers - impersonated GETs carry +// this param so the two identities can never share a cached response. +export const impersonationCacheParams = () => { + const role = getImpersonatedRole() + return role ? { _imp: role } : {} +} diff --git a/frontend/tests/utils/impersonation.test.js b/frontend/tests/utils/impersonation.test.js new file mode 100644 index 0000000000..2c6dda9c04 --- /dev/null +++ b/frontend/tests/utils/impersonation.test.js @@ -0,0 +1,119 @@ +import { + getImpersonatedRole, + subscribeImpersonation, + enterImpersonation, + exitImpersonation, + impersonationCacheParams, +} from '../../src/utils/impersonation' + +const KEY = 'cipp_impersonate_role' + +describe('impersonation store', () => { + let reloadSpy + + beforeEach(() => { + window.localStorage.clear() + // jsdom's location.reload is not configurable via vi.spyOn directly + reloadSpy = vi.fn() + Object.defineProperty(window, 'location', { + value: { ...window.location, reload: reloadSpy }, + writable: true, + }) + }) + + it('is null by default and reflects the stored role', () => { + expect(getImpersonatedRole()).toBeNull() + window.localStorage.setItem(KEY, 'helpdesk') + expect(getImpersonatedRole()).toBe('helpdesk') + }) + + it('enterImpersonation lowercases, stores, clears caches and reloads', () => { + window.localStorage.setItem('REACT_QUERY_OFFLINE_CACHE', 'x') + window.localStorage.setItem('REACT_QUERY_OFFLINE_CACHE_extra', 'y') + window.localStorage.setItem('app.settings', 'keep-me') + const queryClient = { clear: vi.fn() } + + enterImpersonation('HelpDesk', queryClient) + + expect(window.localStorage.getItem(KEY)).toBe('helpdesk') + expect(queryClient.clear).toHaveBeenCalledTimes(1) + expect(window.localStorage.getItem('REACT_QUERY_OFFLINE_CACHE')).toBeNull() + expect(window.localStorage.getItem('REACT_QUERY_OFFLINE_CACHE_extra')).toBeNull() + expect(window.localStorage.getItem('app.settings')).toBe('keep-me') + expect(reloadSpy).toHaveBeenCalledTimes(1) + }) + + it('exitImpersonation removes the key, clears caches and reloads', () => { + window.localStorage.setItem(KEY, 'helpdesk') + window.localStorage.setItem('REACT_QUERY_OFFLINE_CACHE', 'x') + const queryClient = { clear: vi.fn() } + + exitImpersonation(queryClient) + + expect(window.localStorage.getItem(KEY)).toBeNull() + expect(window.localStorage.getItem('REACT_QUERY_OFFLINE_CACHE')).toBeNull() + expect(reloadSpy).toHaveBeenCalledTimes(1) + }) + + it('notifies subscribers on enter and exit, and unsubscribe works', () => { + const listener = vi.fn() + const unsubscribe = subscribeImpersonation(listener) + + enterImpersonation('readonly', { clear: vi.fn() }) + expect(listener).toHaveBeenCalledTimes(1) + + exitImpersonation({ clear: vi.fn() }) + expect(listener).toHaveBeenCalledTimes(2) + + unsubscribe() + enterImpersonation('editor', { clear: vi.fn() }) + expect(listener).toHaveBeenCalledTimes(2) + }) + + it('impersonationCacheParams segregates the Craft cache key only while impersonating', () => { + expect(impersonationCacheParams()).toEqual({}) + window.localStorage.setItem(KEY, 'helpdesk') + expect(impersonationCacheParams()).toEqual({ _imp: 'helpdesk' }) + }) + + it('survives a throwing localStorage without crashing', () => { + const original = window.localStorage + Object.defineProperty(window, 'localStorage', { + value: { + getItem: () => { + throw new Error('denied') + }, + setItem: () => { + throw new Error('denied') + }, + removeItem: () => { + throw new Error('denied') + }, + }, + configurable: true, + }) + + expect(getImpersonatedRole()).toBeNull() + expect(() => exitImpersonation({ clear: vi.fn() })).not.toThrow() + + Object.defineProperty(window, 'localStorage', { value: original, configurable: true }) + }) +}) + +describe('buildVersionedHeaders impersonation header', () => { + beforeEach(() => { + window.localStorage.clear() + global.fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ version: '1.0' }) }) + }) + + it('adds x-cipp-impersonate-role only while impersonating', async () => { + const { buildVersionedHeaders } = await import('../../src/utils/cippVersion') + + const plain = await buildVersionedHeaders() + expect(plain['x-cipp-impersonate-role']).toBeUndefined() + + window.localStorage.setItem(KEY, 'helpdesk') + const impersonated = await buildVersionedHeaders() + expect(impersonated['x-cipp-impersonate-role']).toBe('helpdesk') + }) +}) From 7a8eccfaeed767764404e27ec327dc112d0b2f9d Mon Sep 17 00:00:00 2001 From: Bobby <31723128+kris6673@users.noreply.github.com> Date: Fri, 14 Aug 2026 18:23:18 +0200 Subject: [PATCH 040/226] fix(identity): include all assigned licenses in filter options Build assigned license choices from the full user dataset so licenses beyond the table heuristic sample remain selectable. Closes #281 --- .../components/CippTable/util-columnsFromAPI.js | 2 +- .../CippTable/util-columnsFromAPI.test.jsx | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/CippTable/util-columnsFromAPI.js b/frontend/src/components/CippTable/util-columnsFromAPI.js index ad170fc3dd..15f132dfc9 100644 --- a/frontend/src/components/CippTable/util-columnsFromAPI.js +++ b/frontend/src/components/CippTable/util-columnsFromAPI.js @@ -294,7 +294,7 @@ export const utilColumnsFromAPI = (dataArray) => { sampleValue, values: valuesForColumn, getValue: (row) => resolveValue(row), - dataArray: filterSample, + dataArray, }), Cell: ({ row }) => { const value = resolveValue(row.original) diff --git a/frontend/tests/components/CippTable/util-columnsFromAPI.test.jsx b/frontend/tests/components/CippTable/util-columnsFromAPI.test.jsx index 3840f4bd7b..9023485aeb 100644 --- a/frontend/tests/components/CippTable/util-columnsFromAPI.test.jsx +++ b/frontend/tests/components/CippTable/util-columnsFromAPI.test.jsx @@ -13,6 +13,22 @@ describe('utilColumnsFromAPI', () => { expect(ids).toContain('department') }) + it('includes assigned license filter options found after the heuristic sample', () => { + const businessPremiumSku = 'cbdc14ab-d96c-4c30-b9f4-6ada7cdc1d46' + const data = Array.from({ length: 51 }, (_, index) => ({ + assignedLicenses: index === 50 ? [{ skuId: businessPremiumSku }] : [], + })) + + const licenseColumn = utilColumnsFromAPI(data).find( + (column) => column.id === 'assignedLicenses' + ) + + expect(licenseColumn.filterSelectOptions).toContainEqual({ + label: 'Microsoft 365 Business Premium', + value: businessPremiumSku, + }) + }) + it('generates columns for nested object properties', () => { const data = [ { info: { city: 'Seattle', state: 'WA' }, name: 'Test' }, From ef8bc7b9ddb563f709bd624f8cfc265ce42debd8 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Fri, 14 Aug 2026 12:40:22 -0400 Subject: [PATCH 041/226] feat(auth): apply role impersonation in Get-CIPPAccessRole Move role impersonation logic into Get-CIPPAccessRole so all downstream authorization checks (API client grants, Sherweb, alerts, domain health, etc.) consistently see the impersonated role, matching the behavior of Test-CIPPAccess. --- .../Public/Authentication/Get-CIPPAccessRole.ps1 | 14 ++++++++++++++ .../Authentication/Test-CippApiClientRoleGrant.ps1 | 2 ++ 2 files changed, 16 insertions(+) diff --git a/backend/Modules/CIPPCore/Public/Authentication/Get-CIPPAccessRole.ps1 b/backend/Modules/CIPPCore/Public/Authentication/Get-CIPPAccessRole.ps1 index 7440118bc1..ca422b372c 100644 --- a/backend/Modules/CIPPCore/Public/Authentication/Get-CIPPAccessRole.ps1 +++ b/backend/Modules/CIPPCore/Public/Authentication/Get-CIPPAccessRole.ps1 @@ -48,5 +48,19 @@ function Get-CIPPAccessRole { # For debugging Write-Debug "Combined Roles: $($CombinedRoles -join ', ')" + + # Apply role impersonation here so every secondary authorization or visibility check + # that resolves roles through this function (API client grants, Sherweb, alerts, + # domain health, ...) sees the impersonated role, consistent with Test-CIPPAccess. + if (![string]::IsNullOrWhiteSpace($Headers.'x-cipp-impersonate-role') -and $CombinedRoles -contains 'superadmin') { + $Impersonation = Resolve-CippImpersonation -User ([pscustomobject]@{ + identityProvider = 'swa' + userId = $null + userDetails = $Username + userRoles = @($CombinedRoles) + }) -Request ([pscustomobject]@{ Headers = $Headers }) + return @($Impersonation.User.userRoles) + } + return $CombinedRoles } diff --git a/backend/Modules/CIPPCore/Public/Authentication/Test-CippApiClientRoleGrant.ps1 b/backend/Modules/CIPPCore/Public/Authentication/Test-CippApiClientRoleGrant.ps1 index eeb49beeb8..9468da344f 100644 --- a/backend/Modules/CIPPCore/Public/Authentication/Test-CippApiClientRoleGrant.ps1 +++ b/backend/Modules/CIPPCore/Public/Authentication/Test-CippApiClientRoleGrant.ps1 @@ -59,6 +59,8 @@ function Test-CippApiClientRoleGrant { $CallerRoles = @('cipp-api') } } else { + # Get-CIPPAccessRole applies role impersonation, so an impersonated superadmin + # is subset-checked as the impersonated role, like a real user holding it. $CallerRoles = @(Get-CIPPAccessRole -Request $Request) } } catch { From 8e8d4bbb41506e5c19093fb2af435e946f447b66 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Fri, 14 Aug 2026 12:53:48 -0400 Subject: [PATCH 042/226] feat(roles): expand matched permissions to show API endpoints Replace the flat permission list in the rule expansion panel with collapsible accordions. Each permission entry now shows the count of API endpoints it covers and can be expanded to reveal each endpoint's name and description. ReadWrite grants also surface the Read endpoints when Read is not separately matched. --- .../CippSettings/CippRoleAddEdit.jsx | 103 ++++++++++++++++-- 1 file changed, 92 insertions(+), 11 deletions(-) diff --git a/frontend/src/components/CippSettings/CippRoleAddEdit.jsx b/frontend/src/components/CippSettings/CippRoleAddEdit.jsx index f853bc49a9..46bcddf447 100644 --- a/frontend/src/components/CippSettings/CippRoleAddEdit.jsx +++ b/frontend/src/components/CippSettings/CippRoleAddEdit.jsx @@ -1036,21 +1036,102 @@ export const CippRoleAddEdit = ({ selectedRole }) => { visible={rulePreviewVisible} onClose={() => setRulePreviewVisible(false)} title="Effective Permissions" + size="lg" > - Permissions granted by the current patterns. Struck-through entries were - matched by an include pattern but removed by an exclusion. + Permissions granted by the current patterns — expand one to see the API + endpoints it serves. Struck-through entries were matched by an include + pattern but removed by an exclusion. - {ruleExpansion.matched.map((permission) => ( - - {permission} - - ))} + {ruleExpansion.matched.map((permission) => { + const [permCat, permObj, permType] = permission.split("."); + // A ReadWrite grant also serves the Read endpoints (enforcement + // matches loosely), so show them unless Read is granted separately. + const sections = [ + { type: permType, endpoints: apiPermissions?.[permCat]?.[permObj]?.[permType] }, + ]; + if ( + permType === "ReadWrite" && + apiPermissions?.[permCat]?.[permObj]?.Read && + !ruleExpansion.matched.includes(`${permCat}.${permObj}.Read`) + ) { + sections.push({ + type: "Read (included by ReadWrite)", + endpoints: apiPermissions[permCat][permObj].Read, + }); + } + const endpointCount = sections.reduce( + (total, section) => total + Object.keys(section.endpoints || {}).length, + 0 + ); + return ( + + } + sx={{ "& .MuiAccordionSummary-content": { minWidth: 0 } }} + > + + + {permission} + + + + + + + {sections.map((section) => ( + + {sections.length > 1 && ( + {section.type} + )} + {Object.keys(section.endpoints || {}).map((apiKey) => { + const apiFunction = section.endpoints[apiKey]; + const description = getFunctionDescriptionText( + apiFunction.Description + ); + return ( + + + {apiFunction.Name} + + {description && ( + + {description} + + )} + + ); + })} + + ))} + + + + ); + })} {Object.entries(ruleExpansion.excludedBy).map(([permission, pattern]) => ( Date: Fri, 14 Aug 2026 13:00:33 -0400 Subject: [PATCH 043/226] fix(auth): deduplicate and filter null roles Filter out null/empty values and deduplicate roles when combining derived and user roles to prevent issues with duplicate or empty role entries. --- .../CIPPCore/Public/Authentication/Test-CIPPAccessUserRole.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/Modules/CIPPCore/Public/Authentication/Test-CIPPAccessUserRole.ps1 b/backend/Modules/CIPPCore/Public/Authentication/Test-CIPPAccessUserRole.ps1 index ca42c57fa9..831fe3d26c 100644 --- a/backend/Modules/CIPPCore/Public/Authentication/Test-CIPPAccessUserRole.ps1 +++ b/backend/Modules/CIPPCore/Public/Authentication/Test-CIPPAccessUserRole.ps1 @@ -110,7 +110,7 @@ function Test-CIPPAccessUserRole { $swDeriveRoles.Stop() $UserRoleTimings['DeriveRoles'] = $swDeriveRoles.Elapsed.TotalMilliseconds - $Roles = @($Roles) + @($User.userRoles) + $Roles = @(@($Roles) + @($User.userRoles) | Where-Object { $_ } | Select-Object -Unique) if ($Roles) { Write-Information "Roles determined for $($User.userDetails): $($Roles -join ', ')" From d6183797cdbf19c4e4268f9323654c75e3dbebee Mon Sep 17 00:00:00 2001 From: k-grube Date: Fri, 14 Aug 2026 10:06:07 -0700 Subject: [PATCH 044/226] fix(mobile): flex wrapping on configuration-backup --- .../tenant/manage/configuration-backup.js | 29 +++++++++++++++---- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/frontend/src/pages/tenant/manage/configuration-backup.js b/frontend/src/pages/tenant/manage/configuration-backup.js index 2d839fa646..7c91597744 100644 --- a/frontend/src/pages/tenant/manage/configuration-backup.js +++ b/frontend/src/pages/tenant/manage/configuration-backup.js @@ -401,15 +401,26 @@ const Page = () => { sx={{ height: "100%", display: "flex", flexDirection: "column" }} > Backup History - + {settings.currentTenant === "AllTenants" && ( - + { display: "flex", justifyContent: "space-between", alignItems: "flex-start", + flexWrap: "wrap", + rowGap: 1, + columnGap: 1, }} > - + {(() => { const match = backup.name.match( @@ -485,7 +499,12 @@ const Page = () => { /> )} - + )} - + + ); + + return ( + <> + {/* Page-level utilities: a row of contained buttons on desktop, but three of those + stacked full-width at 390px read as a banner wall — on mobile they ride in the + page-actions FAB sheet as plain list rows, uniform with every other sheet action. + TabbedLayout no longer puts anything in that corner, so the FAB is this page's own. */} + {isMobile ? ( + + + + + + + + + {/* The sheet stays mounted (keepMounted), so the hidden input survives the + sheet closing while the OS file picker is up. */} + + + + + + + + + + + + + + {importReport && ( + setImportReport(false)} sx={{ minHeight: 48 }}> + + + + + + )} + {importError && ( + setImportError(false)} + sx={{ minHeight: 48, color: "error.main" }} + > + + + + + + )} + + + ) : ( + + {reportButtons} + + )} { {!hideTitle && ( { setConfiguredSimpleColumns={setConfiguredSimpleColumns} queueMetadata={getRequestData.data?.pages?.[0]?.Metadata} isInDialog={isInDialog} + embedded={isInDialog || noCard} showBulkExportAction={showBulkExportAction} viewMode="cards" selectMode={selectModeActive} diff --git a/frontend/src/components/CippTable/CippMobileCardList.jsx b/frontend/src/components/CippTable/CippMobileCardList.jsx index 19fb57b279..d4e27fb991 100644 --- a/frontend/src/components/CippTable/CippMobileCardList.jsx +++ b/frontend/src/components/CippTable/CippMobileCardList.jsx @@ -185,7 +185,12 @@ export const CippMobileCardList = (props) => { return ( {isStreaming && !showSkeletons && } - + {/* pb clears the fixed FAB / bulk bar — chrome an embedded (noCard/dialog) list does + not have, so it pays a normal gap instead of 80px of blank card. */} + {showSkeletons ? ( Array.from({ length: 5 }, (_, i) => ) ) : totalFiltered === 0 ? ( @@ -218,7 +223,7 @@ export const CippMobileCardList = (props) => { variant="outlined" onClick={(event) => handleCardTap(event, row)} sx={{ - p: 1.25, + p: 2, display: "flex", gap: 1.25, position: "relative", @@ -259,7 +264,7 @@ export const CippMobileCardList = (props) => { spacing={0.75} useFlexGap flexWrap="wrap" - sx={{ mt: 0.75, alignItems: "center" }} + sx={{ mt: 1, alignItems: "center" }} > {slots.chips.map((col) => { // Booleans format as a bare ✓/✕ icon — meaningful under a column @@ -303,11 +308,11 @@ export const CippMobileCardList = (props) => { // label column truncating "Business Phones" while values sit half-empty. diff --git a/frontend/src/components/CippTable/CippMobileTableControls.jsx b/frontend/src/components/CippTable/CippMobileTableControls.jsx index 2188a4034b..57e19e3194 100644 --- a/frontend/src/components/CippTable/CippMobileTableControls.jsx +++ b/frontend/src/components/CippTable/CippMobileTableControls.jsx @@ -62,6 +62,7 @@ export const CippMobileTableControls = (props) => { onExportPdf, onViewApiResponse, fixedChrome = true, + embedded = false, queueTracker, dataSourceControls, } = props; @@ -104,7 +105,7 @@ export const CippMobileTableControls = (props) => { zIndex: 10, display: "flex", gap: 1, - px: 1, + px: embedded ? 0 : 1, py: 1, // matches the card-view paper surface it sticks over bgcolor: "background.paper", diff --git a/frontend/src/layouts/TabbedLayout.jsx b/frontend/src/layouts/TabbedLayout.jsx index 36f2aa8055..cc89fd11a7 100644 --- a/frontend/src/layouts/TabbedLayout.jsx +++ b/frontend/src/layouts/TabbedLayout.jsx @@ -78,7 +78,9 @@ export const TabbedLayout = (props) => { > {isMobile && ( - + // pt: 2 nets to the same 16px the sides and the Stack gap below pay: the + // breadcrumb divider's mb (8) is cancelled by this layout's mt: -1. + )} diff --git a/frontend/src/pages/cipp/advanced/authentication/sso.js b/frontend/src/pages/cipp/advanced/authentication/sso.js index fc5b112f3f..ec8cb012df 100644 --- a/frontend/src/pages/cipp/advanced/authentication/sso.js +++ b/frontend/src/pages/cipp/advanced/authentication/sso.js @@ -7,7 +7,7 @@ import { CippSSOSettings } from "../../../../components/CippSettings/CippSSOSett const Page = () => { return ( - + diff --git a/frontend/src/pages/cipp/settings/backend.js b/frontend/src/pages/cipp/settings/backend.js index a988cb463a..f77ef9cd18 100644 --- a/frontend/src/pages/cipp/settings/backend.js +++ b/frontend/src/pages/cipp/settings/backend.js @@ -102,7 +102,7 @@ const Page = () => { }, ]; return ( - + {backendInfo.map((item) => ( diff --git a/frontend/src/pages/cipp/settings/branding.js b/frontend/src/pages/cipp/settings/branding.js index f00435b6c7..2322f86fa4 100644 --- a/frontend/src/pages/cipp/settings/branding.js +++ b/frontend/src/pages/cipp/settings/branding.js @@ -6,7 +6,7 @@ import CippBrandingSettings from "../../../components/CippSettings/CippBrandingS const Page = () => { return ( - + ); diff --git a/frontend/src/pages/cipp/settings/index.js b/frontend/src/pages/cipp/settings/index.js index 75cb7f909a..8367367827 100644 --- a/frontend/src/pages/cipp/settings/index.js +++ b/frontend/src/pages/cipp/settings/index.js @@ -14,7 +14,7 @@ import CippLogRetentionSettings from "../../../components/CippSettings/CippLogRe import CippJitAdminSettings from "../../../components/CippSettings/CippJitAdminSettings"; const Page = () => { return ( - + diff --git a/frontend/src/pages/cipp/settings/permissions.js b/frontend/src/pages/cipp/settings/permissions.js index 4393a3e602..8c42c71ce5 100644 --- a/frontend/src/pages/cipp/settings/permissions.js +++ b/frontend/src/pages/cipp/settings/permissions.js @@ -31,9 +31,12 @@ const Page = () => { const showGdapCheck = !partnerCheckComplete || isPartner || Boolean(importReport?.GDAP); return ( - + - + {/* Below lg the report renders as a fixed FAB (plus a portaled dialog), so this item + is empty in flow — display: contents dissolves it, or grid spacing leaves a blank + 16px row between the picker and the first card. */} + diff --git a/frontend/src/pages/cipp/settings/siem.js b/frontend/src/pages/cipp/settings/siem.js index bc5e2e70fb..b28263e7b0 100644 --- a/frontend/src/pages/cipp/settings/siem.js +++ b/frontend/src/pages/cipp/settings/siem.js @@ -30,7 +30,7 @@ const filterExamples = [ const Page = () => { return ( - + diff --git a/frontend/tests/components/CippSettings/CippPermissionReport.test.jsx b/frontend/tests/components/CippSettings/CippPermissionReport.test.jsx new file mode 100644 index 0000000000..eb13775528 --- /dev/null +++ b/frontend/tests/components/CippSettings/CippPermissionReport.test.jsx @@ -0,0 +1,86 @@ +import React from "react"; +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { renderWithProviders } from "../../test-utils"; + +// jsdom has no width-based matchMedia, so the mobile branch is driven by mocking the hook +const layoutState = vi.hoisted(() => ({ isMobile: false })); +vi.mock("../../../src/hooks/use-breakpoint", async (importOriginal) => ({ + ...(await importOriginal()), + useIsMobileLayout: () => layoutState.isMobile, +})); + +// Stable identities — a fresh object per call re-renders forever +const idle = vi.hoisted(() => ({ + isSuccess: false, + isFetching: false, + isPending: false, + isError: false, + data: undefined, + mutate: () => {}, + reset: () => {}, + refetch: () => {}, +})); +vi.mock("../../../src/api/ApiCall", () => ({ + ApiGetCall: () => idle, + ApiPostCall: () => idle, + ApiGetCallWithPagination: () => ({ ...idle, fetchNextPage: () => {} }), +})); + +import { CippPermissionReport } from "../../../src/components/CippSettings/CippPermissionReport"; + +const renderReport = () => + renderWithProviders( {}} />); + +describe("CippPermissionReport report actions", () => { + beforeEach(() => { + layoutState.isMobile = false; + }); + + it("keeps the button row inline on desktop, with no FAB", () => { + renderReport(); + expect(screen.getByRole("button", { name: /export report/i })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /page actions/i })).not.toBeInTheDocument(); + }); + + // Three contained buttons stacked full-width at 390px read as a banner wall before any + // content — page-level utilities belong in the page-actions FAB sheet on mobile. + it("moves the buttons into the FAB sheet on mobile", async () => { + layoutState.isMobile = true; + const user = userEvent.setup(); + renderReport(); + + const fab = screen.getByRole("button", { name: /page actions/i }); + // not on the page until the sheet opens + expect(screen.queryByRole("button", { name: /export report/i })).not.toBeInTheDocument(); + + await user.click(fab); + expect(await screen.findByText("Report")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /export report/i })).toBeInTheDocument(); + expect(screen.getByText(/import report/i)).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /paste report/i })).toBeInTheDocument(); + + // uniform with every other sheet action: list rows, not contained buttons in a sheet + expect(document.querySelector(".MuiDrawer-paper .MuiButton-contained")).toBeNull(); + expect( + screen.getByRole("button", { name: /export report/i }).classList.contains("MuiListItemButton-root") + ).toBe(true); + }); + + // The sheet sits at modal + 1 — if a row tap didn't close it, the export dialog would + // open UNDERNEATH it. ListItemButton is a div[role=button], which the close selector + // originally missed. + it("closes the sheet when a row opens its dialog", async () => { + layoutState.isMobile = true; + const user = userEvent.setup(); + renderReport(); + + await user.click(screen.getByRole("button", { name: /page actions/i })); + const exportRow = await screen.findByRole("button", { name: /export report/i }); + await user.click(exportRow); + + // keepMounted keeps rows in the DOM; closed means hidden + await vi.waitFor(() => expect(screen.getByText(/paste report/i)).not.toBeVisible()); + }); +}); From 0aea0f5ea41ecd94a26337b98a0ac5ef9ea39351 Mon Sep 17 00:00:00 2001 From: Bobby <31723128+kris6673@users.noreply.github.com> Date: Sat, 15 Aug 2026 09:13:51 +0200 Subject: [PATCH 052/226] feat(autopilot): add group assignment for enrollment profiles Fixes #76 --- backend/Config/openapi.json | 77 +++++++ .../Set-CIPPDefaultAPDeploymentProfile.ps1 | 29 +++ .../Autopilot/Invoke-AddAutopilotConfig.ps1 | 9 + .../Invoke-ExecAssignAutopilotProfile.ps1 | 120 +++++++++++ .../Invoke-AddAutopilotConfig.Tests.ps1 | 127 ++++++++++++ ...nvoke-ExecAssignAutopilotProfile.Tests.ps1 | 139 +++++++++++++ ...t-CIPPDefaultAPDeploymentProfile.Tests.ps1 | 128 ++++++++++++ .../autopilot/enrollment-profiles/README.md | 5 +- .../CippAutopilotProfileDrawer.jsx | 188 ++++++++++++------ .../CippComponents/EnrollmentProfileTabs.jsx | 136 +++++++++++++ .../CippAutopilotProfileDrawer.test.jsx | 163 +++++++++++++++ 11 files changed, 1062 insertions(+), 59 deletions(-) create mode 100644 backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/Autopilot/Invoke-ExecAssignAutopilotProfile.ps1 create mode 100644 backend/Tests/Endpoint/Invoke-AddAutopilotConfig.Tests.ps1 create mode 100644 backend/Tests/Endpoint/Invoke-ExecAssignAutopilotProfile.Tests.ps1 create mode 100644 backend/Tests/Private/Set-CIPPDefaultAPDeploymentProfile.Tests.ps1 create mode 100644 frontend/tests/components/CippComponents/CippAutopilotProfileDrawer.test.jsx diff --git a/backend/Config/openapi.json b/backend/Config/openapi.json index 454444db4e..78bb1525be 100644 --- a/backend/Config/openapi.json +++ b/backend/Config/openapi.json @@ -620,6 +620,12 @@ "DisplayName": { "type": "string" }, + "GroupIds": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LabelValue" + } + }, "selectedTenants": { "$ref": "#/components/schemas/LabelValue" } @@ -13577,6 +13583,77 @@ "x-cipp-role": "Endpoint.Application.ReadWrite" } }, + "/api/ExecAssignAutopilotProfile": { + "post": { + "summary": "ExecAssignAutopilotProfile", + "operationId": "ExecAssignAutopilotProfile", + "tags": [ + "Endpoint > Autopilot" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "AssignTo": { + "type": "string" + }, + "GroupIds": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LabelValue" + } + }, + "ProfileId": { + "type": "string" + }, + "ProfileName": { + "type": "string" + }, + "tenantFilter": { + "type": "string" + } + }, + "required": [ + "AssignTo", + "ProfileId", + "tenantFilter" + ] + } + } + } + }, + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StandardResults" + } + } + } + }, + "401": { + "description": "Unauthorized - invalid or missing bearer token" + }, + "403": { + "description": "Forbidden - caller lacks the required RBAC role" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "x-cipp-role": "Endpoint.Autopilot.ReadWrite" + } + }, "/api/ExecAssignmentFilter": { "post": { "summary": "ExecAssignmentFilter", diff --git a/backend/Modules/CIPPCore/Public/Set-CIPPDefaultAPDeploymentProfile.ps1 b/backend/Modules/CIPPCore/Public/Set-CIPPDefaultAPDeploymentProfile.ps1 index 9bc288f30f..6c51b141b8 100644 --- a/backend/Modules/CIPPCore/Public/Set-CIPPDefaultAPDeploymentProfile.ps1 +++ b/backend/Modules/CIPPCore/Public/Set-CIPPDefaultAPDeploymentProfile.ps1 @@ -11,6 +11,7 @@ function Set-CIPPDefaultAPDeploymentProfile { $DeploymentMode, $HideChangeAccount = $true, $AssignTo, + $GroupIds, $HidePrivacy, $HideTerms, $AutoKeyboard, @@ -103,6 +104,34 @@ function Set-CIPPDefaultAPDeploymentProfile { $ErrorMessage = Get-CippException -Exception $_ Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter -message "Failed to assign Autopilot profile $($DisplayName) to $($AssignTo): $($ErrorMessage.NormalizedError)" -Sev 'Error' -LogData $ErrorMessage } + } elseif (@($GroupIds) -and @($GroupIds).Count -gt 0) { + try { + $Assigned = New-GraphGETRequest -uri "https://graph.microsoft.com/beta/deviceManagement/windowsAutopilotDeploymentProfiles/$($GraphRequest.id)/assignments" -tenantid $TenantFilter + $ExistingGroupIds = @($Assigned | + Where-Object { $_.target.'@odata.type' -eq '#microsoft.graph.groupAssignmentTarget' } | + ForEach-Object { $_.target.groupId }) + $CreatedGroupIds = [System.Collections.Generic.List[string]]::new() + foreach ($GroupId in @($GroupIds)) { + if (-not $GroupId -or $ExistingGroupIds -contains $GroupId) { continue } + $GroupAssignBody = @{ + target = @{ + '@odata.type' = '#microsoft.graph.groupAssignmentTarget' + groupId = $GroupId + } + } | ConvertTo-Json -Depth 5 -Compress + if ($PSCmdlet.ShouldProcess($GroupId, "Assign Autopilot profile $DisplayName to group")) { + $null = New-GraphPOSTRequest -uri "https://graph.microsoft.com/beta/deviceManagement/windowsAutopilotDeploymentProfiles/$($GraphRequest.id)/assignments" -tenantid $TenantFilter -type POST -body $GroupAssignBody + $CreatedGroupIds.Add($GroupId) + } + } + if (@($CreatedGroupIds).Count -gt 0) { + Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter -message "Assigned autopilot profile $($DisplayName) to group(s): $($CreatedGroupIds -join ', ')" -Sev 'Info' + } + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter -message "Failed to assign Autopilot profile $($DisplayName) to groups: $($ErrorMessage.NormalizedError)" -Sev 'Error' -LogData $ErrorMessage + throw + } } "Successfully $($Type)ed profile for $($TenantFilter)" } catch { diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/Autopilot/Invoke-AddAutopilotConfig.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/Autopilot/Invoke-AddAutopilotConfig.ps1 index 52591cc8b3..32093660d2 100644 --- a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/Autopilot/Invoke-AddAutopilotConfig.ps1 +++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/Autopilot/Invoke-AddAutopilotConfig.ps1 @@ -24,6 +24,14 @@ function Invoke-AddAutopilotConfig { $UserType = if ($Profbod.NotLocalAdmin -eq 'true') { 'standard' } else { 'administrator' } $DeploymentMode = if ($Profbod.DeploymentMode -eq 'true') { 'shared' } else { 'singleUser' } + # The frontend group picker sends option objects ({ value, label }); accept those plus + # bare id strings from direct API callers, and drop anything empty. + $GroupIds = @( + $Request.Body.GroupIds | ForEach-Object { + if ($_ -is [string]) { $_.Trim() } elseif ($_ -and $_.value) { $_.value } + } | Where-Object { $_ } + ) + # If deployment mode is shared, disable white glove (pre-provisioning) as it's not supported $AllowWhiteGlove = if ($DeploymentMode -eq 'shared') { $false } else { $Profbod.allowWhiteGlove } @@ -33,6 +41,7 @@ function Invoke-AddAutopilotConfig { UserType = $UserType DeploymentMode = $DeploymentMode AssignTo = $Request.Body.Assignto + GroupIds = $GroupIds DeviceNameTemplate = $Profbod.DeviceNameTemplate AllowWhiteGlove = $AllowWhiteGlove CollectHash = $Profbod.CollectHash diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/Autopilot/Invoke-ExecAssignAutopilotProfile.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/Autopilot/Invoke-ExecAssignAutopilotProfile.ps1 new file mode 100644 index 0000000000..743cb752d3 --- /dev/null +++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/Autopilot/Invoke-ExecAssignAutopilotProfile.ps1 @@ -0,0 +1,120 @@ +function Invoke-ExecAssignAutopilotProfile { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Endpoint.Autopilot.ReadWrite + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers + $TenantFilter = $Request.Body.tenantFilter + $ProfileId = $Request.Body.ProfileId + $ProfileName = $Request.Body.ProfileName + $AssignTo = $Request.Body.AssignTo + + try { + if ([string]::IsNullOrEmpty($TenantFilter)) { throw 'Tenant filter is required' } + if ([string]::IsNullOrEmpty($ProfileId)) { throw 'Profile ID is required' } + if ([string]::IsNullOrEmpty($AssignTo)) { throw 'AssignTo is required' } + + $BaseUri = "https://graph.microsoft.com/beta/deviceManagement/windowsAutopilotDeploymentProfiles/$ProfileId/assignments" + $Existing = @(New-GraphGETRequest -uri $BaseUri -tenantid $TenantFilter) + + if ($AssignTo -eq 'AllDevices') { + $AlreadyAssigned = $Existing | Where-Object { $_.target.'@odata.type' -eq '#microsoft.graph.allDevicesAssignmentTarget' } + if ($AlreadyAssigned) { + $Result = "Profile $ProfileName is already assigned to all devices" + } else { + $Body = '{"target":{"@odata.type":"#microsoft.graph.allDevicesAssignmentTarget"}}' + $null = New-GraphPOSTRequest -uri $BaseUri -tenantid $TenantFilter -type POST -body $Body + Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter -message "Assigned autopilot profile $ProfileName to all devices" -Sev 'Info' + $Result = "Successfully assigned profile $ProfileName to all devices" + } + } elseif ($AssignTo -eq 'RemoveAll') { + if ($Existing.Count -eq 0) { + $Result = "Profile $ProfileName has no assignments to remove" + } else { + $Removed = 0 + foreach ($Assignment in $Existing) { + $null = New-GraphPOSTRequest -uri "$BaseUri/$($Assignment.id)" -tenantid $TenantFilter -type DELETE + $Removed++ + } + Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter -message "Removed all $Removed assignment(s) from autopilot profile $ProfileName" -Sev 'Info' + $Result = "Successfully removed all $Removed assignment(s) from profile $ProfileName" + } + } elseif ($AssignTo -eq 'RemoveGroups') { + $GroupIds = @( + $Request.Body.GroupIds | ForEach-Object { + if ($_ -is [string]) { $_.Trim() } elseif ($_ -and $_.value) { $_.value } + } | Where-Object { $_ } + ) + if ($GroupIds.Count -eq 0) { throw 'At least one assignment is required' } + + $Removed = 0 + foreach ($Assignment in $Existing) { + $TargetId = if ($Assignment.target.'@odata.type' -eq '#microsoft.graph.groupAssignmentTarget') { + $Assignment.target.groupId + } elseif ($Assignment.target.'@odata.type' -eq '#microsoft.graph.allDevicesAssignmentTarget') { + 'allDevices' + } + if ($TargetId -and $GroupIds -contains $TargetId) { + $null = New-GraphPOSTRequest -uri "$BaseUri/$($Assignment.id)" -tenantid $TenantFilter -type DELETE + $Removed++ + } + } + if ($Removed -gt 0) { + Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter -message "Removed $Removed assignment(s) from autopilot profile $ProfileName" -Sev 'Info' + $Result = "Successfully removed $Removed assignment(s) from profile $ProfileName" + } else { + $Result = "No matching assignments found to remove from profile $ProfileName" + } + } else { + # Accept both bare strings and { value, label } option objects + $GroupIds = @( + $Request.Body.GroupIds | ForEach-Object { + if ($_ -is [string]) { $_.Trim() } elseif ($_ -and $_.value) { $_.value } + } | Where-Object { $_ } + ) + if ($GroupIds.Count -eq 0) { throw 'At least one group ID is required' } + + $ExistingGroupIds = @($Existing | + Where-Object { $_.target.'@odata.type' -eq '#microsoft.graph.groupAssignmentTarget' } | + ForEach-Object { $_.target.groupId }) + + $Created = [System.Collections.Generic.List[string]]::new() + foreach ($GroupId in $GroupIds) { + if ($ExistingGroupIds -contains $GroupId) { continue } + $Body = @{ + target = @{ + '@odata.type' = '#microsoft.graph.groupAssignmentTarget' + groupId = $GroupId + } + } | ConvertTo-Json -Depth 5 -Compress + $null = New-GraphPOSTRequest -uri $BaseUri -tenantid $TenantFilter -type POST -body $Body + $Created.Add($GroupId) + } + + if ($Created.Count -gt 0) { + Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter -message "Assigned autopilot profile $ProfileName to group(s): $($Created -join ', ')" -Sev 'Info' + $Result = "Successfully assigned profile $ProfileName to $($Created.Count) group(s)" + } else { + $Result = "Profile $ProfileName is already assigned to all specified groups" + } + } + + $StatusCode = [HttpStatusCode]::OK + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter -message "Failed to assign autopilot profile $ProfileName`: $($ErrorMessage.NormalizedError)" -Sev 'Error' -LogData $ErrorMessage + $Result = "Failed to assign profile: $($ErrorMessage.NormalizedError)" + $StatusCode = [HttpStatusCode]::InternalServerError + } + + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = @{ 'Results' = $Result } + }) +} diff --git a/backend/Tests/Endpoint/Invoke-AddAutopilotConfig.Tests.ps1 b/backend/Tests/Endpoint/Invoke-AddAutopilotConfig.Tests.ps1 new file mode 100644 index 0000000000..96c61099be --- /dev/null +++ b/backend/Tests/Endpoint/Invoke-AddAutopilotConfig.Tests.ps1 @@ -0,0 +1,127 @@ +# Pester tests for Invoke-AddAutopilotConfig. +# +# Covers the group-assignment forwarding contract: option objects from the frontend +# picker are normalized to bare ids, bare string ids pass through, group ids reach +# the helper for every selected tenant, and the invalid-name guard short-circuits. + +BeforeAll { + # Resolve by name under Modules/ so the test survives the function moving between modules. + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $FunctionPath = Get-ChildItem -Path (Join-Path $RepoRoot 'Modules') -Recurse -Filter 'Invoke-AddAutopilotConfig.ps1' -File -ErrorAction SilentlyContinue | + Select-Object -First 1 -ExpandProperty FullName + if (-not $FunctionPath) { throw 'Could not locate Invoke-AddAutopilotConfig.ps1 under Modules/' } + + # Azure Functions binding types do not exist outside the Functions host - fake them. + class HttpResponseContext { + [int]$StatusCode + [object]$Body + } + + # The endpoint references the unqualified [HttpStatusCode], which only resolves in the + # Functions host. Register it as a type accelerator so the source parses here too. + $Accelerators = [PSObject].Assembly.GetType('System.Management.Automation.TypeAccelerators') + if (-not ('HttpStatusCode' -as [type])) { + $Accelerators::Add('HttpStatusCode', [System.Net.HttpStatusCode]) + } + + # Stub every CIPP helper the function calls so Pester's Mock has a command to replace. + # Declare the splatted keys so they bind as real parameters (a bare stub would swallow + # them into $args and Pester's ParameterFilter would see nulls). + function Set-CIPPDefaultAPDeploymentProfile { + param( + $DisplayName, $Description, $UserType, $DeploymentMode, $AssignTo, $GroupIds, + $DeviceNameTemplate, $AllowWhiteGlove, $CollectHash, $HideChangeAccount, + $HidePrivacy, $HideTerms, $Autokeyboard, $Language, $Headers, $APIName, $TenantFilter + ) + } + function Test-CIPPAutopilotProfileName { } + + . $FunctionPath +} + +Describe 'Invoke-AddAutopilotConfig' { + BeforeEach { + Mock -CommandName Test-CIPPAutopilotProfileName -MockWith { [PSCustomObject]@{ IsValid = $true; Message = '' } } + Mock -CommandName Set-CIPPDefaultAPDeploymentProfile -MockWith { 'done' } + } + + It 'forwards normalized group ids to the helper for every selected tenant' { + $request = [pscustomobject]@{ + Params = @{ CIPPEndpoint = 'AddAutopilotConfig' } + Headers = @{ Authorization = 'token' } + Body = [pscustomobject]@{ + Assignto = $true + Description = 'Test' + DisplayName = 'AP Test' + GroupIds = @(@{ value = 'group-1'; label = 'Group 1' }, @{ value = 'group-2'; label = 'Group 2' }) + selectedTenants = @(@{ value = 'tenant-a' }, @{ value = 'tenant-b' }) + } + } + + $response = Invoke-AddAutopilotConfig -Request $request -TriggerMetadata $null + + $response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::OK) + Should -Invoke Set-CIPPDefaultAPDeploymentProfile -Times 2 -ParameterFilter { + $GroupIds -contains 'group-1' -and $GroupIds -contains 'group-2' -and $AssignTo -eq $true + } + } + + It 'passes bare string group ids through unchanged' { + $request = [pscustomobject]@{ + Params = @{ CIPPEndpoint = 'AddAutopilotConfig' } + Headers = @{ Authorization = 'token' } + Body = [pscustomobject]@{ + Assignto = $false + Description = 'Test' + DisplayName = 'AP Test' + GroupIds = @('group-1', '') + selectedTenants = @(@{ value = 'tenant-a' }) + } + } + + $response = Invoke-AddAutopilotConfig -Request $request -TriggerMetadata $null + + $response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::OK) + Should -Invoke Set-CIPPDefaultAPDeploymentProfile -ParameterFilter { + $GroupIds.Count -eq 1 -and $GroupIds[0] -eq 'group-1' + } + } + + It 'propagates profile or assignment failures from the helper' { + Mock -CommandName Set-CIPPDefaultAPDeploymentProfile -MockWith { throw 'assignment failed' } + $request = [pscustomobject]@{ + Params = @{ CIPPEndpoint = 'AddAutopilotConfig' } + Headers = @{ Authorization = 'token' } + Body = [pscustomobject]@{ + Assignto = $false + Description = 'Test' + DisplayName = 'AP Test' + GroupIds = @('group-1') + selectedTenants = @(@{ value = 'tenant-a' }) + } + } + + { Invoke-AddAutopilotConfig -Request $request -TriggerMetadata $null } | + Should -Throw 'assignment failed' + } + + It 'rejects an invalid profile name without calling the helper' { + Mock -CommandName Test-CIPPAutopilotProfileName -MockWith { [PSCustomObject]@{ IsValid = $false; Message = 'Name rejected' } } + $request = [pscustomobject]@{ + Params = @{ CIPPEndpoint = 'AddAutopilotConfig' } + Headers = @{ Authorization = 'token' } + Body = [pscustomobject]@{ + Assignto = $true + Description = 'Test' + DisplayName = 'Bad-Name' + selectedTenants = @(@{ value = 'tenant-a' }) + } + } + + $response = Invoke-AddAutopilotConfig -Request $request -TriggerMetadata $null + + $response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::BadRequest) + $response.Body.Results | Should -Be 'Name rejected' + Should -Invoke Set-CIPPDefaultAPDeploymentProfile -Times 0 + } +} diff --git a/backend/Tests/Endpoint/Invoke-ExecAssignAutopilotProfile.Tests.ps1 b/backend/Tests/Endpoint/Invoke-ExecAssignAutopilotProfile.Tests.ps1 new file mode 100644 index 0000000000..bae9922f23 --- /dev/null +++ b/backend/Tests/Endpoint/Invoke-ExecAssignAutopilotProfile.Tests.ps1 @@ -0,0 +1,139 @@ +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $FunctionPath = Get-ChildItem -Path (Join-Path $RepoRoot 'Modules') -Recurse -Filter 'Invoke-ExecAssignAutopilotProfile.ps1' -File -ErrorAction SilentlyContinue | + Select-Object -First 1 -ExpandProperty FullName + if (-not $FunctionPath) { throw 'Could not locate Invoke-ExecAssignAutopilotProfile.ps1 under Modules/' } + + class HttpResponseContext { + [int]$StatusCode + [object]$Body + } + $Accelerators = [PSObject].Assembly.GetType('System.Management.Automation.TypeAccelerators') + if (-not ('HttpStatusCode' -as [type])) { + $Accelerators::Add('HttpStatusCode', [System.Net.HttpStatusCode]) + } + + function New-GraphGETRequest { param($uri, $tenantid) } + function New-GraphPOSTRequest { param($uri, $tenantid, $type, $body) } + function Write-LogMessage { param($Headers, $API, $tenant, $message, $Sev, $LogData) } + function Get-CippException { param($Exception) @{ NormalizedError = $Exception.Exception.Message } } + + . $FunctionPath + + function New-AssignAPRequest { + param($Body) + [pscustomobject]@{ + Params = @{ CIPPEndpoint = 'ExecAssignAutopilotProfile' } + Headers = @{ Authorization = 'token' } + Body = [pscustomobject]$Body + } + } +} + +Describe 'Invoke-ExecAssignAutopilotProfile' { + BeforeEach { + Mock -CommandName New-GraphGETRequest -MockWith { @() } + Mock -CommandName New-GraphPOSTRequest -MockWith { $null } + Mock -CommandName Write-LogMessage + } + + It 'assigns AllDevices when no existing assignment' { + $req = New-AssignAPRequest @{ tenantFilter = 't1'; ProfileId = 'p1'; ProfileName = 'Test'; AssignTo = 'AllDevices' } + $res = Invoke-ExecAssignAutopilotProfile -Request $req -TriggerMetadata $null + + $res.StatusCode | Should -Be ([System.Net.HttpStatusCode]::OK) + $res.Body.Results | Should -BeLike '*Successfully*all devices*' + Should -Invoke New-GraphPOSTRequest -Times 1 -ParameterFilter { + $body -like '*allDevicesAssignmentTarget*' + } + } + + It 'skips AllDevices when already assigned' { + Mock -CommandName New-GraphGETRequest -MockWith { + @([pscustomobject]@{ target = @{ '@odata.type' = '#microsoft.graph.allDevicesAssignmentTarget' } }) + } + $req = New-AssignAPRequest @{ tenantFilter = 't1'; ProfileId = 'p1'; ProfileName = 'Test'; AssignTo = 'AllDevices' } + $res = Invoke-ExecAssignAutopilotProfile -Request $req -TriggerMetadata $null + + $res.StatusCode | Should -Be ([System.Net.HttpStatusCode]::OK) + $res.Body.Results | Should -BeLike '*already assigned*' + Should -Invoke New-GraphPOSTRequest -Times 0 + } + + It 'assigns new groups and skips duplicates' { + Mock -CommandName New-GraphGETRequest -MockWith { + @([pscustomobject]@{ target = @{ '@odata.type' = '#microsoft.graph.groupAssignmentTarget'; groupId = 'existing-1' } }) + } + $req = New-AssignAPRequest @{ tenantFilter = 't1'; ProfileId = 'p1'; ProfileName = 'Test'; AssignTo = 'customGroup'; GroupIds = @('existing-1', 'new-1', 'new-2') } + $res = Invoke-ExecAssignAutopilotProfile -Request $req -TriggerMetadata $null + + $res.StatusCode | Should -Be ([System.Net.HttpStatusCode]::OK) + Should -Invoke New-GraphPOSTRequest -Times 2 + } + + It 'normalizes option objects to bare ids' { + $req = New-AssignAPRequest @{ tenantFilter = 't1'; ProfileId = 'p1'; ProfileName = 'Test'; AssignTo = 'customGroup'; GroupIds = @(@{ value = 'g1'; label = 'Group 1' }) } + $res = Invoke-ExecAssignAutopilotProfile -Request $req -TriggerMetadata $null + + $res.StatusCode | Should -Be ([System.Net.HttpStatusCode]::OK) + Should -Invoke New-GraphPOSTRequest -Times 1 -ParameterFilter { + $body -like '*g1*' + } + } + + It 'returns error when ProfileId is missing' { + $req = New-AssignAPRequest @{ tenantFilter = 't1'; AssignTo = 'AllDevices' } + $res = Invoke-ExecAssignAutopilotProfile -Request $req -TriggerMetadata $null + + $res.StatusCode | Should -Be ([System.Net.HttpStatusCode]::InternalServerError) + $res.Body.Results | Should -BeLike '*Profile ID*' + } + + It 'returns error when no group ids provided for customGroup' { + $req = New-AssignAPRequest @{ tenantFilter = 't1'; ProfileId = 'p1'; ProfileName = 'Test'; AssignTo = 'customGroup'; GroupIds = @() } + $res = Invoke-ExecAssignAutopilotProfile -Request $req -TriggerMetadata $null + + $res.StatusCode | Should -Be ([System.Net.HttpStatusCode]::InternalServerError) + $res.Body.Results | Should -BeLike '*at least one group*' + } + + It 'RemoveAll deletes all existing assignments' { + Mock -CommandName New-GraphGETRequest -MockWith { + @( + [pscustomobject]@{ id = 'a1'; target = @{ '@odata.type' = '#microsoft.graph.allDevicesAssignmentTarget' } }, + [pscustomobject]@{ id = 'a2'; target = @{ '@odata.type' = '#microsoft.graph.groupAssignmentTarget'; groupId = 'g1' } } + ) + } + $req = New-AssignAPRequest @{ tenantFilter = 't1'; ProfileId = 'p1'; ProfileName = 'Test'; AssignTo = 'RemoveAll' } + $res = Invoke-ExecAssignAutopilotProfile -Request $req -TriggerMetadata $null + + $res.StatusCode | Should -Be ([System.Net.HttpStatusCode]::OK) + $res.Body.Results | Should -BeLike '*removed all 2*' + Should -Invoke New-GraphPOSTRequest -Times 2 -ParameterFilter { $type -eq 'DELETE' } + } + + It 'RemoveAll reports no assignments when empty' { + $req = New-AssignAPRequest @{ tenantFilter = 't1'; ProfileId = 'p1'; ProfileName = 'Test'; AssignTo = 'RemoveAll' } + $res = Invoke-ExecAssignAutopilotProfile -Request $req -TriggerMetadata $null + + $res.StatusCode | Should -Be ([System.Net.HttpStatusCode]::OK) + $res.Body.Results | Should -BeLike '*no assignments*' + Should -Invoke New-GraphPOSTRequest -Times 0 + } + + It 'RemoveGroups removes only matching assignments' { + Mock -CommandName New-GraphGETRequest -MockWith { + @( + [pscustomobject]@{ id = 'a1'; target = @{ '@odata.type' = '#microsoft.graph.groupAssignmentTarget'; groupId = 'g1' } }, + [pscustomobject]@{ id = 'a2'; target = @{ '@odata.type' = '#microsoft.graph.groupAssignmentTarget'; groupId = 'g2' } }, + [pscustomobject]@{ id = 'a3'; target = @{ '@odata.type' = '#microsoft.graph.allDevicesAssignmentTarget' } } + ) + } + $req = New-AssignAPRequest @{ tenantFilter = 't1'; ProfileId = 'p1'; ProfileName = 'Test'; AssignTo = 'RemoveGroups'; GroupIds = @('g1', 'allDevices') } + $res = Invoke-ExecAssignAutopilotProfile -Request $req -TriggerMetadata $null + + $res.StatusCode | Should -Be ([System.Net.HttpStatusCode]::OK) + $res.Body.Results | Should -BeLike '*removed 2*' + Should -Invoke New-GraphPOSTRequest -Times 2 -ParameterFilter { $type -eq 'DELETE' } + } +} diff --git a/backend/Tests/Private/Set-CIPPDefaultAPDeploymentProfile.Tests.ps1 b/backend/Tests/Private/Set-CIPPDefaultAPDeploymentProfile.Tests.ps1 new file mode 100644 index 0000000000..d09aef1380 --- /dev/null +++ b/backend/Tests/Private/Set-CIPPDefaultAPDeploymentProfile.Tests.ps1 @@ -0,0 +1,128 @@ +# Pester tests for Set-CIPPDefaultAPDeploymentProfile. +# +# Covers the assignment half of profile creation: the all-devices branch, the new +# group-target branch (one assignment per group, already-assigned groups skipped), +# no assignment when neither is requested, the invalid-name guard, and the error path. + +BeforeAll { + # Resolve by name under Modules/ so the test survives the function moving between modules. + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $FunctionPath = Get-ChildItem -Path (Join-Path $RepoRoot 'Modules') -Recurse -Filter 'Set-CIPPDefaultAPDeploymentProfile.ps1' -File -ErrorAction SilentlyContinue | + Select-Object -First 1 -ExpandProperty FullName + if (-not $FunctionPath) { throw 'Could not locate Set-CIPPDefaultAPDeploymentProfile.ps1 under Modules/' } + + # Stub every CIPP helper the function calls so Pester's Mock has a command to replace. + function Get-CippException { [CmdletBinding()] param($Exception) [PSCustomObject]@{ NormalizedError = [string]$Exception } } + function New-GraphGETRequest { [CmdletBinding()] param($uri, $tenantid, $body, $type) } + function New-GraphPOSTRequest { [CmdletBinding()] param($uri, $tenantid, $body, $type) } + function Test-CIPPAutopilotProfileName { [CmdletBinding()] param($DisplayName) [PSCustomObject]@{ IsValid = $true; Message = '' } } + function Write-LogMessage { [CmdletBinding()] param($Headers, $API, $tenant, $message, $Sev, $LogData) } + + . $FunctionPath + + $script:Tenant = 'contoso.onmicrosoft.com' +} + +Describe 'Set-CIPPDefaultAPDeploymentProfile assignment handling' { + BeforeEach { + $script:PostCalls = @() + + Mock -CommandName Test-CIPPAutopilotProfileName -MockWith { [PSCustomObject]@{ IsValid = $true; Message = '' } } + Mock -CommandName New-GraphGETRequest -ParameterFilter { $uri -like '*windowsAutopilotDeploymentProfiles' -and $uri -notlike '*assignments*' } -MockWith { @() } + Mock -CommandName New-GraphPOSTRequest -ParameterFilter { $uri -like '*windowsAutopilotDeploymentProfiles' -and $uri -notlike '*assignments*' } -MockWith { + $script:PostCalls += @{ uri = $uri; body = $body; type = $type } + [PSCustomObject]@{ id = 'profile-1' } + } + Mock -CommandName New-GraphGETRequest -ParameterFilter { $uri -like '*assignments*' } -MockWith { @() } + Mock -CommandName New-GraphPOSTRequest -ParameterFilter { $uri -like '*assignments*' } -MockWith { + $script:PostCalls += @{ uri = $uri; body = $body; type = $type } + [PSCustomObject]@{ id = 'assignment-1' } + } + Mock -CommandName Write-LogMessage -MockWith { } + Mock -CommandName Get-CippException -MockWith { [PSCustomObject]@{ NormalizedError = 'boom' } } + } + + It 'creates one groupAssignmentTarget assignment per group' { + Set-CIPPDefaultAPDeploymentProfile -TenantFilter $script:Tenant -DisplayName 'AP Test' -Description '' ` + -DeploymentMode 'singleUser' -UserType 'standard' -AssignTo $false -GroupIds @('group-1', 'group-2') ` + -HidePrivacy $true -HideTerms $true -AutoKeyboard $true -AllowWhiteGlove $true -CollectHash $false + + $AssignmentCalls = @($script:PostCalls | Where-Object { $_.uri -like '*assignments' }) + $AssignmentCalls.Count | Should -Be 2 + $AssignmentCalls[0].body | Should -BeLike '*#microsoft.graph.groupAssignmentTarget*' + $AssignmentCalls[0].body | Should -BeLike '*group-1*' + $AssignmentCalls[1].body | Should -BeLike '*group-2*' + $AssignmentCalls | ForEach-Object { $_.body | Should -Not -BeLike '*allDevicesAssignmentTarget*' } + } + + It 'skips groups that already have an assignment' { + Mock -CommandName New-GraphGETRequest -ParameterFilter { $uri -like '*assignments*' } -MockWith { + @( + [PSCustomObject]@{ target = [PSCustomObject]@{ '@odata.type' = '#microsoft.graph.groupAssignmentTarget'; groupId = 'group-1' } } + ) + } + + Set-CIPPDefaultAPDeploymentProfile -TenantFilter $script:Tenant -DisplayName 'AP Test' -Description '' ` + -DeploymentMode 'singleUser' -UserType 'standard' -AssignTo $false -GroupIds @('group-1', 'group-2') ` + -HidePrivacy $true -HideTerms $true -AutoKeyboard $true -AllowWhiteGlove $true -CollectHash $false + + $AssignmentCalls = @($script:PostCalls | Where-Object { $_.uri -like '*assignments' }) + $AssignmentCalls.Count | Should -Be 1 + $AssignmentCalls[0].body | Should -BeLike '*group-2*' + } + + It 'throws when existing group assignments cannot be read' { + Mock -CommandName New-GraphGETRequest -ParameterFilter { $uri -like '*assignments*' } -MockWith { throw 'assignment lookup failed' } + + { Set-CIPPDefaultAPDeploymentProfile -TenantFilter $script:Tenant -DisplayName 'AP Test' -Description '' ` + -DeploymentMode 'singleUser' -UserType 'standard' -AssignTo $false -GroupIds @('group-1') ` + -HidePrivacy $true -HideTerms $true -AutoKeyboard $true -AllowWhiteGlove $true -CollectHash $false } | + Should -Throw '*Failed*' + + Should -Invoke New-GraphPOSTRequest -ParameterFilter { $uri -like '*assignments*' } -Times 0 + } + + It 'throws when any group assignment post fails' { + Mock -CommandName New-GraphPOSTRequest -ParameterFilter { $uri -like '*assignments*' } -MockWith { throw 'assignment post failed' } + + { Set-CIPPDefaultAPDeploymentProfile -TenantFilter $script:Tenant -DisplayName 'AP Test' -Description '' ` + -DeploymentMode 'singleUser' -UserType 'standard' -AssignTo $false -GroupIds @('group-1') ` + -HidePrivacy $true -HideTerms $true -AutoKeyboard $true -AllowWhiteGlove $true -CollectHash $false } | + Should -Throw '*Failed*' + } + + It 'keeps the all-devices branch when AssignTo is true, ignoring GroupIds' { + Set-CIPPDefaultAPDeploymentProfile -TenantFilter $script:Tenant -DisplayName 'AP Test' -Description '' ` + -DeploymentMode 'singleUser' -UserType 'standard' -AssignTo $true -GroupIds @('group-1') ` + -HidePrivacy $true -HideTerms $true -AutoKeyboard $true -AllowWhiteGlove $true -CollectHash $false + + $AssignmentCalls = @($script:PostCalls | Where-Object { $_.uri -like '*assignments' }) + $AssignmentCalls.Count | Should -Be 1 + $AssignmentCalls[0].body | Should -BeLike '*allDevicesAssignmentTarget*' + } + + It 'creates no assignment when neither all devices nor groups are requested' { + Set-CIPPDefaultAPDeploymentProfile -TenantFilter $script:Tenant -DisplayName 'AP Test' -Description '' ` + -DeploymentMode 'singleUser' -UserType 'standard' -AssignTo $false -GroupIds @() ` + -HidePrivacy $true -HideTerms $true -AutoKeyboard $true -AllowWhiteGlove $true -CollectHash $false + + @($script:PostCalls | Where-Object { $_.uri -like '*assignments' }).Count | Should -Be 0 + } + + It 'refuses an invalid profile name without calling Graph' { + Mock -CommandName Test-CIPPAutopilotProfileName -MockWith { [PSCustomObject]@{ IsValid = $false; Message = 'Name rejected' } } + + { Set-CIPPDefaultAPDeploymentProfile -TenantFilter $script:Tenant -DisplayName 'Bad-Name' -AssignTo $false -GroupIds @('group-1') } | + Should -Throw 'Name rejected' + @($script:PostCalls).Count | Should -Be 0 + Should -Invoke Write-LogMessage -ParameterFilter { $message -eq 'Name rejected' } + } + + It 'throws a readable error when the profile lookup fails' { + Mock -CommandName New-GraphGETRequest -ParameterFilter { $uri -like '*windowsAutopilotDeploymentProfiles' -and $uri -notlike '*assignments*' } -MockWith { throw 'graph down' } + + { Set-CIPPDefaultAPDeploymentProfile -TenantFilter $script:Tenant -DisplayName 'AP Test' -AssignTo $true } | + Should -Throw '*Failed*' + Should -Invoke Write-LogMessage -ParameterFilter { $Sev -eq 'Error' } + } +} diff --git a/docs/user-documentation/endpoint/autopilot/enrollment-profiles/README.md b/docs/user-documentation/endpoint/autopilot/enrollment-profiles/README.md index fc535daf75..c6fc9a3c89 100644 --- a/docs/user-documentation/endpoint/autopilot/enrollment-profiles/README.md +++ b/docs/user-documentation/endpoint/autopilot/enrollment-profiles/README.md @@ -18,7 +18,8 @@ Opens the Autopilot Profile Wizard, which creates a deployment profile in one or | Description | Optional text describing the profile. | | Unique Name Template | The naming pattern applied to devices that receive the profile, for example `%SERIAL%` or `%RAND:x%`. Leave blank to leave device names alone. | | Convert all targeted devices to Autopilot | Registers any device the profile is assigned to into Autopilot automatically, rather than requiring it to be imported first. | -| Assign to all devices | Assigns the profile to every Autopilot device in the tenant on creation. On by default. | +| Assign to all devices | On by default. Assigns the profile to every Autopilot device in the tenant on creation. Turn it off to choose groups instead. | +| Assign to Selected Groups | Shown only when _Assign to all devices_ is off and exactly one tenant is selected. Assigns the profile to the chosen groups. Leave empty to create the profile without an assignment. Groups are tenant-specific, so a single tenant must be selected; with multiple tenants selected the field is replaced by a warning instead. | | Self-deploying mode | Enrols the device without a user present, for kiosks and shared devices. | | Hide Terms and conditions | Skips the terms and conditions page during out-of-box experience. On by default. | | Hide Privacy Settings | Skips the privacy settings page during out-of-box experience. On by default. | @@ -37,6 +38,6 @@ The properties returned are for the Graph resource type `windowsAutopilotDeploym ## Table Actions -
    ActionDescriptionBulk Action Available
    Delete ProfileDeletes the profile from the tenant along with its assignments. Devices already deployed with it are unaffected, but devices reset afterwards will no longer receive it.true
    More InfoOpens the Extended Info flyout with the full details for the selected row.false
    +
    ActionDescriptionBulk Action Available
    Assign to All DevicesAssigns the profile to every Autopilot device in the tenant. If the profile is already assigned to all devices the action reports that and makes no changes.true
    Assign to Custom Group(s)Assigns the profile to one or more Entra ID security groups. A group picker dialog lets you search and select groups. Groups the profile is already assigned to are skipped automatically.true
    Remove Assignment(s)Removes assignments from the profile. A "Remove all assignments" switch is on by default and removes every assignment in one action. Turn it off to pick specific groups to unassign instead.true
    Delete ProfileDeletes the profile from the tenant along with its assignments. Devices already deployed with it are unaffected, but devices reset afterwards will no longer receive it.true
    More InfoOpens the Extended Info flyout with the full details for the selected row.false
    {% include "../../../../../.gitbook/includes/feature-request.md" %} diff --git a/frontend/src/components/CippComponents/CippAutopilotProfileDrawer.jsx b/frontend/src/components/CippComponents/CippAutopilotProfileDrawer.jsx index 1093811cbe..6f5781b3bc 100644 --- a/frontend/src/components/CippComponents/CippAutopilotProfileDrawer.jsx +++ b/frontend/src/components/CippComponents/CippAutopilotProfileDrawer.jsx @@ -1,38 +1,45 @@ -import React, { useState, useEffect } from "react"; -import { Divider, Button } from "@mui/material"; -import { Grid } from "@mui/system"; -import { useForm, useWatch, useFormState } from "react-hook-form"; -import { AccountCircle } from "@mui/icons-material"; -import { CippOffCanvas } from "./CippOffCanvas"; -import CippFormComponent from "./CippFormComponent"; -import { CippFormTenantSelector } from "./CippFormTenantSelector"; -import { CippApiResults } from "./CippApiResults"; -import languageList from "../../data/languageList.json"; -import { ApiPostCall } from "../../api/ApiCall"; +import React, { useState, useEffect } from 'react' +import { Divider, Button, Alert } from '@mui/material' +import { Grid } from '@mui/system' +import { useForm, useWatch, useFormState } from 'react-hook-form' +import { AccountCircle } from '@mui/icons-material' +import { CippOffCanvas } from './CippOffCanvas' +import CippFormComponent from './CippFormComponent' +import { CippFormTenantSelector } from './CippFormTenantSelector' +import { CippApiResults } from './CippApiResults' +import languageList from '../../data/languageList.json' +import { ApiPostCall } from '../../api/ApiCall' +import { usePermissions } from '../../hooks/use-permissions' // Intune rejects anything outside this set with a generic 500 that carries no reason, so we catch it here. // Kept in sync with Test-CIPPAutopilotProfileName on the backend. -const PROFILE_NAME_PATTERN = /^[\p{L}\p{N} :"?.@$&_\[\]{}|\\]+$/u; +const PROFILE_NAME_PATTERN = /^[\p{L}\p{N} :"?.@$&_\[\]{}|\\]+$/u const PROFILE_NAME_MESSAGE = - 'Only letters, numbers, spaces and : " ? . @ $ & _ [ ] { } | \\ are allowed'; + 'Only letters, numbers, spaces and : " ? . @ $ & _ [ ] { } | \\ are allowed' const PROFILE_NAME_HINT = - 'Intune only accepts letters, numbers, spaces and : " ? . @ $ & _ [ ] { } | \\ — hyphens are rejected'; + 'Intune only accepts letters, numbers, spaces and : " ? . @ $ & _ [ ] { } | \\ — hyphens are rejected' export const CippAutopilotProfileDrawer = ({ - buttonText = "Add Profile", + buttonText = 'Add Profile', requiredPermissions = [], PermissionButton = Button, }) => { - const [drawerVisible, setDrawerVisible] = useState(false); + const [drawerVisible, setDrawerVisible] = useState(false) + const { checkPermissions } = usePermissions() + const canReadGroups = checkPermissions([ + 'Identity.Group.Read', + 'Identity.Group.ReadWrite', + ]) const formControl = useForm({ - mode: "onChange", + mode: 'onChange', defaultValues: { - DisplayName: "", - Description: "", - DeviceNameTemplate: "", + DisplayName: '', + Description: '', + DeviceNameTemplate: '', languages: null, CollectHash: false, Assignto: true, + GroupIds: [], DeploymentMode: false, HideTerms: true, HidePrivacy: true, @@ -41,47 +48,72 @@ export const CippAutopilotProfileDrawer = ({ allowWhiteglove: true, Autokeyboard: true, }, - }); + }) const createProfile = ApiPostCall({ urlFromData: true, - relatedQueryKeys: ["Autopilot Profiles*"], - }); + relatedQueryKeys: ['Autopilot Profiles*'], + }) // Watch the deployment mode to conditionally disable white glove const deploymentMode = useWatch({ control: formControl.control, - name: "DeploymentMode", - }); + name: 'DeploymentMode', + }) + + // Group targets are tenant-scoped, so they are only offered for a single tenant. + const selectedTenants = useWatch({ + control: formControl.control, + name: 'selectedTenants', + }) + const assignToGroups = useWatch({ + control: formControl.control, + name: 'Assignto', + }) + const singleTenant = + Array.isArray(selectedTenants) && selectedTenants.length === 1 + const groupTenant = singleTenant ? selectedTenants[0]?.value : undefined // Watch form state for validation const { isValid, isDirty } = useFormState({ control: formControl.control, - }); + }) // Automatically disable white glove when self-deploying mode (shared) is enabled useEffect(() => { if (deploymentMode === true) { // Self-deploying mode is enabled (shared mode), disable white glove - formControl.setValue("allowWhiteglove", false); + formControl.setValue('allowWhiteglove', false) } - }, [deploymentMode, formControl]); + }, [deploymentMode, formControl]) + + // A group selection is only valid for the tenant it was loaded from. + useEffect(() => { + formControl.setValue('GroupIds', []) + }, [assignToGroups, canReadGroups, formControl, groupTenant]) const handleSubmit = () => { - const formData = formControl.getValues(); + const formData = formControl.getValues() // Always set HideChangeAccount to true regardless of form state - formData.HideChangeAccount = true; + formData.HideChangeAccount = true + // The group picker stores option objects; the endpoint expects bare group ids. + const canAssignGroups = + assignToGroups === false && singleTenant && canReadGroups + formData.GroupIds = + canAssignGroups && Array.isArray(formData.GroupIds) + ? formData.GroupIds.map((group) => group.value).filter(Boolean) + : [] createProfile.mutate({ - url: "/api/AddAutopilotConfig", + url: '/api/AddAutopilotConfig', data: formData, - relatedQueryKeys: ["Autopilot Profiles*"], - }); - }; + relatedQueryKeys: ['Autopilot Profiles*'], + }) + } const handleCloseDrawer = () => { - setDrawerVisible(false); - formControl.reset(); - }; + setDrawerVisible(false) + formControl.reset() + } return ( <> @@ -102,10 +134,10 @@ export const CippAutopilotProfileDrawer = ({
    + ) + } + return ( Add Standards to Stage - setSearch(event.target.value)} - InputProps={{ - startAdornment: ( - - - - ), - }} - /> - - {categories.map((entry) => ( - setCategory(entry)} + + + setSearch(event.target.value)} + autoComplete="off" + placeholder="Search by name, description, or benchmark tag..." + InputProps={{ + startAdornment: ( + + ), + }} + /> + + + + setSelectedCategories( + Array.isArray(newValue) + ? newValue.map((option) => option.value) + : [] + ) + } /> - ))} + + + + setSelectedImpacts( + Array.isArray(newValue) + ? newValue.map((option) => option.value) + : [] + ) + } + /> + + + + setSelectedRecommendedBy( + Array.isArray(newValue) + ? newValue.map((option) => option.value) + : [] + ) + } + /> + + + + setSelectedTagFrameworks( + Array.isArray(newValue) + ? newValue.map((option) => option.value) + : [] + ) + } + /> + + + + + + + Showing {sorted.length} of {catalog.length} standards + + {hasActiveFilters && ( + + )} + + + { + if (newValue !== null) setStatusFilter(newValue) + }} + > + All + Added + Not added + + + { + if (newValue) setSortOption(newValue) + }} + /> + + { + if (newViewMode !== null) setViewMode(newViewMode) + }} + > + + + + + + + + + + + + - - {filtered.map((standard) => { - // Multi-instance standards count 'Name#n' keys; each click adds another instance. - const instanceCount = selectedStandards.filter( - (key) => key.split('#')[0] === standard.name - ).length - const isSelected = instanceCount > 0 - return ( - - + + No standards match your search and filter criteria + + + Try adjusting your search terms or clearing some filters + + + )} + + {viewMode === 'card' ? ( + + {sorted.map((standard) => { + const instanceCount = instanceCountOf(standard) + const isSelected = instanceCount > 0 + const benchmarkTags = (standard.tag ?? []).filter( + (tag) => !tag.toLowerCase().includes('impact') + ) + return ( + + + + + {standard.label} + + + {standard.cat} + + + + {standard.secureScoreImpact > 0 && ( + + + + )} + {(standard.recommendedBy ?? []).map((source) => ( + + ))} + {isNewStandard(standard.addedDate) && ( + + )} + + + {standard.helpText} + + + + + {addButton(standard, instanceCount)} + + + + ) + })} + + ) : ( + + {sorted.map((standard) => { + const instanceCount = instanceCountOf(standard) + const isSelected = instanceCount > 0 + return ( + - - - {standard.label} - - - {standard.cat} - - - - {standard.secureScoreImpact > 0 && ( - + + + {standard.label} + + {isNewStandard(standard.addedDate) && ( - - )} - {(standard.recommendedBy ?? []).map((source) => ( + )} - ))} - - - {standard.helpText} - - - - - - - - ) - })} - {filtered.length === 0 && ( - - - No standards match this search. - - - )} - + + + } + secondary={ + + + {standard.helpText} + + + {(standard.tag ?? []) + .filter( + (tag) => !tag.toLowerCase().includes('impact') + ) + .slice(0, 3) + .map((tag) => ( + + ))} + {(standard.recommendedBy ?? []).length > 0 && ( + + • Recommended by:{' '} + {standard.recommendedBy.join(', ')} + + )} + {standard.secureScoreImpact > 0 && ( + + • +{standard.secureScoreImpact} Secure Score pts + + )} + + + } + sx={{ pr: 22 }} + /> + + {addButton(standard, instanceCount)} + + + ) + })} + + )} diff --git a/frontend/src/components/CippBaselines/CippBaselineStandardItem.jsx b/frontend/src/components/CippBaselines/CippBaselineStandardItem.jsx index a2ac421247..78b472d3fb 100644 --- a/frontend/src/components/CippBaselines/CippBaselineStandardItem.jsx +++ b/frontend/src/components/CippBaselines/CippBaselineStandardItem.jsx @@ -104,11 +104,14 @@ export const CippBaselineStandardItem = ({ ] // Seed the action posture: saved configuration (editing an existing baseline) wins, - // then the defaults for a freshly added standard. The settings fields seed themselves - // (saved value > recommended) inside CippBaselineStandardSettings. + // then the defaults for a freshly added standard - report only, never auto-remediate, + // until the operator explicitly enables it. The settings fields seed themselves + // (saved value > recommended) inside CippBaselineStandardSettings. savedConfig is a + // dependency so a form reset (reloading a template into a mounted editor) re-seeds + // the wiped fields; the undefined guard keeps live edits untouched. useEffect(() => { const postureDefaults = { - remediateEnabled: true, + remediateEnabled: false, alertEnabled: true, alertOnRemediate: false, } @@ -121,9 +124,9 @@ export const CippBaselineStandardItem = ({ } }) // eslint-disable-next-line react-hooks/exhaustive-deps - }, [fieldBase]) + }, [fieldBase, savedConfig]) - const remediateEnabled = watched?.remediateEnabled ?? true + const remediateEnabled = watched?.remediateEnabled ?? false const alertEnabled = watched?.alertEnabled ?? true const alertOnRemediate = watched?.alertOnRemediate ?? false const renderedExpected = renderExpectedValue( diff --git a/frontend/src/pages/tenant/baselines/template.jsx b/frontend/src/pages/tenant/baselines/template.jsx index 4503bb68f9..04688fc7b4 100644 --- a/frontend/src/pages/tenant/baselines/template.jsx +++ b/frontend/src/pages/tenant/baselines/template.jsx @@ -189,8 +189,13 @@ const StagePanel = ({ { label: 'Alert when remediated', field: 'alertOnRemediate', value: true }, ] const applyPostureToAll = (field, value) => { + // Force the value onto every standard: dirty + touched so the form registers + // the change even on fields the operator never interacted with. stage.standards.forEach((instanceKey) => { - formControl.setValue(`${instanceKey}.${field}`, value) + formControl.setValue(`${instanceKey}.${field}`, value, { + shouldDirty: true, + shouldTouch: true, + }) }) } @@ -236,7 +241,9 @@ const StagePanel = ({ unwrapValue(value), ]) ), - remediateEnabled: config.remediateEnabled ?? true, + // Report-only unless the operator explicitly enabled remediation - a + // missing value must never fail open into auto-fixing tenants. + remediateEnabled: config.remediateEnabled ?? false, alertEnabled: config.alertEnabled ?? true, alertOnRemediate: config.alertOnRemediate ?? false, } @@ -522,6 +529,10 @@ const Page = () => { const router = useRouter() const [activeStage, setActiveStage] = useState(0) const [loadedTemplateId, setLoadedTemplateId] = useState(null) + // The GUID the next save updates. Null means the save CREATES a baseline (new + // editor, or a clone before its first save); the save response's id is adopted + // so saving twice never creates twice. + const [saveTargetId, setSaveTargetId] = useState(null) const [stages, setStages] = useState(() => buildEditorStages(undefined)) const [dialogOpen, setDialogOpen] = useState(false) const [dialogStageIndex, setDialogStageIndex] = useState(0) @@ -541,6 +552,23 @@ const Page = () => { // and table), all alignment views for every tenant, and the standards catalog. const saveBaseline = ApiPostCall({ relatedQueryKeys: ['ListBaseline*'], + onResult: (result) => { + const savedId = result?.Metadata?.id + if (!savedId) return + // Adopt the saved baseline: the next save updates it instead of creating a + // duplicate, and the URL reflects it so a refresh keeps editing the same one. + // Matching loadedTemplateId also stops the render-phase loader from + // re-resetting the form when the refetched list arrives. + setSaveTargetId(savedId) + setLoadedTemplateId(savedId) + if (router.query.id !== savedId || router.query.clone) { + router.replace( + { pathname: router.pathname, query: { id: savedId } }, + undefined, + { shallow: true } + ) + } + }, }) // After a save, the natural next step is seeing where the tenants stand - offer a // no-changes check right away instead of ending the setup flow in silence. @@ -579,6 +607,7 @@ const Page = () => { // Render-phase reset (not an effect) so the switch happens before anything paints. if (template && template.GUID !== loadedTemplateId) { setLoadedTemplateId(template.GUID) + setSaveTargetId(router.query.clone ? null : template.GUID) setStages(buildEditorStages(template)) setActiveStage(0) setHasUnsavedChanges(false) @@ -791,7 +820,7 @@ const Page = () => { saveBaseline.mutate({ url: '/api/AddBaseline', data: { - GUID: router.query.clone ? undefined : (loadedTemplateId ?? undefined), + GUID: saveTargetId ?? undefined, templateName: values.templateName, description: values.description, // Send the selector's option objects as-is (label/value/type) so they can be From da95ad2af33a4beb672debe2d361bb87a6b10e90 Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Sat, 15 Aug 2026 13:53:54 +0200 Subject: [PATCH 054/226] Fix deny remediation --- .../Invoke-ExecUpdateBaselineDeviation.ps1 | 7 ++ .../pages/tenant/baselines/alignment/index.js | 72 +++++++++++-------- 2 files changed, 48 insertions(+), 31 deletions(-) diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ExecUpdateBaselineDeviation.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ExecUpdateBaselineDeviation.ps1 index 60b7989847..12fa48e77b 100644 --- a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ExecUpdateBaselineDeviation.ps1 +++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ExecUpdateBaselineDeviation.ps1 @@ -122,6 +122,13 @@ function Invoke-ExecUpdateBaselineDeviation { { $_ -in @('AcceptPath', 'DenyPath', 'ClearPath') } { $Path = $Request.Body.path if (-not $Path) { throw "$Action requires the property path." } + # A deny-delete verdict orders an OBJECT deletion on the next run. Only + # definitions with a delete executor (the detect-drift standards, where + # each path is a whole policy) can carry it out - anything else would + # park the row at Delete Pending forever. + if ($Action -eq 'DenyPath' -and -not (Get-CIPPBaselineDefinition -Name (($Standard -split '#')[0])).delete) { + throw "$Standard does not support deletion. Accept the property to tolerate it, or Deny the deviation to enforce the baseline configuration." + } $AcceptedPaths = if ($Entity.AcceptedPaths) { $Entity.AcceptedPaths | ConvertFrom-Json } else { [PSCustomObject]@{} } # Per-path verdicts: 'accept' tolerates that property's drift; 'denyDelete' # queues the path's object for deletion once delete executors exist. Both diff --git a/frontend/src/pages/tenant/baselines/alignment/index.js b/frontend/src/pages/tenant/baselines/alignment/index.js index a595d8ec23..de982f4d92 100644 --- a/frontend/src/pages/tenant/baselines/alignment/index.js +++ b/frontend/src/pages/tenant/baselines/alignment/index.js @@ -556,6 +556,13 @@ const Page = () => { }) const catalog = definitionsApi.data ?? [] + // A per-path deny queues an OBJECT deletion, so it only exists where the + // definition ships a delete executor (the detect-drift standards, where each + // path IS a policy). Ordinary standards get accept-only per-property actions - + // enforcing the baseline is the row-level Deny. + const supportsPathDeletion = (standardName) => + !!catalog.find((entry) => entry.name === `${standardName}`.split('#')[0]) + ?.delete const baselines = baselinesApi.data ?? [] const standardAggregates = aggregateApi.data?.standards ?? [] const tenant = { @@ -1309,23 +1316,24 @@ const Page = () => { Accept this property only )} - {!acceptedPath && ( - - )} + {!acceptedPath && + supportsPathDeletion(row.standardName) && ( + + )} ) @@ -1469,20 +1477,22 @@ const Page = () => { Accept this property only )} - {drifted && !acceptedPath && ( - - )} + {drifted && + !acceptedPath && + supportsPathDeletion(row.standardName) && ( + + )} ) From 5f7b527dc7e7e0876ea6bf91ffffd132608e58e5 Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Sat, 15 Aug 2026 14:32:22 +0200 Subject: [PATCH 055/226] temporary fix --- backend/Config/openapi.json | 6 ++++-- .../CIPPCore/Public/Baselines/Get-CIPPBaseline.ps1 | 8 ++++++-- .../Tools/GitHub/Invoke-ExecCommunityRepo.ps1 | 9 +++++---- 3 files changed, 15 insertions(+), 8 deletions(-) diff --git a/backend/Config/openapi.json b/backend/Config/openapi.json index 454444db4e..66ff513134 100644 --- a/backend/Config/openapi.json +++ b/backend/Config/openapi.json @@ -16327,7 +16327,8 @@ "type": "boolean" }, "FullName": { - "type": "string" + "type": "string", + "description": "Pretty-printed, not compressed: repo files are hand-edited on GitHub." }, "GUID": { "type": "string", @@ -16337,7 +16338,8 @@ "type": "string" }, "Message": { - "type": "string" + "type": "string", + "description": "Pretty-printed, not compressed: repo files are hand-edited on GitHub." }, "Path": { "type": "string" diff --git a/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaseline.ps1 b/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaseline.ps1 index cbaae9d33a..1a4865ebba 100644 --- a/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaseline.ps1 +++ b/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaseline.ps1 @@ -202,10 +202,14 @@ function Get-CIPPBaseline { } } - # Explicit rollout state rows for this baseline. + # Explicit rollout state rows for this baseline. 'Exported Template' is the + # community-export assignment placeholder - it shows in the editor's tenant + # selector so the operator knows to re-assign, but it is never a runnable + # tenant: no state, no work items, no resolved rows. $StateRows = Get-CIPPAzDataTableEntity @StateTable -Filter "PartitionKey eq '$SafeGuid'" $TenantStates = [System.Collections.Generic.List[object]]::new() foreach ($State in $StateRows) { + if ("$($State.RowKey)" -eq 'Exported Template') { continue } $TenantStates.Add((& $NewState $State.RowKey ([int]($State.currentStage ?? 1)) $State.enteredStageAt)) } @@ -220,7 +224,7 @@ function Get-CIPPBaseline { $Assignment.scopeId } } - $AssignedDomains = @($AssignedDomains | Where-Object { $_ -and $ExcludedTenants -notcontains $_ } | Select-Object -Unique) + $AssignedDomains = @($AssignedDomains | Where-Object { $_ -and $_ -ne 'Exported Template' -and $ExcludedTenants -notcontains $_ } | Select-Object -Unique) foreach ($Domain in $AssignedDomains) { if ($TenantStates.tenantFilter -notcontains $Domain) { $TenantStates.Add((& $NewState $Domain 1 $RolloutRow.updatedAt)) diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tools/GitHub/Invoke-ExecCommunityRepo.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tools/GitHub/Invoke-ExecCommunityRepo.ps1 index fbc2278de8..335a5d0b68 100644 --- a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tools/GitHub/Invoke-ExecCommunityRepo.ps1 +++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tools/GitHub/Invoke-ExecCommunityRepo.ps1 @@ -153,7 +153,8 @@ function Invoke-ExecCommunityRepo { $Basename = $DisplayName -replace '\s', '_' -replace '[^\w\d_]', '' $Path = '{0}/{1}.json' -f $TemplateEntity.PartitionKey, $Basename - $Results = Push-GitHubContent -FullName $Request.Body.FullName -Path $Path -Content ($TemplateEntity | ConvertTo-Json -Compress) -Message $Request.Body.Message -Branch $Branch + # Pretty-printed, not compressed: repo files are hand-edited on GitHub. + $Results = Push-GitHubContent -FullName $Request.Body.FullName -Path $Path -Content ($TemplateEntity | ConvertTo-Json -Depth 100) -Message $Request.Body.Message -Branch $Branch $Results = @{ resultText = "Template '$($DisplayName)' uploaded" @@ -182,11 +183,11 @@ function Invoke-ExecCommunityRepo { $DisplayName = "$($TemplateJson.Displayname ?? $TemplateJson.displayName ?? $TemplateJson.name ?? $TemplateEntity.RowKey)" $Basename = $DisplayName -replace '\s', '_' -replace '[^\w\d_]', '' $Path = '{0}/{1}.json' -f $TemplateEntity.PartitionKey, $Basename - $null = Push-GitHubContent -FullName $Request.Body.FullName -Path $Path -Content ($TemplateEntity | ConvertTo-Json -Compress -Depth 100) -Message $Message -Branch $Branch + $null = Push-GitHubContent -FullName $Request.Body.FullName -Path $Path -Content ($TemplateEntity | ConvertTo-Json -Depth 100) -Message $Message -Branch $Branch } $BaselineBasename = "$($Export.Baseline.templateName)" -replace '\s', '_' -replace '[^\w\d_]', '' $BaselinePath = 'BaselineTemplate/{0}.json' -f $BaselineBasename - $null = Push-GitHubContent -FullName $Request.Body.FullName -Path $BaselinePath -Content ($Export.Baseline | ConvertTo-Json -Compress -Depth 100) -Message $Message -Branch $Branch + $null = Push-GitHubContent -FullName $Request.Body.FullName -Path $BaselinePath -Content ($Export.Baseline | ConvertTo-Json -Depth 100) -Message $Message -Branch $Branch $Results = @{ resultText = "Baseline '$($Export.Baseline.templateName)' uploaded with $(@($Export.Templates).Count) related template$(if (@($Export.Templates).Count -eq 1) { '' } else { 's' })" state = 'success' @@ -294,7 +295,7 @@ function Invoke-ExecCommunityRepo { $Basename = $LatestScript.ScriptName -replace '\s', '_' -replace '[^\w\d_]', '' $Path = 'CustomTests/{0}.json' -f $Basename - $null = Push-GitHubContent -FullName $Request.Body.FullName -Path $Path -Content ($ExportData | ConvertTo-Json -Compress -Depth 10) -Message $Request.Body.Message -Branch $Branch + $null = Push-GitHubContent -FullName $Request.Body.FullName -Path $Path -Content ($ExportData | ConvertTo-Json -Depth 10) -Message $Request.Body.Message -Branch $Branch $Results = @{ resultText = "Custom test '$($LatestScript.ScriptName)' uploaded" From 888e38c5d1a67b93d3883acd4c0686d7ea2473f4 Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Sat, 15 Aug 2026 15:08:01 +0200 Subject: [PATCH 056/226] more granular permissions --- backend/Config/openapi.json | 18 +++++++++--------- .../Timer Functions/Start-TableCleanup.ps1 | 13 +++++++++++++ .../Tenant/Standards/Invoke-AddBaseline.ps1 | 2 +- .../Standards/Invoke-ExecBaselineOverride.ps1 | 2 +- .../Standards/Invoke-ExecBaselineRun.ps1 | 2 +- .../Standards/Invoke-ExecBaselineStage.ps1 | 2 +- .../Invoke-ExecUpdateBaselineDeviation.ps1 | 2 +- .../Standards/Invoke-ListBaselineAlignment.ps1 | 2 +- .../Standards/Invoke-ListBaselineStandards.ps1 | 2 +- .../Tenant/Standards/Invoke-ListBaselines.ps1 | 2 +- .../Tenant/Standards/Invoke-RemoveBaseline.ps1 | 2 +- frontend/src/data/cipp-roles.json | 3 ++- frontend/src/layouts/config.js | 5 +++-- 13 files changed, 36 insertions(+), 21 deletions(-) diff --git a/backend/Config/openapi.json b/backend/Config/openapi.json index 66ff513134..6a493af4ac 100644 --- a/backend/Config/openapi.json +++ b/backend/Config/openapi.json @@ -764,7 +764,7 @@ "bearerAuth": [] } ], - "x-cipp-role": "Tenant.Standards.ReadWrite", + "x-cipp-role": "Tenant.Baselines.ReadWrite", "x-cipp-reads-via": [ "New-CIPPBaseline" ] @@ -14386,7 +14386,7 @@ "bearerAuth": [] } ], - "x-cipp-role": "Tenant.Standards.ReadWrite" + "x-cipp-role": "Tenant.Baselines.ReadWrite" } }, "/api/ExecBaselineRun": { @@ -14461,7 +14461,7 @@ "bearerAuth": [] } ], - "x-cipp-role": "Tenant.Standards.ReadWrite" + "x-cipp-role": "Tenant.BaselinesRun.ReadWrite" } }, "/api/ExecBaselineStage": { @@ -14525,7 +14525,7 @@ "bearerAuth": [] } ], - "x-cipp-role": "Tenant.Standards.ReadWrite" + "x-cipp-role": "Tenant.Baselines.ReadWrite" } }, "/api/ExecBECCheck": { @@ -34607,7 +34607,7 @@ "bearerAuth": [] } ], - "x-cipp-role": "Tenant.Standards.ReadWrite" + "x-cipp-role": "Tenant.BaselinesDeviations.ReadWrite" } }, "/api/ExecUpdateDriftDeviation": { @@ -37997,7 +37997,7 @@ "bearerAuth": [] } ], - "x-cipp-role": "Tenant.Standards.Read" + "x-cipp-role": "Tenant.Baselines.Read" } }, "/api/ListBaselines": { @@ -38064,7 +38064,7 @@ "bearerAuth": [] } ], - "x-cipp-role": "Tenant.Standards.Read" + "x-cipp-role": "Tenant.Baselines.Read" } }, "/api/ListBaselineStandards": { @@ -38105,7 +38105,7 @@ "bearerAuth": [] } ], - "x-cipp-role": "Tenant.Standards.Read" + "x-cipp-role": "Tenant.Baselines.Read" } }, "/api/ListBasicAuth": { @@ -59047,7 +59047,7 @@ "bearerAuth": [] } ], - "x-cipp-role": "Tenant.Standards.ReadWrite" + "x-cipp-role": "Tenant.Baselines.ReadWrite" } }, "/api/RemoveBPATemplate": { diff --git a/backend/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-TableCleanup.ps1 b/backend/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-TableCleanup.ps1 index 69879bc6bc..ef2c704964 100644 --- a/backend/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-TableCleanup.ps1 +++ b/backend/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-TableCleanup.ps1 @@ -115,6 +115,19 @@ function Start-TableCleanup { Property = @('PartitionKey', 'RowKey', 'ETag') } } + @{ + # Baseline run/audit history: 90-day rolling retention. Active tenant-standard + # pairs rewrite rows every 12h run, so recent history always survives; pairs + # that stopped resolving age out entirely with their rows. + FunctionName = 'TableCleanupTask' + Type = 'CleanupRule' + TableName = 'BaselineHistory' + DataTableProps = @{ + Filter = "Timestamp lt datetime'$((Get-Date).AddDays(-90).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ'))'" + First = 10000 + Property = @('PartitionKey', 'RowKey', 'ETag') + } + } @{ FunctionName = 'TableCleanupTask' Type = 'DeleteTable' diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-AddBaseline.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-AddBaseline.ps1 index 7aa6685d0a..be5bc7ba8d 100644 --- a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-AddBaseline.ps1 +++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-AddBaseline.ps1 @@ -3,7 +3,7 @@ function Invoke-AddBaseline { .FUNCTIONALITY Entrypoint .ROLE - Tenant.Standards.ReadWrite + Tenant.Baselines.ReadWrite .DESCRIPTION Creates or updates a baseline. There is no baseline blob: the Baselines delta rows (design doc §4.1) are the editable source of truth for every standard's diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ExecBaselineOverride.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ExecBaselineOverride.ps1 index 432d90b8f0..3b00d8dcca 100644 --- a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ExecBaselineOverride.ps1 +++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ExecBaselineOverride.ps1 @@ -3,7 +3,7 @@ function Invoke-ExecBaselineOverride { .FUNCTIONALITY Entrypoint .ROLE - Tenant.Standards.ReadWrite + Tenant.Baselines.ReadWrite .DESCRIPTION Creates or removes a tenant-scoped delta (design doc §4.1) overriding one standard for one tenant. Presence is the override: the delta's expectedValue (the configured variable diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ExecBaselineRun.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ExecBaselineRun.ps1 index d3d93a0621..b8c08b144e 100644 --- a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ExecBaselineRun.ps1 +++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ExecBaselineRun.ps1 @@ -3,7 +3,7 @@ function Invoke-ExecBaselineRun { .FUNCTIONALITY Entrypoint .ROLE - Tenant.Standards.ReadWrite + Tenant.BaselinesRun.ReadWrite .DESCRIPTION Starts an on-demand baseline run directly as a durable orchestration: a full baseline run (templateId), a tenant- or standard-scoped run, a compare (no remediation), or a diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ExecBaselineStage.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ExecBaselineStage.ps1 index 633e4512b9..3a108a647d 100644 --- a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ExecBaselineStage.ps1 +++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ExecBaselineStage.ps1 @@ -3,7 +3,7 @@ function Invoke-ExecBaselineStage { .FUNCTIONALITY Entrypoint .ROLE - Tenant.Standards.ReadWrite + Tenant.Baselines.ReadWrite .DESCRIPTION Advances a tenant to the next stage of a baseline (manual stage approval). The tenant receives all standards from the new stage on the next engine run. diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ExecUpdateBaselineDeviation.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ExecUpdateBaselineDeviation.ps1 index 12fa48e77b..7042137b20 100644 --- a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ExecUpdateBaselineDeviation.ps1 +++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ExecUpdateBaselineDeviation.ps1 @@ -3,7 +3,7 @@ function Invoke-ExecUpdateBaselineDeviation { .FUNCTIONALITY Entrypoint .ROLE - Tenant.Standards.ReadWrite + Tenant.BaselinesDeviations.ReadWrite .DESCRIPTION Triage for baseline drift on a resolved (tenant, standard) row: Accept (reason required, optional expiry, optional remediate-on-expire), diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ListBaselineAlignment.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ListBaselineAlignment.ps1 index e9551aa094..0bf3359f26 100644 --- a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ListBaselineAlignment.ps1 +++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ListBaselineAlignment.ps1 @@ -3,7 +3,7 @@ function Invoke-ListBaselineAlignment { .FUNCTIONALITY Entrypoint .ROLE - Tenant.Standards.Read + Tenant.Baselines.Read .DESCRIPTION Baseline alignment data. With ?tenantFilter= returns the tenant payload (summary, resolved rows with history, stage states, deviation feed); with diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ListBaselineStandards.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ListBaselineStandards.ps1 index 090922b547..a6388c4ea6 100644 --- a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ListBaselineStandards.ps1 +++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ListBaselineStandards.ps1 @@ -3,7 +3,7 @@ function Invoke-ListBaselineStandards { .FUNCTIONALITY Entrypoint .ROLE - Tenant.Standards.Read + Tenant.Baselines.Read .DESCRIPTION Lists the Baseline definition catalog: the standards available to add to a baseline, including their configurable variables and metadata. diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ListBaselines.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ListBaselines.ps1 index 63ea8ff167..0a4c16d240 100644 --- a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ListBaselines.ps1 +++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ListBaselines.ps1 @@ -3,7 +3,7 @@ function Invoke-ListBaselines { .FUNCTIONALITY Entrypoint .ROLE - Tenant.Standards.Read + Tenant.Baselines.Read .DESCRIPTION Lists baselines with their stages, per-stage rollout occupancy, and per-tenant stage states. diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-RemoveBaseline.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-RemoveBaseline.ps1 index a42fa33f7c..794d2f0c73 100644 --- a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-RemoveBaseline.ps1 +++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-RemoveBaseline.ps1 @@ -3,7 +3,7 @@ function Invoke-RemoveBaseline { .FUNCTIONALITY Entrypoint .ROLE - Tenant.Standards.ReadWrite + Tenant.Baselines.ReadWrite .DESCRIPTION Deletes a baseline with its rollout state, delta rows, and resolved rows - the alignment view reflects the removal immediately. diff --git a/frontend/src/data/cipp-roles.json b/frontend/src/data/cipp-roles.json index ac3c389f65..750449ac13 100644 --- a/frontend/src/data/cipp-roles.json +++ b/frontend/src/data/cipp-roles.json @@ -18,7 +18,8 @@ "CIPP.SuperAdmin.*", "CIPP.Admin.*", "CIPP.AppSettings.*", - "Tenant.Standards.ReadWrite" + "Tenant.Standards.ReadWrite", + "Tenant.Baselines.ReadWrite" ] }, "admin": { diff --git a/frontend/src/layouts/config.js b/frontend/src/layouts/config.js index 3be081ae15..6d35ba95d2 100644 --- a/frontend/src/layouts/config.js +++ b/frontend/src/layouts/config.js @@ -215,6 +215,7 @@ export const nativeMenuItems = [ title: 'Standards & Drift', permissions: [ 'Tenant.Standards.*', + 'Tenant.Baselines.*', 'Tenant.BestPracticeAnalyser.*', 'Tenant.DomainAnalyser.*', ], @@ -225,12 +226,12 @@ export const nativeMenuItems = [ permissions: ['Tenant.Standards.*'], scope: 'global', }, - // Baselines mockup - hidden from the nav for now; reach it directly + // Baselines - hidden from the nav for now; reach it directly // at /tenant/baselines // { // title: 'Baselines (Preview)', // path: '/tenant/baselines', - // permissions: ['Tenant.Standards.*'], + // permissions: ['Tenant.Baselines.*'], // scope: 'global', // }, { From 162e81df2d71c0215eb411e15631b438f55255dd Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Sat, 15 Aug 2026 15:33:10 +0200 Subject: [PATCH 057/226] baseline items --- .../Entra (AAD) Standards/AdminSSPR.json | 1 + .../BitLockerKeysForOwnedDevice.json | 1 + .../DisableAppCreation.json | 1 + .../DisableSecurityGroupUsers.json | 1 + .../DisableTenantCreation.json | 1 + .../Entra (AAD) Standards/GuestInvite.json | 1 + .../OauthConsentLowSec.json | 6 ++ .../Entra (AAD) Standards/UndoOauth.json | 1 + .../DisableGuestDirectory.json | 1 + .../Baselines/Get-CIPPBaselineAlignment.ps1 | 44 ++++++++++ .../Invoke-CIPPBaselineGraphRequest.ps1 | 9 ++- .../Baselines/Set-CIPPBaselineTrendPoint.ps1 | 80 ++++++++++++------- .../pages/tenant/baselines/alignment/index.js | 55 +++++++++++++ 13 files changed, 168 insertions(+), 34 deletions(-) diff --git a/backend/Config/BaselineStandards/Entra (AAD) Standards/AdminSSPR.json b/backend/Config/BaselineStandards/Entra (AAD) Standards/AdminSSPR.json index c4a4c5db00..66a49b9e5d 100644 --- a/backend/Config/BaselineStandards/Entra (AAD) Standards/AdminSSPR.json +++ b/backend/Config/BaselineStandards/Entra (AAD) Standards/AdminSSPR.json @@ -41,6 +41,7 @@ "requests": [ { "method": "PATCH", + "asApp": false, "uri": "policies/authorizationPolicy/authorizationPolicy", "body": { "allowedToUseSSPR": "%allowSSPR%" diff --git a/backend/Config/BaselineStandards/Entra (AAD) Standards/BitLockerKeysForOwnedDevice.json b/backend/Config/BaselineStandards/Entra (AAD) Standards/BitLockerKeysForOwnedDevice.json index 66eac8ab72..bafe1f818b 100644 --- a/backend/Config/BaselineStandards/Entra (AAD) Standards/BitLockerKeysForOwnedDevice.json +++ b/backend/Config/BaselineStandards/Entra (AAD) Standards/BitLockerKeysForOwnedDevice.json @@ -40,6 +40,7 @@ "requests": [ { "method": "PATCH", + "asApp": false, "uri": "policies/authorizationPolicy/authorizationPolicy", "body": { "defaultUserRolePermissions": { diff --git a/backend/Config/BaselineStandards/Entra (AAD) Standards/DisableAppCreation.json b/backend/Config/BaselineStandards/Entra (AAD) Standards/DisableAppCreation.json index 731d7a2c50..435081e3ff 100644 --- a/backend/Config/BaselineStandards/Entra (AAD) Standards/DisableAppCreation.json +++ b/backend/Config/BaselineStandards/Entra (AAD) Standards/DisableAppCreation.json @@ -42,6 +42,7 @@ "requests": [ { "method": "PATCH", + "asApp": false, "uri": "policies/authorizationPolicy/authorizationPolicy", "body": { "defaultUserRolePermissions": { diff --git a/backend/Config/BaselineStandards/Entra (AAD) Standards/DisableSecurityGroupUsers.json b/backend/Config/BaselineStandards/Entra (AAD) Standards/DisableSecurityGroupUsers.json index ce3c118fb9..cc62a43f46 100644 --- a/backend/Config/BaselineStandards/Entra (AAD) Standards/DisableSecurityGroupUsers.json +++ b/backend/Config/BaselineStandards/Entra (AAD) Standards/DisableSecurityGroupUsers.json @@ -43,6 +43,7 @@ "requests": [ { "method": "PATCH", + "asApp": false, "uri": "policies/authorizationPolicy/authorizationPolicy", "body": { "defaultUserRolePermissions": { diff --git a/backend/Config/BaselineStandards/Entra (AAD) Standards/DisableTenantCreation.json b/backend/Config/BaselineStandards/Entra (AAD) Standards/DisableTenantCreation.json index 0127453ec5..f41e4f23f4 100644 --- a/backend/Config/BaselineStandards/Entra (AAD) Standards/DisableTenantCreation.json +++ b/backend/Config/BaselineStandards/Entra (AAD) Standards/DisableTenantCreation.json @@ -47,6 +47,7 @@ "requests": [ { "method": "PATCH", + "asApp": false, "uri": "policies/authorizationPolicy/authorizationPolicy", "body": { "defaultUserRolePermissions": { diff --git a/backend/Config/BaselineStandards/Entra (AAD) Standards/GuestInvite.json b/backend/Config/BaselineStandards/Entra (AAD) Standards/GuestInvite.json index 0e068158c5..279c3913c8 100644 --- a/backend/Config/BaselineStandards/Entra (AAD) Standards/GuestInvite.json +++ b/backend/Config/BaselineStandards/Entra (AAD) Standards/GuestInvite.json @@ -62,6 +62,7 @@ "requests": [ { "method": "PATCH", + "asApp": false, "uri": "policies/authorizationPolicy/authorizationPolicy", "body": { "allowInvitesFrom": "%allowInvitesFrom%" diff --git a/backend/Config/BaselineStandards/Entra (AAD) Standards/OauthConsentLowSec.json b/backend/Config/BaselineStandards/Entra (AAD) Standards/OauthConsentLowSec.json index 187c055388..0985f8b93d 100644 --- a/backend/Config/BaselineStandards/Entra (AAD) Standards/OauthConsentLowSec.json +++ b/backend/Config/BaselineStandards/Entra (AAD) Standards/OauthConsentLowSec.json @@ -30,6 +30,7 @@ "requests": [ { "method": "PATCH", + "asApp": false, "uri": "policies/authorizationPolicy/authorizationPolicy", "body": { "permissionGrantPolicyIdsAssignedToDefaultUserRole": [ @@ -39,6 +40,7 @@ }, { "method": "POST", + "asApp": false, "uri": "servicePrincipals(appId='00000003-0000-0000-c000-000000000000')/delegatedPermissionClassifications", "body": { "permissionName": "offline_access", @@ -48,6 +50,7 @@ }, { "method": "POST", + "asApp": false, "uri": "servicePrincipals(appId='00000003-0000-0000-c000-000000000000')/delegatedPermissionClassifications", "body": { "permissionName": "openid", @@ -57,6 +60,7 @@ }, { "method": "POST", + "asApp": false, "uri": "servicePrincipals(appId='00000003-0000-0000-c000-000000000000')/delegatedPermissionClassifications", "body": { "permissionName": "User.Read", @@ -66,6 +70,7 @@ }, { "method": "POST", + "asApp": false, "uri": "servicePrincipals(appId='00000003-0000-0000-c000-000000000000')/delegatedPermissionClassifications", "body": { "permissionName": "profile", @@ -75,6 +80,7 @@ }, { "method": "POST", + "asApp": false, "uri": "servicePrincipals(appId='00000003-0000-0000-c000-000000000000')/delegatedPermissionClassifications", "body": { "permissionName": "email", diff --git a/backend/Config/BaselineStandards/Entra (AAD) Standards/UndoOauth.json b/backend/Config/BaselineStandards/Entra (AAD) Standards/UndoOauth.json index 977bbf2c29..1ccc22241a 100644 --- a/backend/Config/BaselineStandards/Entra (AAD) Standards/UndoOauth.json +++ b/backend/Config/BaselineStandards/Entra (AAD) Standards/UndoOauth.json @@ -27,6 +27,7 @@ "requests": [ { "method": "PATCH", + "asApp": false, "uri": "policies/authorizationPolicy/authorizationPolicy", "body": { "permissionGrantPolicyIdsAssignedToDefaultUserRole": [ diff --git a/backend/Config/BaselineStandards/Global Standards/DisableGuestDirectory.json b/backend/Config/BaselineStandards/Global Standards/DisableGuestDirectory.json index 018be8354e..f9230b63c6 100644 --- a/backend/Config/BaselineStandards/Global Standards/DisableGuestDirectory.json +++ b/backend/Config/BaselineStandards/Global Standards/DisableGuestDirectory.json @@ -66,6 +66,7 @@ "requests": [ { "method": "PATCH", + "asApp": false, "uri": "policies/authorizationPolicy/authorizationPolicy", "body": { "guestUserRoleId": "%guestUserRoleId%" diff --git a/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineAlignment.ps1 b/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineAlignment.ps1 index cbd8ba3dcb..6d8930c6ac 100644 --- a/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineAlignment.ps1 +++ b/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineAlignment.ps1 @@ -376,11 +376,31 @@ function Get-CIPPBaselineAlignment { $Summary.tenantId = $TenantFilter $Summary.displayName = ($Rows | Select-Object -First 1).tenantName ?? $TenantFilter + # This tenant's trend: the daily rollups Set-CIPPBaselineTrendPoint writes (last + # 90 days), with today's point always replaced by the LIVE score - same shape as + # the fleet trend so the same chart renders it. + $Today = (Get-Date).ToUniversalTime().ToString('yyyy-MM-dd') + $Trend = [System.Collections.Generic.List[object]]::new() + try { + $TrendTable = Get-CippTable -tablename 'BaselineTrend' + $Cutoff = (Get-Date).ToUniversalTime().AddDays(-90).ToString('yyyy-MM-dd') + $TrendRows = @(Get-CIPPAzDataTableEntity @TrendTable -Filter "PartitionKey eq 'tenant_$SafeTenant' and RowKey ge '$Cutoff' and RowKey lt '$Today'") | Sort-Object -Property RowKey + foreach ($Point in $TrendRows) { + $Trend.Add([PSCustomObject]@{ date = $Point.RowKey; aligned = [int]$Point.Aligned; verified = [int]$Point.Verified }) + } + } catch { + Write-Information "Baseline tenant trend read skipped: $($_.Exception.Message)" + } + if ($Rows.Count -gt 0) { + $Trend.Add([PSCustomObject]@{ date = $Today; aligned = $Summary.alignedPercentage; verified = $Summary.verifiedPercentage }) + } + return [PSCustomObject]@{ summary = [PSCustomObject]$Summary rows = @($Rows) stageStates = @($StageStates) deviationFeed = @($Feed | Sort-Object -Property timestamp -Descending) + trend = @($Trend) } } @@ -388,9 +408,32 @@ function Get-CIPPBaselineAlignment { $Entities = Get-CIPPAzDataTableEntity @ResolvedTable -Filter "PartitionKey ne ''" $Rows = @($Entities | ForEach-Object { Convert-CIPPBaselineResolvedEntity -Entity $_ -Definitions $Definitions -ResolveTemplateName $ResolveTemplateName }) + # Per-standard trends in ONE range scan over the 'standard_*' partitions (keys + # sanitize '#' to '~'), attached to each standard so the offcanvas charts without + # another call. Today's point is always the LIVE score, appended per group below. + $Today = (Get-Date).ToUniversalTime().ToString('yyyy-MM-dd') + $StandardTrends = @{} + try { + $TrendTable = Get-CippTable -tablename 'BaselineTrend' + $Cutoff = (Get-Date).ToUniversalTime().AddDays(-90).ToString('yyyy-MM-dd') + $StandardTrendRows = @(Get-CIPPAzDataTableEntity @TrendTable -Filter ("PartitionKey ge 'standard_' and PartitionKey lt 'standard{0}' and RowKey ge '{1}' and RowKey lt '{2}'" -f [char]0x60, $Cutoff, $Today)) + foreach ($Point in ($StandardTrendRows | Sort-Object -Property RowKey)) { + $StandardKey = "$($Point.PartitionKey)".Substring(9) -replace '~', '#' + if (-not $StandardTrends.ContainsKey($StandardKey)) { + $StandardTrends[$StandardKey] = [System.Collections.Generic.List[object]]::new() + } + $StandardTrends[$StandardKey].Add([PSCustomObject]@{ date = $Point.RowKey; aligned = [int]$Point.Aligned; verified = [int]$Point.Verified }) + } + } catch { + Write-Information "Baseline standard trend read skipped: $($_.Exception.Message)" + } + $Standards = foreach ($Group in ($Rows | Group-Object -Property standardName)) { $First = $Group.Group | Select-Object -First 1 $Scores = & $ScoreRows $Group.Group + $TrendPoints = [System.Collections.Generic.List[object]]::new() + foreach ($Point in @($StandardTrends[$Group.Name] ?? @())) { $TrendPoints.Add($Point) } + $TrendPoints.Add([PSCustomObject]@{ date = $Today; aligned = $Scores.alignedPercentage; verified = $Scores.verifiedPercentage }) [PSCustomObject]([ordered]@{ standardName = $Group.Name standardLabel = $First.standardLabel @@ -399,6 +442,7 @@ function Get-CIPPBaselineAlignment { secureScoreImpact = $First.secureScoreImpact totalTenants = $Scores.total rows = @($Group.Group) + trend = @($TrendPoints) } + $Scores) } diff --git a/backend/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineGraphRequest.ps1 b/backend/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineGraphRequest.ps1 index 2142740546..4520963382 100644 --- a/backend/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineGraphRequest.ps1 +++ b/backend/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineGraphRequest.ps1 @@ -5,8 +5,11 @@ function Invoke-CIPPBaselineGraphRequest { .DESCRIPTION One script for the whole request type - the ordered array supports remediations that need several Graph calls. Each entry is { method (PATCH/POST/PUT), uri (relative to - the beta endpoint), body, continueOnError }. The spec arrives fully rendered (%var% + - tenant tokens resolved). + the beta endpoint), body, continueOnError, asApp }. The spec arrives fully rendered + (%var% + tenant tokens resolved). asApp defaults to true (app-only); a step sets + asApp: false where the SAM app holds the permission only as a delegated scope - + e.g. Policy.ReadWrite.Authorization, so every authorizationPolicy write must go + delegated or Graph returns 403. .FUNCTIONALITY Internal #> @@ -19,7 +22,7 @@ function Invoke-CIPPBaselineGraphRequest { foreach ($Step in @($Remediate.requests)) { if (-not $Step) { continue } try { - $null = New-GraphPostRequest -tenantid $TenantFilter -uri "https://graph.microsoft.com/beta/$($Step.uri)" -type ($Step.method ?? 'PATCH') -body (ConvertTo-Json -Compress -Depth 100 -InputObject $Step.body) -AsApp $true + $null = New-GraphPostRequest -tenantid $TenantFilter -uri "https://graph.microsoft.com/beta/$($Step.uri)" -type ($Step.method ?? 'PATCH') -body (ConvertTo-Json -Compress -Depth 100 -InputObject $Step.body) -AsApp ([bool]($Step.asApp ?? $true)) } catch { if ($Step.continueOnError -eq $true) { Write-Information "Baselines: $($Step.method) $($Step.uri) on $TenantFilter continued past: $($_.Exception.Message)" diff --git a/backend/Modules/CIPPCore/Public/Baselines/Set-CIPPBaselineTrendPoint.ps1 b/backend/Modules/CIPPCore/Public/Baselines/Set-CIPPBaselineTrendPoint.ps1 index 26018b92a0..1eada9eb28 100644 --- a/backend/Modules/CIPPCore/Public/Baselines/Set-CIPPBaselineTrendPoint.ps1 +++ b/backend/Modules/CIPPCore/Public/Baselines/Set-CIPPBaselineTrendPoint.ps1 @@ -1,15 +1,18 @@ function Set-CIPPBaselineTrendPoint { <# .SYNOPSIS - Upserts today's fleet compliance rollup into the BaselineTrend table. + Upserts today's compliance rollups into the BaselineTrend table. .DESCRIPTION - One row per UTC day (PartitionKey 'fleet', RowKey yyyy-MM-dd, so the partition sorts - chronologically), written after every orchestrated baseline run finishes - later runs - the same day overwrite the day's point with the newer state. The Fleet Overview trend - chart reads this partition; before these rollups existed it could only ever show a - single live point. Buckets mirror the scoring in Get-CIPPBaselineAlignment: aligned = - Compliant + Accepted, verified = Compliant only, drift includes Partially Accepted, - and License Missing / No Data rows are excluded from the applicable base. + One row per UTC day per bucket, written after every orchestrated baseline run + finishes - later runs the same day overwrite the day's point with the newer state. + Buckets: 'fleet' (the Fleet Overview trend chart), 'tenant_' (the tenant + view's trend chart) and 'standard_' (the standard offcanvas trend chart; + '#' sanitized to '~' - forbidden in Azure Table keys). All three come from the one + resolved-store read, so per-tenant and per-standard points cost nothing extra. + RowKey is yyyy-MM-dd so every partition sorts chronologically. Buckets mirror the + scoring in Get-CIPPBaselineAlignment: aligned = Compliant + Accepted, verified = + Compliant only, drift includes Partially Accepted, and License Missing / No Data + rows are excluded from the applicable base. .FUNCTIONALITY Internal #> @@ -20,30 +23,45 @@ function Set-CIPPBaselineTrendPoint { $Rows = @(Get-CIPPAzDataTableEntity @ResolvedTable -Filter "PartitionKey ne ''") if ($Rows.Count -eq 0) { return } - $Total = $Rows.Count - $LicenseMissing = @($Rows | Where-Object { $_.Status -eq 'Skipped - No License' }).Count - $NoData = @($Rows | Where-Object { $_.Status -eq 'No Data' }).Count - $Applicable = $Total - $LicenseMissing - $NoData - $Compliant = @($Rows | Where-Object { $_.Status -eq 'Compliant' }).Count - $Accepted = @($Rows | Where-Object { $_.Status -eq 'Accepted' }).Count - $Drift = @($Rows | Where-Object { $_.Status -in @('Drift', 'Partially Accepted') }).Count - $Denied = @($Rows | Where-Object { $_.Status -like 'Denied - *' }).Count - $Pct = { param($Count) if ($Applicable) { [math]::Round(($Count / $Applicable) * 100) } else { 0 } } - + $Day = (Get-Date).ToUniversalTime().ToString('yyyy-MM-dd') $TrendTable = Get-CippTable -tablename 'BaselineTrend' $TrendTable.Force = $true - Add-CIPPAzDataTableEntity @TrendTable -Entity @{ - PartitionKey = 'fleet' - RowKey = (Get-Date).ToUniversalTime().ToString('yyyy-MM-dd') - Aligned = [int](& $Pct ($Compliant + $Accepted)) - Verified = [int](& $Pct $Compliant) - Compliant = [int]$Compliant - Accepted = [int]$Accepted - Drift = [int]$Drift - Denied = [int]$Denied - LicenseMissing = [int]$LicenseMissing - Total = [int]$Total - Applicable = [int]$Applicable - CapturedAt = [int64]([datetimeoffset]::UtcNow.ToUnixTimeSeconds()) + + $WritePoint = { + param($PartitionKey, $BucketRows) + $BucketRows = @($BucketRows) + $Total = $BucketRows.Count + $LicenseMissing = @($BucketRows | Where-Object { $_.Status -eq 'Skipped - No License' }).Count + $NoData = @($BucketRows | Where-Object { $_.Status -eq 'No Data' }).Count + $Applicable = $Total - $LicenseMissing - $NoData + $Compliant = @($BucketRows | Where-Object { $_.Status -eq 'Compliant' }).Count + $Accepted = @($BucketRows | Where-Object { $_.Status -eq 'Accepted' }).Count + $Drift = @($BucketRows | Where-Object { $_.Status -in @('Drift', 'Partially Accepted') }).Count + $Denied = @($BucketRows | Where-Object { $_.Status -like 'Denied - *' }).Count + $Pct = { param($Count) if ($Applicable) { [math]::Round(($Count / $Applicable) * 100) } else { 0 } } + + Add-CIPPAzDataTableEntity @TrendTable -Entity @{ + PartitionKey = "$PartitionKey" + RowKey = $Day + Aligned = [int](& $Pct ($Compliant + $Accepted)) + Verified = [int](& $Pct $Compliant) + Compliant = [int]$Compliant + Accepted = [int]$Accepted + Drift = [int]$Drift + Denied = [int]$Denied + LicenseMissing = [int]$LicenseMissing + Total = [int]$Total + Applicable = [int]$Applicable + CapturedAt = [int64]([datetimeoffset]::UtcNow.ToUnixTimeSeconds()) + } + } + + & $WritePoint 'fleet' $Rows + foreach ($Group in ($Rows | Group-Object -Property PartitionKey)) { + & $WritePoint ('tenant_{0}' -f $Group.Name) $Group.Group + } + foreach ($Group in ($Rows | Group-Object -Property StandardName)) { + if (-not $Group.Name) { continue } + & $WritePoint ('standard_{0}' -f ($Group.Name -replace '#', '~')) $Group.Group } } diff --git a/frontend/src/pages/tenant/baselines/alignment/index.js b/frontend/src/pages/tenant/baselines/alignment/index.js index de982f4d92..8d590e3e1b 100644 --- a/frontend/src/pages/tenant/baselines/alignment/index.js +++ b/frontend/src/pages/tenant/baselines/alignment/index.js @@ -65,6 +65,7 @@ import { CippDataTable } from '../../../../components/CippTable/CippDataTable' import { CippQueueTracker } from '../../../../components/CippTable/CippQueueTracker' import { CippHead } from '../../../../components/CippComponents/CippHead' import { CippInfoBar } from '../../../../components/CippCards/CippInfoBar' +import { CippChartCard } from '../../../../components/CippCards/CippChartCard' import CippButtonCard from '../../../../components/CippCards/CippButtonCard' import { CippApiDialog } from '../../../../components/CippComponents/CippApiDialog' import { CippApiLogsDrawer } from '../../../../components/CippComponents/CippApiLogsDrawer' @@ -583,6 +584,11 @@ const Page = () => { rows: resolvedApi.data?.rows ?? [], } const stageStates = resolvedApi.data?.stageStates ?? [] + // The API serializes single-element arrays as a bare object; the chart needs a + // real array (and a lone point is hidden anyway - it is just today's score). + const tenantTrend = Array.isArray(resolvedApi.data?.trend) + ? resolvedApi.data.trend + : [] const triageFormFields = ({ formHook }) => ( @@ -1682,6 +1688,32 @@ const Page = () => { : 'None', }, ])} + {/* A single point is just today's live score (already listed above) - the + chart earns its space once there is an actual line to draw. */} + {Array.isArray(row.trend) && row.trend.length > 1 && ( + + ({ + x: point.date, + y: point.aligned, + })), + }, + { + name: 'Compliant with baseline', + data: row.trend.map((point) => ({ + x: point.date, + y: point.verified, + })), + }, + ]} + /> + + )} { {tenantScoreBar} + {tenantTrend.length > 1 && ( + ({ + x: point.date, + y: point.aligned, + })), + }, + { + name: 'Compliant with baseline', + data: tenantTrend.map((point) => ({ + x: point.date, + y: point.verified, + })), + }, + ]} + /> + )} {rolloutCard} Date: Sat, 15 Aug 2026 15:53:46 +0200 Subject: [PATCH 058/226] fixes for baselines --- .../Defender Standards/AtpPolicyForO365.json | 21 +- .../AuthMethodsSettings.json | 101 ++++++++++ .../PWcompanionAppAllowedState.json | 85 ++++++++ .../PWdisplayAppInformationRequiredState.json | 79 ++++++++ .../SecurityDefaults.json | 47 +++++ .../Exchange Standards/AutoExpandArchive.json | 17 +- .../DisableBasicAuthSMTP.json | 16 +- .../DisableExternalCalendarSharing.json | 65 +++++++ .../TeamsMeetingsByDefault.json | 5 +- .../ActivityBasedTimeout.json | 18 +- .../Manual Tasks/ManualTask.json | 1 + .../SharePoint Standards/DisableReshare.json | 1 + .../DisableSharePointLegacyAuth.json | 1 + .../sharingCapability.json | 2 + .../TeamsGlobalMeetingPolicy.json | 7 +- .../Templates/ConditionalAccessTemplate.json | 65 ++++++- .../ConditionalAccessTemplatePackage.json | 1 + .../Templates/IntuneTemplate.json | 92 +++++---- .../Templates/IntuneTemplatePackage.json | 1 + backend/Config/CIPPDBCacheTypes.json | 5 + .../Get-CIPPBaselineDetectCADriftState.ps1 | 22 ++- ...nvoke-CIPPBaselineActivityBasedTimeout.ps1 | 19 +- ...nvoke-CIPPBaselineDisableBasicAuthSMTP.ps1 | 181 ++++++++++++++++++ .../Baselines/Invoke-CIPPBaselineStandard.ps1 | 81 +++++++- .../Public/Invoke-CIPPDBCacheCollection.ps1 | 1 + ...-CIPPDBCacheActivityBasedTimeoutPolicy.ps1 | 30 +++ .../Set-CIPPDBCacheExoCASMailboxSmtpAuth.ps1 | 37 ++++ .../Set-CIPPDBCacheSecurityDefaults.ps1 | 39 ++++ .../CippBaselineStandardSettings.jsx | 9 +- 29 files changed, 947 insertions(+), 102 deletions(-) create mode 100644 backend/Config/BaselineStandards/Entra (AAD) Standards/AuthMethodsSettings.json create mode 100644 backend/Config/BaselineStandards/Entra (AAD) Standards/PWcompanionAppAllowedState.json create mode 100644 backend/Config/BaselineStandards/Entra (AAD) Standards/PWdisplayAppInformationRequiredState.json create mode 100644 backend/Config/BaselineStandards/Entra (AAD) Standards/SecurityDefaults.json create mode 100644 backend/Config/BaselineStandards/Exchange Standards/DisableExternalCalendarSharing.json create mode 100644 backend/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineDisableBasicAuthSMTP.ps1 create mode 100644 backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheActivityBasedTimeoutPolicy.ps1 create mode 100644 backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoCASMailboxSmtpAuth.ps1 create mode 100644 backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheSecurityDefaults.ps1 diff --git a/backend/Config/BaselineStandards/Defender Standards/AtpPolicyForO365.json b/backend/Config/BaselineStandards/Defender Standards/AtpPolicyForO365.json index 2a19f98ef0..6063a93404 100644 --- a/backend/Config/BaselineStandards/Defender Standards/AtpPolicyForO365.json +++ b/backend/Config/BaselineStandards/Defender Standards/AtpPolicyForO365.json @@ -20,12 +20,21 @@ "CIS" ], "requiredCapabilities": [ - "SHAREPOINTWAC", - "SHAREPOINTSTANDARD", - "SHAREPOINTENTERPRISE", - "SHAREPOINTENTERPRISE_EDU", - "ONEDRIVE_BASIC", - "ONEDRIVE_ENTERPRISE" + [ + "SHAREPOINTWAC", + "SHAREPOINTSTANDARD", + "SHAREPOINTENTERPRISE", + "SHAREPOINTENTERPRISE_EDU", + "SHAREPOINTENTERPRISE_GOV", + "ONEDRIVE_BASIC", + "ONEDRIVE_ENTERPRISE" + ], + [ + "ATP_ENTERPRISE", + "ATP_ENTERPRISE_GOV", + "THREAT_INTELLIGENCE", + "THREAT_INTELLIGENCE_GOV" + ] ], "secureScoreImpact": 0, "compare": "subset", diff --git a/backend/Config/BaselineStandards/Entra (AAD) Standards/AuthMethodsSettings.json b/backend/Config/BaselineStandards/Entra (AAD) Standards/AuthMethodsSettings.json new file mode 100644 index 0000000000..62bd935a82 --- /dev/null +++ b/backend/Config/BaselineStandards/Entra (AAD) Standards/AuthMethodsSettings.json @@ -0,0 +1,101 @@ +{ + "name": "AuthMethodsSettings", + "label": "Configure Authentication Methods Policy Settings", + "cat": "Entra (AAD) Standards", + "tag": [ + "CIS M365 7.0.0 (5.2.3.6)", + "EIDSCA.AG01", + "EIDSCA.AG02", + "EIDSCA.AG03", + "SMB1001 (2.8)" + ], + "impact": "Low Impact", + "helpText": "Configures the report suspicious activity settings and system credential preferences in the authentication methods policy.", + "executiveText": "Configures security settings that allow users to report suspicious login attempts and manages how the system handles authentication credentials. This enhances overall security by enabling early detection of potential security threats and optimizing authentication processes.", + "docsDescription": "Controls the authentication methods policy settings for reporting suspicious activity and system credential preferences. These settings help enhance the security of authentication in your organization.", + "impactColour": "info", + "addedDate": "2025-02-10", + "powershellEquivalent": "Update-MgBetaPolicyAuthenticationMethodPolicy", + "appliesToTest": [ + "CIS_5_2_3_6", + "EIDSCAAG01", + "EIDSCAAG02", + "EIDSCAAG03", + "SMB1001_2_8", + "ZTNA21841" + ], + "recommendedBy": [], + "requiredCapabilities": [], + "secureScoreImpact": 0, + "compare": "subset", + "variables": { + "reportSuspiciousActivity": { + "type": "autoComplete", + "label": "Report Suspicious Activity Settings", + "options": [ + { + "label": "Microsoft managed", + "value": "default" + }, + { + "label": "Enabled", + "value": "enabled" + }, + { + "label": "Disabled", + "value": "disabled" + } + ], + "default": "enabled", + "recommended": "enabled" + }, + "systemCredential": { + "type": "autoComplete", + "label": "System Credential Preferences", + "options": [ + { + "label": "Microsoft managed", + "value": "default" + }, + { + "label": "Enabled", + "value": "enabled" + }, + { + "label": "Disabled", + "value": "disabled" + } + ], + "default": "enabled", + "recommended": "enabled" + } + }, + "expected": { + "reportSuspiciousActivitySettings": { + "state": "%reportSuspiciousActivity%" + }, + "systemCredentialPreferences": { + "state": "%systemCredential%" + } + }, + "read": { + "cacheType": "AuthenticationMethodsPolicy" + }, + "remediate": { + "executor": "GraphRequest", + "requests": [ + { + "method": "PATCH", + "uri": "policies/authenticationMethodsPolicy", + "body": { + "reportSuspiciousActivitySettings": { + "state": "%reportSuspiciousActivity%" + }, + "systemCredentialPreferences": { + "state": "%systemCredential%" + } + } + } + ] + } +} diff --git a/backend/Config/BaselineStandards/Entra (AAD) Standards/PWcompanionAppAllowedState.json b/backend/Config/BaselineStandards/Entra (AAD) Standards/PWcompanionAppAllowedState.json new file mode 100644 index 0000000000..d4f37adfc7 --- /dev/null +++ b/backend/Config/BaselineStandards/Entra (AAD) Standards/PWcompanionAppAllowedState.json @@ -0,0 +1,85 @@ +{ + "name": "PWcompanionAppAllowedState", + "label": "Set Authenticator Lite state", + "cat": "Entra (AAD) Standards", + "tag": [ + "CIS M365 7.0.0 (5.2.3.10)", + "EIDSCA.AM01" + ], + "impact": "Low Impact", + "helpText": "Sets the state of Authenticator Lite, Authenticator lite is a companion app for passwordless authentication.", + "executiveText": "Enables a simplified authentication experience by allowing users to authenticate directly through Outlook without requiring a separate authenticator app. This improves user convenience while maintaining security standards for passwordless authentication.", + "docsDescription": "Sets the Authenticator Lite state to enabled. This allows users to use the Authenticator Lite built into the Outlook app instead of the full Authenticator app.", + "impactColour": "info", + "addedDate": "2023-05-18", + "powershellEquivalent": "Update-MgBetaPolicyAuthenticationMethodPolicyAuthenticationMethodConfiguration", + "appliesToTest": [ + "CIS_5_2_3_10", + "EIDSCAAM01" + ], + "recommendedBy": [], + "requiredCapabilities": [], + "secureScoreImpact": 0, + "compare": "subset", + "variables": { + "state": { + "type": "autoComplete", + "label": "Authenticator Lite state", + "options": [ + { + "label": "Enabled", + "value": "enabled" + }, + { + "label": "Disabled", + "value": "disabled" + }, + { + "label": "Microsoft managed", + "value": "default" + } + ], + "default": "enabled", + "recommended": "enabled" + } + }, + "expected": { + "state": "%state%" + }, + "read": { + "cacheType": "AuthenticationMethodsPolicy", + "array": "authenticationMethodConfigurations", + "filter": [ + { + "property": "id", + "value": "MicrosoftAuthenticator" + } + ], + "object": "featureSettings.companionAppAllowedState" + }, + "remediate": { + "executor": "GraphRequest", + "requests": [ + { + "method": "PATCH", + "uri": "policies/authenticationMethodsPolicy/authenticationMethodConfigurations/microsoftAuthenticator", + "body": { + "@odata.type": "#microsoft.graph.microsoftAuthenticatorAuthenticationMethodConfiguration", + "featureSettings": { + "companionAppAllowedState": { + "state": "%state%", + "includeTarget": { + "targetType": "group", + "id": "all_users" + }, + "excludeTarget": { + "targetType": "group", + "id": "00000000-0000-0000-0000-000000000000" + } + } + } + } + } + ] + } +} diff --git a/backend/Config/BaselineStandards/Entra (AAD) Standards/PWdisplayAppInformationRequiredState.json b/backend/Config/BaselineStandards/Entra (AAD) Standards/PWdisplayAppInformationRequiredState.json new file mode 100644 index 0000000000..c29aaa70ad --- /dev/null +++ b/backend/Config/BaselineStandards/Entra (AAD) Standards/PWdisplayAppInformationRequiredState.json @@ -0,0 +1,79 @@ +{ + "name": "PWdisplayAppInformationRequiredState", + "label": "Enable Passwordless with Location information and Number Matching", + "cat": "Entra (AAD) Standards", + "tag": [ + "CIS M365 7.0.0 (5.2.3.1)", + "EIDSCA.AM03", + "EIDSCA.AM04", + "EIDSCA.AM06", + "EIDSCA.AM07", + "EIDSCA.AM09", + "EIDSCA.AM10", + "NIST CSF 2.0 (PR.AA-03)" + ], + "impact": "Low Impact", + "helpText": "Enables the MS authenticator app to display information about the app that is requesting authentication. This displays the application name.", + "executiveText": "Enhances authentication security by requiring users to match numbers and showing detailed information about login requests, including application names and location data. This helps employees verify legitimate login attempts and prevents unauthorized access through more secure authentication methods.", + "docsDescription": "Allows users to use Passwordless with Number Matching and adds location information from the last request", + "impactColour": "info", + "addedDate": "2021-11-16", + "powershellEquivalent": "Update-MgBetaPolicyAuthenticationMethodPolicyAuthenticationMethodConfiguration", + "appliesToTest": [ + "CIS_5_2_3_1", + "EIDSCAAM01", + "EIDSCAAM03", + "EIDSCAAM04", + "EIDSCAAM06", + "EIDSCAAM07", + "EIDSCAAM09", + "EIDSCAAM10" + ], + "recommendedBy": [ + "CIS" + ], + "requiredCapabilities": [], + "secureScoreImpact": 0, + "compare": "subset", + "variables": {}, + "expected": { + "state": "enabled", + "featureSettings": { + "displayAppInformationRequiredState": { + "state": "enabled" + } + } + }, + "read": { + "cacheType": "AuthenticationMethodsPolicy", + "array": "authenticationMethodConfigurations", + "filter": [ + { + "property": "id", + "value": "MicrosoftAuthenticator" + } + ] + }, + "remediate": { + "executor": "GraphRequest", + "requests": [ + { + "method": "PATCH", + "uri": "policies/authenticationMethodsPolicy/authenticationMethodConfigurations/microsoftAuthenticator", + "body": { + "@odata.type": "#microsoft.graph.microsoftAuthenticatorAuthenticationMethodConfiguration", + "state": "enabled", + "featureSettings": { + "displayAppInformationRequiredState": { + "state": "enabled", + "includeTarget": { + "targetType": "group", + "id": "all_users" + } + } + } + } + } + ] + } +} diff --git a/backend/Config/BaselineStandards/Entra (AAD) Standards/SecurityDefaults.json b/backend/Config/BaselineStandards/Entra (AAD) Standards/SecurityDefaults.json new file mode 100644 index 0000000000..e6711e89a3 --- /dev/null +++ b/backend/Config/BaselineStandards/Entra (AAD) Standards/SecurityDefaults.json @@ -0,0 +1,47 @@ +{ + "name": "SecurityDefaults", + "label": "Enable Security Defaults", + "cat": "Entra (AAD) Standards", + "tag": [ + "CISA (MS.AAD.11.1v1)", + "SMB1001 (2.5)", + "SMB1001 (2.6)", + "SMB1001 (2.9)" + ], + "impact": "High Impact", + "helpText": "Enables security defaults for the tenant, for newer tenants this is enabled by default. Do not enable this feature if you use Conditional Access.", + "executiveText": "Activates Microsoft's baseline security configuration that requires multi-factor authentication and blocks legacy authentication methods. This provides essential security protection for organizations without complex conditional access policies, significantly improving security posture with minimal configuration.", + "docsDescription": "Enables SD for the tenant, which disables all forms of basic authentication and enforces users to configure MFA. Users are only prompted for MFA when a logon is considered 'suspect' by Microsoft.", + "impactColour": "danger", + "addedDate": "2021-11-19", + "powershellEquivalent": "[Read more here](https://www.cyberdrain.com/automating-with-powershell-enabling-secure-defaults-and-sd-explained/)", + "appliesToTest": [ + "SMB1001_2_5", + "SMB1001_2_6", + "SMB1001_2_9", + "ZTNA21843" + ], + "recommendedBy": [], + "requiredCapabilities": [], + "secureScoreImpact": 0, + "compare": "subset", + "variables": {}, + "expected": { + "isEnabled": true + }, + "read": { + "cacheType": "SecurityDefaults" + }, + "remediate": { + "executor": "GraphRequest", + "requests": [ + { + "method": "PATCH", + "uri": "policies/identitySecurityDefaultsEnforcementPolicy", + "body": { + "isEnabled": true + } + } + ] + } +} diff --git a/backend/Config/BaselineStandards/Exchange Standards/AutoExpandArchive.json b/backend/Config/BaselineStandards/Exchange Standards/AutoExpandArchive.json index 329e8faf3a..4d108ab7dc 100644 --- a/backend/Config/BaselineStandards/Exchange Standards/AutoExpandArchive.json +++ b/backend/Config/BaselineStandards/Exchange Standards/AutoExpandArchive.json @@ -4,9 +4,9 @@ "cat": "Exchange Standards", "tag": [], "impact": "Low Impact", - "helpText": "Enables auto-expanding archives for the tenant", + "helpText": "Enables auto-expanding archives for the tenant. Exchange Online cannot turn this back off once enabled, so this standard only ever enables it.", "executiveText": "Enables automatic expansion of email archive storage when users approach their archive limits, ensuring continuous email retention without manual intervention. This prevents email storage issues and maintains compliance with data retention policies without requiring ongoing administrative management.", - "docsDescription": "Enables auto-expanding archives for the tenant. Does not enable archives for users.", + "docsDescription": "Enables auto-expanding archives for the tenant. Does not enable archives for users. Auto-expanding archiving is a one-way switch in Exchange Online - Set-OrganizationConfig -AutoExpandingArchive:$false is rejected by the service - so this standard has no disable direction and enforces the enabled state only.", "impactColour": "info", "addedDate": "2021-11-16", "powershellEquivalent": "Set-OrganizationConfig -AutoExpandingArchive", @@ -20,16 +20,9 @@ ], "secureScoreImpact": 0, "compare": "subset", - "variables": { - "enabled": { - "type": "switch", - "label": "Auto-expanding archives enabled", - "default": true, - "recommended": true - } - }, + "variables": {}, "expected": { - "AutoExpandingArchiveEnabled": "%enabled%" + "AutoExpandingArchiveEnabled": true }, "read": { "cacheType": "ExoOrganizationConfig" @@ -40,7 +33,7 @@ { "cmdlet": "Set-OrganizationConfig", "params": { - "AutoExpandingArchive": "%enabled%" + "AutoExpandingArchive": true } } ] diff --git a/backend/Config/BaselineStandards/Exchange Standards/DisableBasicAuthSMTP.json b/backend/Config/BaselineStandards/Exchange Standards/DisableBasicAuthSMTP.json index d7beb7c101..12431d4b22 100644 --- a/backend/Config/BaselineStandards/Exchange Standards/DisableBasicAuthSMTP.json +++ b/backend/Config/BaselineStandards/Exchange Standards/DisableBasicAuthSMTP.json @@ -40,20 +40,12 @@ } }, "expected": { - "SmtpClientAuthenticationDisabled": "%disabled%" + "SmtpClientAuthenticationDisabled": "%disabled%", + "UsersWithSmtpAuthEnabled": [] }, "read": { "cacheType": "ExoTransportConfig" }, - "remediate": { - "executor": "ExoRequest", - "cmdlets": [ - { - "cmdlet": "Set-TransportConfig", - "params": { - "SmtpClientAuthenticationDisabled": "%disabled%" - } - } - ] - } + "custom": true, + "customFunction": "Invoke-CIPPBaselineDisableBasicAuthSMTP" } diff --git a/backend/Config/BaselineStandards/Exchange Standards/DisableExternalCalendarSharing.json b/backend/Config/BaselineStandards/Exchange Standards/DisableExternalCalendarSharing.json new file mode 100644 index 0000000000..ba66dc1044 --- /dev/null +++ b/backend/Config/BaselineStandards/Exchange Standards/DisableExternalCalendarSharing.json @@ -0,0 +1,65 @@ +{ + "name": "DisableExternalCalendarSharing", + "label": "Disable external calendar sharing", + "cat": "Exchange Standards", + "tag": [ + "CIS M365 7.0.0 (1.3.3)", + "exo_individualsharing" + ], + "impact": "Low Impact", + "helpText": "Disables the ability for users to share their calendar with external users. Only for the default policy, so exclusions can be made if needed.", + "executiveText": "Prevents employees from sharing their calendars with external parties, protecting sensitive meeting information and internal schedules from unauthorized access. This security measure helps maintain confidentiality of business activities while still allowing internal collaboration.", + "docsDescription": "Disables external calendar sharing for the entire tenant. This is not a widely used feature, and it's therefore unlikely that this will impact users. Only for the default policy, so exclusions can be made if needed by making a new policy and assigning it to users.", + "impactColour": "info", + "addedDate": "2024-01-08", + "powershellEquivalent": "Get-SharingPolicy | Set-SharingPolicy -Enabled $False", + "appliesToTest": [ + "CISAMSEXO62", + "CIS_1_3_3", + "ZTNA21803" + ], + "recommendedBy": [ + "CIS" + ], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ], + "secureScoreImpact": 0, + "compare": "subset", + "variables": { + "externalCalendarSharingEnabled": { + "type": "switch", + "label": "External calendar sharing enabled on the default sharing policy", + "default": false, + "recommended": false + } + }, + "expected": { + "Enabled": "%externalCalendarSharingEnabled%" + }, + "read": { + "cacheType": "ExoSharingPolicy", + "filter": [ + { + "property": "Default", + "value": true + } + ] + }, + "remediate": { + "executor": "ExoRequest", + "cmdlets": [ + { + "cmdlet": "Set-SharingPolicy", + "params": { + "Identity": "Default Sharing Policy", + "Enabled": "%externalCalendarSharingEnabled%" + } + } + ] + } +} diff --git a/backend/Config/BaselineStandards/Exchange Standards/TeamsMeetingsByDefault.json b/backend/Config/BaselineStandards/Exchange Standards/TeamsMeetingsByDefault.json index 6871ea1fca..3153192383 100644 --- a/backend/Config/BaselineStandards/Exchange Standards/TeamsMeetingsByDefault.json +++ b/backend/Config/BaselineStandards/Exchange Standards/TeamsMeetingsByDefault.json @@ -30,7 +30,10 @@ "OnlineMeetingsByDefaultEnabled": "%state%" }, "read": { - "cacheType": "ExoOrganizationConfig" + "cacheType": "ExoOrganizationConfig", + "defaults": { + "OnlineMeetingsByDefaultEnabled": true + } }, "remediate": { "executor": "ExoRequest", diff --git a/backend/Config/BaselineStandards/Global Standards/ActivityBasedTimeout.json b/backend/Config/BaselineStandards/Global Standards/ActivityBasedTimeout.json index d9dabd10e7..ae7e383b59 100644 --- a/backend/Config/BaselineStandards/Global Standards/ActivityBasedTimeout.json +++ b/backend/Config/BaselineStandards/Global Standards/ActivityBasedTimeout.json @@ -31,20 +31,24 @@ "label": "Idle session timeout", "options": [ { - "label": "1 hour", + "label": "1 Hour", "value": "01:00:00" }, { - "label": "2 hours", - "value": "02:00:00" + "label": "3 Hours", + "value": "03:00:00" }, { - "label": "4 hours", - "value": "04:00:00" + "label": "6 Hours", + "value": "06:00:00" }, { - "label": "6 hours", - "value": "06:00:00" + "label": "12 Hours", + "value": "12:00:00" + }, + { + "label": "24 Hours", + "value": "1.00:00:00" } ], "default": "01:00:00", diff --git a/backend/Config/BaselineStandards/Manual Tasks/ManualTask.json b/backend/Config/BaselineStandards/Manual Tasks/ManualTask.json index d377f0a429..711a3f50b4 100644 --- a/backend/Config/BaselineStandards/Manual Tasks/ManualTask.json +++ b/backend/Config/BaselineStandards/Manual Tasks/ManualTask.json @@ -19,6 +19,7 @@ "taskName": { "type": "textField", "label": "Task name", + "required": true, "default": "" }, "instructions": { diff --git a/backend/Config/BaselineStandards/SharePoint Standards/DisableReshare.json b/backend/Config/BaselineStandards/SharePoint Standards/DisableReshare.json index e0274a270f..ddc09abae7 100644 --- a/backend/Config/BaselineStandards/SharePoint Standards/DisableReshare.json +++ b/backend/Config/BaselineStandards/SharePoint Standards/DisableReshare.json @@ -28,6 +28,7 @@ "SHAREPOINTSTANDARD", "SHAREPOINTENTERPRISE", "SHAREPOINTENTERPRISE_EDU", + "SHAREPOINTENTERPRISE_GOV", "ONEDRIVE_BASIC", "ONEDRIVE_ENTERPRISE" ], diff --git a/backend/Config/BaselineStandards/SharePoint Standards/DisableSharePointLegacyAuth.json b/backend/Config/BaselineStandards/SharePoint Standards/DisableSharePointLegacyAuth.json index 0804aa17d4..114b73b0b8 100644 --- a/backend/Config/BaselineStandards/SharePoint Standards/DisableSharePointLegacyAuth.json +++ b/backend/Config/BaselineStandards/SharePoint Standards/DisableSharePointLegacyAuth.json @@ -29,6 +29,7 @@ "SHAREPOINTSTANDARD", "SHAREPOINTENTERPRISE", "SHAREPOINTENTERPRISE_EDU", + "SHAREPOINTENTERPRISE_GOV", "ONEDRIVE_BASIC", "ONEDRIVE_ENTERPRISE" ], diff --git a/backend/Config/BaselineStandards/SharePoint Standards/sharingCapability.json b/backend/Config/BaselineStandards/SharePoint Standards/sharingCapability.json index e3de25762c..458fbf2a64 100644 --- a/backend/Config/BaselineStandards/SharePoint Standards/sharingCapability.json +++ b/backend/Config/BaselineStandards/SharePoint Standards/sharingCapability.json @@ -38,6 +38,8 @@ "sharingCapability": { "type": "autoComplete", "label": "Select Sharing Level", + "required": true, + "recommended": 1, "options": [ { "label": "Users can share only with people in the organization. No external sharing is allowed.", diff --git a/backend/Config/BaselineStandards/Teams Standards/TeamsGlobalMeetingPolicy.json b/backend/Config/BaselineStandards/Teams Standards/TeamsGlobalMeetingPolicy.json index cf3dc07862..e1eab2fc4b 100644 --- a/backend/Config/BaselineStandards/Teams Standards/TeamsGlobalMeetingPolicy.json +++ b/backend/Config/BaselineStandards/Teams Standards/TeamsGlobalMeetingPolicy.json @@ -78,7 +78,12 @@ "AutoAdmittedUsers": { "type": "autoComplete", "label": "Who can bypass the lobby?", + "omitWhenBlank": true, "options": [ + { + "label": "Keep the tenant's current value", + "value": "" + }, { "label": "Only organizers and co-organizers", "value": "OrganizerOnly" @@ -100,7 +105,7 @@ "value": "Everyone" } ], - "default": "EveryoneInCompanyExcludingGuests", + "default": "", "recommended": "EveryoneInCompanyExcludingGuests" }, "AllowPSTNUsersToBypassLobby": { diff --git a/backend/Config/BaselineStandards/Templates/ConditionalAccessTemplate.json b/backend/Config/BaselineStandards/Templates/ConditionalAccessTemplate.json index 04ef9a253a..b6359a7fb2 100644 --- a/backend/Config/BaselineStandards/Templates/ConditionalAccessTemplate.json +++ b/backend/Config/BaselineStandards/Templates/ConditionalAccessTemplate.json @@ -3,12 +3,26 @@ "label": "Conditional Access Template", "cat": "Templates", "tag": [ - "CIS M365 7.0.0 (5.2.2)", - "HighImpact" + "CIS M365 7.0.0 (5.2.2.1)", + "CIS M365 7.0.0 (5.2.2.2)", + "CIS M365 7.0.0 (5.2.2.3)", + "CIS M365 7.0.0 (5.2.2.4)", + "CIS M365 7.0.0 (5.2.2.5)", + "CIS M365 7.0.0 (5.2.2.6)", + "CIS M365 7.0.0 (5.2.2.7)", + "CIS M365 7.0.0 (5.2.2.8)", + "CIS M365 7.0.0 (5.2.2.9)", + "CIS M365 7.0.0 (5.2.2.10)", + "CIS M365 7.0.0 (5.2.2.11)", + "CIS M365 7.0.0 (5.2.2.12)", + "SMB1001 (2.5)", + "SMB1001 (2.6)", + "SMB1001 (2.8)", + "SMB1001 (2.9)" ], "impact": "High Impact", - "helpText": "Deploys and drift-checks a Conditional Access policy from a CA template. Deploy in report-only first, then enforce via a later stage.", - "executiveText": "Manages the sign-in rules that protect accounts - for example requiring a second factor or blocking legacy sign-in methods. The single most effective control against account takeover.", + "helpText": "Manage conditional access policies for better security.", + "executiveText": "Deploys standardized conditional access policies that automatically enforce security requirements based on user location, device compliance, and risk factors. These templates ensure consistent security controls across the organization while enabling secure access to business resources.", "recommendedBy": [ "CIS", "Microsoft", @@ -27,6 +41,7 @@ "caTemplate": { "type": "autoComplete", "label": "Select Conditional Access Template", + "required": true, "api": { "url": "/api/ListCATemplates", "labelField": "displayName", @@ -79,7 +94,11 @@ }, "read": { "cacheType": "ConditionalAccessPolicies", - "requiredCaches": ["ConditionalAccessPolicies", "NamedLocations", "AuthenticationStrengths"], + "requiredCaches": [ + "ConditionalAccessPolicies", + "NamedLocations", + "AuthenticationStrengths" + ], "liveCount": { "uri": "identity/conditionalAccess/policies", "cacheType": "ConditionalAccessPolicies" @@ -92,5 +111,41 @@ "state": "%state%", "disableSD": "%disableSD%", "createGroups": "%createGroups%" + }, + "addedDate": "2023-12-30", + "appliesToTest": [ + "CIS_5_2_2_1", + "CIS_5_2_2_10", + "CIS_5_2_2_11", + "CIS_5_2_2_12", + "CIS_5_2_2_2", + "CIS_5_2_2_3", + "CIS_5_2_2_4", + "CIS_5_2_2_5", + "CIS_5_2_2_6", + "CIS_5_2_2_7", + "CIS_5_2_2_8", + "CIS_5_2_2_9", + "SMB1001_2_5", + "SMB1001_2_6", + "SMB1001_2_8", + "SMB1001_2_9", + "ZTNA21783", + "ZTNA21786", + "ZTNA21806", + "ZTNA21808", + "ZTNA21824", + "ZTNA21825", + "ZTNA21828", + "ZTNA21830", + "ZTNA21883", + "ZTNA21892", + "ZTNA21941", + "ZTNA24827" + ], + "disabledFeatures": { + "report": false, + "warn": false, + "remediate": false } } diff --git a/backend/Config/BaselineStandards/Templates/ConditionalAccessTemplatePackage.json b/backend/Config/BaselineStandards/Templates/ConditionalAccessTemplatePackage.json index f899060e31..e8db824d04 100644 --- a/backend/Config/BaselineStandards/Templates/ConditionalAccessTemplatePackage.json +++ b/backend/Config/BaselineStandards/Templates/ConditionalAccessTemplatePackage.json @@ -21,6 +21,7 @@ "caTemplatePackage": { "type": "autoComplete", "label": "Select a package of Conditional Access Templates", + "required": true, "api": { "url": "/api/ListCATemplates?mode=Tag", "labelField": "label", diff --git a/backend/Config/BaselineStandards/Templates/IntuneTemplate.json b/backend/Config/BaselineStandards/Templates/IntuneTemplate.json index be9cecc53e..e0f74ccd33 100644 --- a/backend/Config/BaselineStandards/Templates/IntuneTemplate.json +++ b/backend/Config/BaselineStandards/Templates/IntuneTemplate.json @@ -4,50 +4,18 @@ "cat": "Templates", "tag": [ "SMB1001 (1.2)", - "HighImpact" + "SMB1001 (1.3)", + "SMB1001 (1.4)", + "SMB1001 (1.8)", + "SMB1001 (1.9)", + "SMB1001 (1.10)", + "SMB1001 (1.12)", + "SMB1001 (2.2)", + "SMB1001 (4.7)" ], "impact": "High Impact", - "helpText": "Deploys and drift-checks an Intune policy from an Intune template: device configurations, settings catalog, compliance policies, app protection, administrative templates and update profiles.", - "executiveText": "Deploys standardized device management configurations across corporate devices, ensuring consistent security policies, application settings, and compliance requirements.", - "addedDate": "2023-12-30", - "appliesToTest": [ - "SMB1001_1_10", - "SMB1001_1_12", - "SMB1001_1_2", - "SMB1001_1_3", - "SMB1001_1_4", - "SMB1001_1_8", - "SMB1001_1_9", - "SMB1001_2_2", - "SMB1001_4_7", - "ZTNA24540", - "ZTNA24541", - "ZTNA24542", - "ZTNA24543", - "ZTNA24545", - "ZTNA24547", - "ZTNA24548", - "ZTNA24549", - "ZTNA24550", - "ZTNA24552", - "ZTNA24553", - "ZTNA24564", - "ZTNA24568", - "ZTNA24569", - "ZTNA24572", - "ZTNA24574", - "ZTNA24575", - "ZTNA24576", - "ZTNA24784", - "ZTNA24839", - "ZTNA24840", - "ZTNA24870" - ], - "disabledFeatures": { - "report": false, - "warn": false, - "remediate": false - }, + "helpText": "Deploy and manage Intune templates across devices.", + "executiveText": "Deploys standardized device management configurations across all corporate devices, ensuring consistent security policies, application settings, and compliance requirements. This template-based approach streamlines device management while maintaining uniform security standards across the organization.", "recommendedBy": [ "CIPP" ], @@ -67,6 +35,7 @@ "intuneTemplate": { "type": "autoComplete", "label": "Select Intune Template", + "required": true, "api": { "url": "/api/ListIntuneTemplates", "labelField": "Displayname", @@ -177,5 +146,44 @@ "assignmentFilter": "%assignmentFilter%", "assignmentFilterType": "%assignmentFilterType%", "levenshteinDistance": "%levenshteinDistance%" + }, + "addedDate": "2023-12-30", + "appliesToTest": [ + "SMB1001_1_10", + "SMB1001_1_12", + "SMB1001_1_2", + "SMB1001_1_3", + "SMB1001_1_4", + "SMB1001_1_8", + "SMB1001_1_9", + "SMB1001_2_2", + "SMB1001_4_7", + "ZTNA24540", + "ZTNA24541", + "ZTNA24542", + "ZTNA24543", + "ZTNA24545", + "ZTNA24547", + "ZTNA24548", + "ZTNA24549", + "ZTNA24550", + "ZTNA24552", + "ZTNA24553", + "ZTNA24564", + "ZTNA24568", + "ZTNA24569", + "ZTNA24572", + "ZTNA24574", + "ZTNA24575", + "ZTNA24576", + "ZTNA24784", + "ZTNA24839", + "ZTNA24840", + "ZTNA24870" + ], + "disabledFeatures": { + "report": false, + "warn": false, + "remediate": false } } diff --git a/backend/Config/BaselineStandards/Templates/IntuneTemplatePackage.json b/backend/Config/BaselineStandards/Templates/IntuneTemplatePackage.json index 2f82f31513..8ef623ffff 100644 --- a/backend/Config/BaselineStandards/Templates/IntuneTemplatePackage.json +++ b/backend/Config/BaselineStandards/Templates/IntuneTemplatePackage.json @@ -27,6 +27,7 @@ "intuneTemplatePackage": { "type": "autoComplete", "label": "Select a package of Intune Templates", + "required": true, "api": { "url": "/api/ListIntuneTemplates?mode=Tag", "labelField": "label", diff --git a/backend/Config/CIPPDBCacheTypes.json b/backend/Config/CIPPDBCacheTypes.json index e97e626043..7645912031 100644 --- a/backend/Config/CIPPDBCacheTypes.json +++ b/backend/Config/CIPPDBCacheTypes.json @@ -324,6 +324,11 @@ "friendlyName": "Conditional Access Policies", "description": "Azure AD Conditional Access policies" }, + { + "type": "SecurityDefaults", + "friendlyName": "Security Defaults", + "description": "Identity security defaults enforcement policy" + }, { "type": "RiskyUsers", "friendlyName": "Risky Users", diff --git a/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineDetectCADriftState.ps1 b/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineDetectCADriftState.ps1 index dc97df5f5d..be70f20751 100644 --- a/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineDetectCADriftState.ps1 +++ b/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineDetectCADriftState.ps1 @@ -17,19 +17,27 @@ function Get-CIPPBaselineDetectCADriftState { param($Item, $TenantFilter) $ManagedNames = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + # The template store is read ONCE and indexed under every key a config may reference: + # RowKey, the GUID column, and the payload displayName - the legacy reference form that + # both the CA prepare and the CATemplate executor still honour. Resolving fewer keys here + # than the deploy path does would leave a policy a baseline actively manages outside the + # managed set, flag it as unmanaged, and let a deny-delete verdict delete it out from + # under that baseline on the next run. $TemplatesTable = Get-CippTable -tablename 'templates' + $TemplateNames = @{} + foreach ($TemplateRow in @(try { Get-CIPPAzDataTableEntity @TemplatesTable -Filter "PartitionKey eq 'CATemplate'" } catch { @() })) { + $TemplateName = "$(try { ($TemplateRow.JSON | ConvertFrom-Json -Depth 100).displayName } catch { $null })" + if (-not $TemplateName) { continue } + foreach ($Key in @("$($TemplateRow.RowKey)", "$($TemplateRow.GUID)", $TemplateName)) { + if ($Key -and -not $TemplateNames.ContainsKey($Key)) { $TemplateNames[$Key] = $TemplateName } + } + } $WorkItems = @(try { Get-CIPPBaselineWorkItems -TenantFilter $TenantFilter } catch { @() }) $Unwrap = { param($Value) if ($Value -is [System.Management.Automation.PSCustomObject] -and $null -ne $Value.value) { $Value.value } else { $Value } } foreach ($WorkItem in ($WorkItems | Where-Object { $_.BaseName -eq 'ConditionalAccessTemplate' })) { $TemplateRef = "$(& $Unwrap $WorkItem.Variables.caTemplate)" if (-not $TemplateRef) { continue } - $SafeRef = ConvertTo-CIPPODataFilterValue -Value $TemplateRef - $TemplateRow = Get-CIPPAzDataTableEntity @TemplatesTable -Filter "PartitionKey eq 'CATemplate' and RowKey eq '$SafeRef'" | Select-Object -First 1 - if (-not $TemplateRow) { - $TemplateRow = Get-CIPPAzDataTableEntity @TemplatesTable -Filter "PartitionKey eq 'CATemplate' and GUID eq '$SafeRef'" | Select-Object -First 1 - } - if (-not $TemplateRow) { continue } - $TemplateName = "$(try { ($TemplateRow.JSON | ConvertFrom-Json -Depth 100).displayName } catch { $null })" + $TemplateName = $TemplateNames[$TemplateRef] if (-not $TemplateName) { continue } $Resolved = $(try { Get-CIPPTextReplacement -TenantFilter $TenantFilter -Text $TemplateName } catch { $TemplateName }) $null = $ManagedNames.Add("$Resolved") diff --git a/backend/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineActivityBasedTimeout.ps1 b/backend/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineActivityBasedTimeout.ps1 index 2a6336b525..245efb0a6e 100644 --- a/backend/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineActivityBasedTimeout.ps1 +++ b/backend/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineActivityBasedTimeout.ps1 @@ -67,8 +67,16 @@ function Invoke-CIPPBaselineActivityBasedTimeout { } # Fail open: a missing cache never returns early - an enforced standard still # applies its expected state (POSTing a new org-default policy when none exists). - # The governed value sits in a JSON string inside the policy's definition array. - $CurrentTimeout = $(try { (@($Policy.definition)[0] | ConvertFrom-Json).ActivityBasedTimeoutPolicy.WebSessionIdleTimeout } catch { $null }) + # The governed value sits in a JSON string inside the policy's definition array; + # the portal/Graph schema nests it under ApplicationPolicies (ApplicationId + # 'default'). Policies written by an early engine build put WebSessionIdleTimeout + # directly on the root - read both so those do not report permanent drift. + $CurrentTimeout = $(try { + $AbtDefinition = (@($Policy.definition)[0] | ConvertFrom-Json).ActivityBasedTimeoutPolicy + $DefaultApplicationPolicy = @($AbtDefinition.ApplicationPolicies) | Where-Object { $_.ApplicationId -eq 'default' } | Select-Object -First 1 + if (-not $DefaultApplicationPolicy) { $DefaultApplicationPolicy = @($AbtDefinition.ApplicationPolicies) | Select-Object -First 1 } + $DefaultApplicationPolicy.WebSessionIdleTimeout ?? $AbtDefinition.WebSessionIdleTimeout + } catch { $null }) if ($null -ne $Policy) { $Result.CurrentValue = [PSCustomObject]@{ timeout = $CurrentTimeout } } @@ -92,8 +100,13 @@ function Invoke-CIPPBaselineActivityBasedTimeout { $WriteNeeded = (-not $Compliant) -or $Force.IsPresent if ($Mode -ne 'compare' -and $RemediationAllowed -and $WriteNeeded) { + # The documented definition schema: ApplicationPolicies[] with the org-wide + # entry keyed ApplicationId 'default' - the shape the portal writes and reads. $PolicyDefinition = ConvertTo-Json -Compress -Depth 10 -InputObject ([PSCustomObject]@{ - ActivityBasedTimeoutPolicy = [PSCustomObject]@{ Version = 1; WebSessionIdleTimeout = $ExpectedTimeout } + ActivityBasedTimeoutPolicy = [PSCustomObject]@{ + Version = 1 + ApplicationPolicies = @([PSCustomObject]@{ ApplicationId = 'default'; WebSessionIdleTimeout = $ExpectedTimeout }) + } }) $Body = ConvertTo-Json -Compress -Depth 10 -InputObject ([PSCustomObject]@{ definition = @($PolicyDefinition) diff --git a/backend/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineDisableBasicAuthSMTP.ps1 b/backend/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineDisableBasicAuthSMTP.ps1 new file mode 100644 index 0000000000..9565548b97 --- /dev/null +++ b/backend/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineDisableBasicAuthSMTP.ps1 @@ -0,0 +1,181 @@ +function Invoke-CIPPBaselineDisableBasicAuthSMTP { + <# + .SYNOPSIS + Custom baseline standard: Disable SMTP Basic Authentication. + .DESCRIPTION + Two dimensions, which is why this standard is custom: the tenant-wide + TransportConfig SmtpClientAuthenticationDisabled flag AND the per-user CAS mailbox + overrides (SmtpClientAuthenticationDisabled -eq $false = SMTP AUTH explicitly + enabled for that user, alive regardless of the tenant switch). Compliant = flag + matches the expectation and, when disabling, no user overrides remain. Remediation + sets the transport flag and clears each override back to $null (inherit), exactly + like the classic standard, then re-collects the override cache so the next run + reads the cleared state. Persists through the shared writer like every engine + result. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Item, + [ValidateSet('run', 'compare', 'oneoff')]$Mode = 'run', + $TriggeredBy = 'schedule', + [switch]$Force, + $RunId + ) + if (-not $RunId) { $RunId = [string](New-Guid).Guid } + + $TenantFilter = $Item.TenantFilter + $Now = [int64]([datetimeoffset]::UtcNow.ToUnixTimeSeconds()) + $ExpectedDisabled = "$($Item.Variables.disabled)" -in @('True', 'true', '1') + + $Result = [PSCustomObject]@{ + Item = $Item + Mode = $Mode + TriggeredBy = $TriggeredBy + ExpectedValue = [PSCustomObject]@{ SmtpClientAuthenticationDisabled = $ExpectedDisabled; UsersWithSmtpAuthEnabled = @() } + CurrentValue = $null + Compliant = $false + PendingVerification = $false + LicenseAvailable = $true + Status = $null + Remediated = $false + Outcome = 'Error' + Diff = $null + Inheritance = @($Item.Tiers) + AlertEvent = $null + CacheType = 'ExoTransportConfig' + } + + try { + $ResolvedTable = Get-CippTable -tablename 'BaselineAlignment' + $SafeTenant = ConvertTo-CIPPODataFilterValue -Value $TenantFilter + $SafeStandard = ConvertTo-CIPPODataFilterValue -Value $Item.Standard + $Prior = Get-CIPPAzDataTableEntity @ResolvedTable -Filter "PartitionKey eq '$SafeTenant' and StandardName eq '$SafeStandard'" | Select-Object -First 1 + $PriorStatus = $Prior.Status + $Result.Status = $PriorStatus + + $TransportConfig = @(New-CIPPDbRequest -TenantFilter $TenantFilter -Type 'ExoTransportConfig' | Where-Object { $_ }) | Select-Object -First 1 + if ($null -eq $TransportConfig) { + $Collector = Get-Command -Name 'Set-CIPPDBCacheExoTransportConfig' -ErrorAction SilentlyContinue + if ($Collector) { + try { + $null = & $Collector -TenantFilter $TenantFilter + $TransportConfig = @(New-CIPPDbRequest -TenantFilter $TenantFilter -Type 'ExoTransportConfig' | Where-Object { $_ }) | Select-Object -First 1 + } catch { + Write-Information "Baselines: TransportConfig cache collection on $TenantFilter failed: $($_.Exception.Message)" + } + } + } + if ($null -eq $TransportConfig) { + # No cache and no way to grade honestly: nothing is written - the row stays + # 'No Data' and retries next run. + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "$($Item.Standard): no ExoTransportConfig data in CIPPDb after collection - skipped, nothing written." -Sev 'Info' + $Result.Outcome = 'Skipped-NoCache' + $Result.Status = $PriorStatus ?? 'No Data' + return $Result + } + + # The override set: an empty read is ambiguous (never collected vs genuinely + # none), so an empty read always re-collects once - the collector's ClearOnEmpty + # makes the collected-empty state authoritative and the recollect cheap. + $CollectOverrides = { + $Collector = Get-Command -Name 'Set-CIPPDBCacheExoCASMailboxSmtpAuth' -ErrorAction SilentlyContinue + if ($Collector) { + try { $null = & $Collector -TenantFilter $TenantFilter } catch { + Write-Information "Baselines: SMTP AUTH override cache collection on $TenantFilter failed: $($_.Exception.Message)" + } + } + } + $Overrides = @(New-CIPPDbRequest -TenantFilter $TenantFilter -Type 'ExoCASMailboxSmtpAuth' | Where-Object { $_ }) + if ($Overrides.Count -eq 0) { + & $CollectOverrides + $Overrides = @(New-CIPPDbRequest -TenantFilter $TenantFilter -Type 'ExoCASMailboxSmtpAuth' | Where-Object { $_ }) + } + $EnabledUsers = @($Overrides | ForEach-Object { "$($_.PrimarySmtpAddress ?? $_.Identity)" } | Where-Object { $_ } | Sort-Object) + + $CurrentDisabled = [bool]$TransportConfig.SmtpClientAuthenticationDisabled + $Result.CurrentValue = [PSCustomObject]@{ + SmtpClientAuthenticationDisabled = $CurrentDisabled + UsersWithSmtpAuthEnabled = $EnabledUsers + } + + $FlagCompliant = $CurrentDisabled -eq $ExpectedDisabled + # Per-user overrides only matter when the point is disabling SMTP AUTH. + $UsersCompliant = (-not $ExpectedDisabled) -or ($EnabledUsers.Count -eq 0) + $Compliant = $FlagCompliant -and $UsersCompliant + if (-not $Compliant) { + $Diff = [System.Collections.Generic.List[object]]::new() + if (-not $FlagCompliant) { + $Diff.Add([PSCustomObject]@{ Property = 'SmtpClientAuthenticationDisabled'; ExpectedValue = $ExpectedDisabled; ReceivedValue = $CurrentDisabled }) + } + if (-not $UsersCompliant) { + $Diff.Add([PSCustomObject]@{ Property = 'UsersWithSmtpAuthEnabled'; ExpectedValue = @(); ReceivedValue = $EnabledUsers }) + } + $Result.Diff = @($Diff) + } + + $Expires = if ("$($Prior.DeviationExpires)" -match '^\d+$') { [int64]$Prior.DeviationExpires } else { 0 } + $AcceptActive = $PriorStatus -eq 'Accepted' -and ($Expires -eq 0 -or $Now -lt $Expires) + if (-not $Compliant -and $AcceptActive) { + $Result.Outcome = 'Drift' + $Result.Status = 'Accepted' + Set-CIPPBaselineResult -Result $Result -Prior $Prior -RunId $RunId + return $Result + } + + $DeniedRemediate = $PriorStatus -eq 'Denied - Remediate Pending' + $RemediationAllowed = (($Mode -eq 'oneoff') -or ($Mode -eq 'run' -and ($Item.RemediateEnabled -or $DeniedRemediate))) -and -not $AcceptActive + $WriteNeeded = (-not $Compliant) -or $Force.IsPresent + + if ($Mode -ne 'compare' -and $RemediationAllowed -and $WriteNeeded) { + try { + if (-not $FlagCompliant -or $Force.IsPresent) { + $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Set-TransportConfig' -cmdParams @{ SmtpClientAuthenticationDisabled = $ExpectedDisabled } + } + # Clear each explicit enablement back to inherit ($null) - never $true, so + # a later tenant-level policy change applies to these users again. + if ($ExpectedDisabled) { + foreach ($User in $EnabledUsers) { + $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Set-CASMailbox' -cmdParams @{ Identity = $User; SmtpClientAuthenticationDisabled = $null } + } + } + } catch { + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "Failed to change `"Disable SMTP Basic Authentication`": $($_.Exception.Message) - Run $RunId" -Sev 'Error' + $Result.Outcome = 'Error' + Set-CIPPBaselineResult -Result $Result -Prior $Prior -RunId $RunId + return $Result + } + if ($EnabledUsers.Count -gt 0) { + # Refresh the override cache now: the cleared users must not read back as + # drift on the next run (ClearOnEmpty makes the emptied state stick). + & $CollectOverrides + } + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "Successfully changed `"Disable SMTP Basic Authentication`" ($($EnabledUsers.Count) user override$(if ($EnabledUsers.Count -eq 1) { '' } else { 's' }) cleared) - Run $RunId" -Sev 'Info' + $Result.CurrentValue = $Result.ExpectedValue + $Result.Compliant = $true + $Result.PendingVerification = $true + $Result.Remediated = $true + $Result.Outcome = 'Remediated' + $Result.Status = 'Compliant' + if ($Item.AlertOnRemediate) { $Result.AlertEvent = 'Remediated' } + } elseif ($Compliant) { + $Result.Compliant = $true + $Result.Outcome = 'Compliant' + $Result.Status = 'Compliant' + } else { + $Result.Outcome = 'Drift' + $Result.Status = if ("$PriorStatus".StartsWith('Denied')) { $PriorStatus } else { 'Drift' } + if ($Result.Status -eq 'Drift' -and $PriorStatus -ne 'Drift' -and $Item.AlertEnabled) { $Result.AlertEvent = 'Drift' } + } + + Set-CIPPBaselineResult -Result $Result -Prior $Prior -RunId $RunId + if ($Result.AlertEvent) { Send-CIPPBaselineAlert -Result $Result } + return $Result + } catch { + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "Disable SMTP Basic Authentication baseline failed on ${TenantFilter}: $($_.Exception.Message)" -Sev 'Error' + $Result.Outcome = 'Error' + try { Set-CIPPBaselineResult -Result $Result -Prior $Prior -RunId $RunId } catch { Write-Information "Set-CIPPBaselineResult failed: $($_.Exception.Message)" } + return $Result + } +} diff --git a/backend/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineStandard.ps1 b/backend/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineStandard.ps1 index e78626cb44..02834d27cb 100644 --- a/backend/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineStandard.ps1 +++ b/backend/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineStandard.ps1 @@ -14,10 +14,14 @@ function Invoke-CIPPBaselineStandard { dot-path descend/flatten -> filter[] (properties may be dot-paths) -> object dot-path). On a cache miss the engine triggers the central collector for that cacheType and re-reads once; if there is still nothing, NOTHING is written - the - row stays 'No Data' and retries naturally on the next run. + row stays 'No Data' and retries naturally on the next run. read.defaults supplies + the documented service default for a property the API omits until it is + explicitly set, so 'never configured' is not read as drift. 3. Render the expected template from the configured variable values, project the current value to the expected keys and compare with Compare-CIPPIntuneObject - (subset). Differences on accepted property paths are tolerated. + (subset). Differences on accepted property paths are tolerated. A variable the + definition marks `required` but the baseline never filled parks the row with an + Error instead: the raw %token% is not a value to compare or deploy. 4. One Status per row: Compliant / Drift / Accepted / Partially Accepted / Denied - Remediate Pending / Denied - Delete Pending / Skipped - No License. Accepted holds until its unix expiry (optionally remediating on lapse); a row @@ -54,6 +58,19 @@ function Invoke-CIPPBaselineStandard { if ($null -eq $Template) { return $null } if ($Variables -is [System.Collections.IDictionary]) { $Variables = [PSCustomObject]$Variables } $Json = ConvertTo-Json -Compress -Depth 100 -InputObject $Template + # A variable declared omitWhenBlank that is left blank (or never configured) + # removes its key entirely - 'keep the tenant's current value': the setting is + # neither graded nor written. Pruned on the serialized template before + # substitution, so expected AND remediate specs stay consistent (keys whose + # value is exactly the "%var%" token). + foreach ($Declared in (($Definition.variables ?? [PSCustomObject]@{}).PSObject.Properties)) { + if ($Declared.Value.omitWhenBlank -ne $true) { continue } + if (-not [string]::IsNullOrEmpty("$(($Variables ?? [PSCustomObject]@{}).($Declared.Name))")) { continue } + $Escaped = [regex]::Escape(('%{0}%' -f $Declared.Name)) + $Json = [regex]::Replace($Json, ('"[^"]*":"{0}",' -f $Escaped), '') + $Json = [regex]::Replace($Json, (',"[^"]*":"{0}"' -f $Escaped), '') + $Json = [regex]::Replace($Json, ('"[^"]*":"{0}"' -f $Escaped), '') + } foreach ($Variable in (($Variables ?? [PSCustomObject]@{}).PSObject.Properties)) { $Token = '%{0}%' -f $Variable.Name $EncodedValue = ConvertTo-Json -Compress -Depth 100 -InputObject $Variable.Value @@ -78,10 +95,29 @@ function Invoke-CIPPBaselineStandard { # capabilities cache is per tenant with a 24h TTL, so at most one Graph call per # tenant per day. A oneoff is an explicit operator ask and bypasses the gate - the # cache may not know about a license bought after the last sync. + # A flat requiredCapabilities list is any-of. A nested array is a GROUP that must + # also match: every group needs at least one licensed capability (AND of any-of + # groups) - AtpPolicyForO365 needs a SharePoint plan AND a Defender for Office 365 + # plan, exactly like the classic standard's two license gates. $Required = @($Definition.requiredCapabilities) if ($Required.Count -gt 0 -and $Mode -ne 'oneoff') { $Capabilities = $(try { Get-CIPPTenantCapabilities -TenantFilter $TenantFilter } catch { $null }) - if (@($Required | Where-Object { $Capabilities.$_ -eq $true }).Count -eq 0) { + # Built as a List: an if-expression's pipeline output unwraps one array + # level, which silently turned every capability into its own AND-group. + $Groups = [System.Collections.Generic.List[object]]::new() + if (@($Required | Where-Object { $_ -is [System.Array] }).Count -gt 0) { + foreach ($Entry in $Required) { $Groups.Add(@($Entry)) } + } else { + $Groups.Add(@($Required)) + } + $Licensed = $true + foreach ($Group in $Groups) { + if (@(@($Group) | Where-Object { $Capabilities.$_ -eq $true }).Count -eq 0) { + $Licensed = $false + break + } + } + if (-not $Licensed) { $Skipped = [PSCustomObject]@{ Item = $Item Mode = $Mode @@ -238,6 +274,30 @@ function Invoke-CIPPBaselineStandard { return $Result } + # 1b00. Unconfigured REQUIRED variable: the baseline was saved without a value the + # definition cannot substitute for, so the render leaves the raw "%var%" token in the + # spec. That token is not a value - comparing it is permanent drift, and remediating + # it sends the literal string to the API (CSOM/Graph/EXO accept a garbage string for + # a typed setting). Nothing is compared and nothing is written; the row keeps + # whatever it last knew and the operator gets a named error, the way the classic + # standards validated their input and aborted. Only variables the definition marks + # `required` are gated: a blank optional field (an empty exclude group, no + # documentation link) is a legitimate configuration, and omitWhenBlank fields have + # their key pruned rather than left behind. + $ConfiguredVariables = $Item.Variables ?? [PSCustomObject]@{} + $Unresolved = @(($Definition.variables ?? [PSCustomObject]@{}).PSObject.Properties | Where-Object { + $_.Value.required -eq $true -and + [string]::IsNullOrEmpty("$($ConfiguredVariables.$($_.Name))") + } | ForEach-Object { $_.Name }) + if ($Unresolved.Count -gt 0) { + $Missing = $Unresolved -join ', ' + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "`"$Label`" is missing a value for $Missing - nothing is compared or changed until the baseline configures it. - Run $RunId" -Sev 'Error' + $null = Add-CIPPBaselineHistoryEvent -TenantFilter $TenantFilter -Standard $Item.Standard -Mode $Mode -TriggeredBy $TriggeredBy -Outcome 'Error' -Detail "Not configured: no value for $Missing - the standard was skipped instead of comparing or writing the raw variable name." -RunId $RunId + $Result.Outcome = 'Error' + $Result.Status = $PriorStatus ?? 'No Data' + return $Result + } + # 1b. Manual tasks: state lives on the resolved row; the operator completes them. if ($Definition.manual) { $Manual = & $Render $Definition.manual $Item.Variables @@ -389,13 +449,24 @@ function Invoke-CIPPBaselineStandard { $CheckBeforeRun = $Definition.checkBeforeRun -ne $false # 3. Project to the expected keys (subset compare) and diff. A key the current object - # lacks stays present as $null so the compare flags the presence mismatch. + # lacks stays present as $null so the compare flags the presence mismatch - EXCEPT + # where read.defaults documents the service default for a property the API omits + # until it is explicitly set (Exchange returns a null OnlineMeetingsByDefaultEnabled + # on a tenant that never touched it, and null there means enabled). Those fill in + # before the compare, so a tenant already in the expected state does not read as + # drift and get written to on every run. Only the properties a definition names are + # filled, and only when the API really returned nothing. + $ReadDefaults = $Definition.read.defaults $Differences = @() $PreFilterDifferences = @() if ($null -ne $Current) { $Projected = [PSCustomObject]@{} foreach ($Property in $Expected.PSObject.Properties.Name) { - $Projected | Add-Member -NotePropertyName $Property -NotePropertyValue $Current.$Property + $Value = $Current.$Property + if ($null -eq $Value -and $null -ne $ReadDefaults -and $ReadDefaults.PSObject.Properties[$Property]) { + $Value = $ReadDefaults.$Property + } + $Projected | Add-Member -NotePropertyName $Property -NotePropertyValue $Value } $Result.CurrentValue = $Projected # The compare copy of expected resolves $anyOf against the CURRENT value: a diff --git a/backend/Modules/CIPPCore/Public/Invoke-CIPPDBCacheCollection.ps1 b/backend/Modules/CIPPCore/Public/Invoke-CIPPDBCacheCollection.ps1 index bc3eeaf918..ec8f7b6446 100644 --- a/backend/Modules/CIPPCore/Public/Invoke-CIPPDBCacheCollection.ps1 +++ b/backend/Modules/CIPPCore/Public/Invoke-CIPPDBCacheCollection.ps1 @@ -55,6 +55,7 @@ function Invoke-CIPPDBCacheCollection { 'AdminConsentRequestPolicy' 'AuthorizationPolicy' 'AuthenticationMethodsPolicy' + 'SecurityDefaults' 'DeviceSettings' 'DirectoryRecommendations' 'CrossTenantAccessPolicy' diff --git a/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheActivityBasedTimeoutPolicy.ps1 b/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheActivityBasedTimeoutPolicy.ps1 new file mode 100644 index 0000000000..dcc5e5797e --- /dev/null +++ b/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheActivityBasedTimeoutPolicy.ps1 @@ -0,0 +1,30 @@ +function Set-CIPPDBCacheActivityBasedTimeoutPolicy { + <# + .SYNOPSIS + Caches activity based timeout policies for a tenant + + .PARAMETER TenantFilter + The tenant to cache activity based timeout policies for + + .PARAMETER QueueId + The queue ID to update with total tasks (optional) + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter, + [string]$QueueId + ) + + try { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching activity based timeout policies' -sev Debug + $Policies = New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/policies/activityBasedTimeoutPolicies' -tenantid $TenantFilter + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'ActivityBasedTimeoutPolicy' -Data @($Policies | Where-Object { $_.id }) -AddCount -ClearOnEmpty + $Policies = $null + + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached activity based timeout policies successfully' -sev Debug + + } catch { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Failed to cache activity based timeout policies: $($_.Exception.Message)" -sev Error + } +} diff --git a/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoCASMailboxSmtpAuth.ps1 b/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoCASMailboxSmtpAuth.ps1 new file mode 100644 index 0000000000..b268710365 --- /dev/null +++ b/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoCASMailboxSmtpAuth.ps1 @@ -0,0 +1,37 @@ +function Set-CIPPDBCacheExoCASMailboxSmtpAuth { + <# + .SYNOPSIS + Caches CAS mailboxes with an explicit SMTP AUTH enablement override for a tenant + + .DESCRIPTION + SmtpClientAuthenticationDisabled on a CAS mailbox is $null (inherit the tenant + default), $true (explicitly disabled) or $false (explicitly ENABLED - the override + that keeps SMTP basic auth alive even after the tenant-wide switch is off). Only + the explicitly-enabled overrides are cached: that set is what the + DisableBasicAuthSMTP baseline grades and clears, and it is small. + + .PARAMETER TenantFilter + The tenant to cache SMTP AUTH overrides for + + .PARAMETER QueueId + The queue ID to update with total tasks (optional) + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter, + [string]$QueueId + ) + + try { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching CAS mailbox SMTP AUTH overrides' -sev Debug + $Overrides = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-CASMailbox' -cmdParams @{ Filter = 'SmtpClientAuthenticationDisabled -eq $false'; Properties = @('SmtpClientAuthenticationDisabled') } + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'ExoCASMailboxSmtpAuth' -Data @($Overrides | Where-Object { $_ }) -AddCount -ClearOnEmpty + $Overrides = $null + + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached CAS mailbox SMTP AUTH overrides successfully' -sev Debug + + } catch { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Failed to cache CAS mailbox SMTP AUTH overrides: $($_.Exception.Message)" -sev Error + } +} diff --git a/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheSecurityDefaults.ps1 b/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheSecurityDefaults.ps1 new file mode 100644 index 0000000000..460d92e958 --- /dev/null +++ b/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheSecurityDefaults.ps1 @@ -0,0 +1,39 @@ +function Set-CIPPDBCacheSecurityDefaults { + <# + .SYNOPSIS + Caches the identity security defaults enforcement policy for a tenant + + .DESCRIPTION + Set-CIPPDBCacheConditionalAccessPolicies also writes this Type, but that collector + returns early for tenants without Entra Premium - exactly the tenants Security + Defaults applies to. This ungated collector keeps the cache populated for them, and + gives the Baselines engine a Set-CIPPDBCache to call on a read miss. + Both writers produce the same row key (Type + policy id), so the write is an upsert. + + .PARAMETER TenantFilter + The tenant to cache the security defaults policy for + + .PARAMETER QueueId + The queue ID to update with total tasks (optional) + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter, + [string]$QueueId + ) + + try { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching Security Defaults policy' -sev Debug + + $SecurityDefaults = New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/policies/identitySecurityDefaultsEnforcementPolicy' -tenantid $TenantFilter -AsApp $true + if ($SecurityDefaults) { + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'SecurityDefaults' -Data @($SecurityDefaults) + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Cached Security Defaults policy (isEnabled=$($SecurityDefaults.isEnabled))" -sev Debug + } + $SecurityDefaults = $null + + } catch { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Failed to cache Security Defaults: $($_.Exception.Message)" -sev Error + } +} diff --git a/frontend/src/components/CippBaselines/CippBaselineStandardSettings.jsx b/frontend/src/components/CippBaselines/CippBaselineStandardSettings.jsx index 09be54513f..870203ebff 100644 --- a/frontend/src/components/CippBaselines/CippBaselineStandardSettings.jsx +++ b/frontend/src/components/CippBaselines/CippBaselineStandardSettings.jsx @@ -19,7 +19,7 @@ export const variableValuesFromExpected = (standard, expectedValue) => { return values } -// The configurable settings of a V3 standard, rendered as real form fields from the +// The configurable settings of a baseline standard, rendered as real form fields from the // definition's `variables`. Used by the template editor and the tenant-override dialog — // users always configure standards through the same fields, never raw JSON. export const CippBaselineStandardSettings = ({ @@ -74,6 +74,13 @@ export const CippBaselineStandardSettings = ({ multiple={false} creatable={false} disabled={definition.locked === true} + // A required variable has no safe fallback: saving without it leaves the + // raw %token% in the baseline, which the engine refuses to compare or apply. + validators={ + definition.required + ? { required: `${definition.label} is required` } + : undefined + } /> {definition.locked && ( From 00686a92bdfdde1877cee3ddfffd5ba870dd9436 Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Sat, 15 Aug 2026 15:56:44 +0200 Subject: [PATCH 059/226] baseline compare items --- .../Baselines/Invoke-CIPPBaselineStandard.ps1 | 89 ++++++------------- 1 file changed, 28 insertions(+), 61 deletions(-) diff --git a/backend/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineStandard.ps1 b/backend/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineStandard.ps1 index 02834d27cb..8eae19c3c5 100644 --- a/backend/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineStandard.ps1 +++ b/backend/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineStandard.ps1 @@ -7,33 +7,7 @@ function Invoke-CIPPBaselineStandard { Licensing is handled upstream: Start-CIPPBaselineOrchestrator strips unlicensed pairs before anything runs. Work items arrive through the durable pipeline as Hashtables - everything item-derived is normalized before use. Flow: - 1. manual definitions track operator completion on the resolved row (reopen on the - configured recurrence); custom definitions delegate to their own - Invoke-CIPPBaseline script. - 2. Read the current value from the CIPPDb cache (cacheType -> optional array - dot-path descend/flatten -> filter[] (properties may be dot-paths) -> object - dot-path). On a cache miss the engine triggers the central collector for that - cacheType and re-reads once; if there is still nothing, NOTHING is written - the - row stays 'No Data' and retries naturally on the next run. read.defaults supplies - the documented service default for a property the API omits until it is - explicitly set, so 'never configured' is not read as drift. - 3. Render the expected template from the configured variable values, project the - current value to the expected keys and compare with Compare-CIPPIntuneObject - (subset). Differences on accepted property paths are tolerated. A variable the - definition marks `required` but the baseline never filled parks the row with an - Error instead: the raw %token% is not a value to compare or deploy. - 4. One Status per row: Compliant / Drift / Accepted / Partially Accepted / - Denied - Remediate Pending / Denied - Delete Pending / Skipped - No License. - Accepted holds until its unix expiry (optionally remediating on lapse); a row - whose drift is fully covered by accepted property paths also scores Accepted, - and partially covered drift scores Partially Accepted. Denied - Remediate - Pending forces remediation regardless of the configured posture. Writes only - happen when needed: drift, -Force (manual runs), or "checkBeforeRun": false - definitions - and never while accepted paths cover live drift, because a write - deploys the whole expected object. - 5. Persist the resolved row + a history row via Set-CIPPBaselineResult. - Modes: run (all steps), compare (never writes), oneoff (remediation forced on). - .FUNCTIONALITY + .FUNCTIONALITY Internal #> [CmdletBinding()] @@ -440,56 +414,49 @@ function Invoke-CIPPBaselineStandard { } } } - - # checkBeforeRun=false marks standards whose pre-check cannot prove the write is - # unnecessary (e.g. a CA template compare only sees name/state) - they write whenever - # remediation applies, cache or not. A missing cache does NOT return early: the - # engine fails OPEN - when the current state cannot be read, an enforced standard - # still applies its expected state, and only a compare/report-only run skips. $CheckBeforeRun = $Definition.checkBeforeRun -ne $false - # 3. Project to the expected keys (subset compare) and diff. A key the current object - # lacks stays present as $null so the compare flags the presence mismatch - EXCEPT - # where read.defaults documents the service default for a property the API omits - # until it is explicitly set (Exchange returns a null OnlineMeetingsByDefaultEnabled - # on a tenant that never touched it, and null there means enabled). Those fill in - # before the compare, so a tenant already in the expected state does not read as - # drift and get written to on every run. Only the properties a definition names are - # filled, and only when the API really returned nothing. + $ReadDefaults = $Definition.read.defaults $Differences = @() $PreFilterDifferences = @() + $ProjectNode = $null + $ProjectNode = { + param($ExpectedNode, $CurrentNode) + $Node = [PSCustomObject]@{} + foreach ($Property in $ExpectedNode.PSObject.Properties) { + $Value = if ($null -ne $CurrentNode) { $CurrentNode.$($Property.Name) } else { $null } + # $anyOf sets are leaf declarations, not shapes to descend into. + if ($Property.Value -is [System.Management.Automation.PSCustomObject] -and + -not $Property.Value.PSObject.Properties['$anyOf'] -and + $Value -is [System.Management.Automation.PSCustomObject]) { + $Value = & $ProjectNode $Property.Value $Value + } + $Node | Add-Member -NotePropertyName $Property.Name -NotePropertyValue $Value + } + $Node + } if ($null -ne $Current) { $Projected = [PSCustomObject]@{} foreach ($Property in $Expected.PSObject.Properties.Name) { $Value = $Current.$Property + $ExpectedLeaf = $Expected.$Property + if (-not $Definition.prepare -and + $ExpectedLeaf -is [System.Management.Automation.PSCustomObject] -and + -not $ExpectedLeaf.PSObject.Properties['$anyOf'] -and + $Value -is [System.Management.Automation.PSCustomObject]) { + $Value = & $ProjectNode $ExpectedLeaf $Value + } if ($null -eq $Value -and $null -ne $ReadDefaults -and $ReadDefaults.PSObject.Properties[$Property]) { $Value = $ReadDefaults.$Property } $Projected | Add-Member -NotePropertyName $Property -NotePropertyValue $Value } $Result.CurrentValue = $Projected - # The compare copy of expected resolves $anyOf against the CURRENT value: a - # member of the set compares equal, a non-member diffs against the canonical. $CompareExpected = & $ResolveAnyOf $ExpectedTemplate $Current $true - # Compare-CIPPIntuneObject emits $null (not an empty set) when nothing differs. - # A prepare hook may request a CompareType (e.g. 'Catalog' flattens settings - # catalog policies to per-setting rows, 'AppProtection' widens the excludes). $CompareTypes = @($Prepared.CompareType | Where-Object { $_ }) $Differences = @(Compare-CIPPIntuneObject -ReferenceObject $CompareExpected -DifferenceObject $Projected -CompareType $CompareTypes | Where-Object { $_ }) - # Hard compares: the shared compare treats false/0/null/''/[] as interchangeable - # empties, which would let 'not configured' satisfy an explicit false/0 - # expectation. The shared function stays untouched (CA/Intune depend on its - # semantics) - this only ADDS the diffs the engine's stricter reading requires: - # an expected boolean or number against an empty current value is drift. - # Definition-controlled via `hardCompare` (default ON; the CA/Intune template - # definitions set false - their prepare pipelines carry their own extensive - # normalization and the lenient empties-equivalence is load-bearing there). - # Two boundaries when enabled: properties the compare deliberately excludes - # (read-only server state like qualityUpdatesWillBeRolledBack) are never - # hard-gapped - same list, one source; and flatten-based Catalog compares - # skip the walker entirely (their paths don't align with the raw tree). $HardCompareEnabled = $Definition.hardCompare -ne $false $HardGapExclusions = @(Get-CIPPIntuneCompareExclusions -AppProtection:($CompareTypes -contains 'AppProtection')) $AddHardGaps = $null @@ -583,8 +550,8 @@ function Invoke-CIPPBaselineStandard { foreach ($DeletedKey in $DeletedKeys) { $AcceptedPaths.PSObject.Properties.Remove($DeletedKey) } $AcceptedKeys = @($AcceptedPaths.PSObject.Properties.Name | Where-Object { $_ }) $DenyDeleteKeys = @($AcceptedPaths.PSObject.Properties | Where-Object { $_.Name -and $_.Value.verdict -eq 'denyDelete' } | ForEach-Object { $_.Name }) - if ($Prior) { $Prior | Add-Member -NotePropertyName 'AcceptedPaths' -NotePropertyValue (ConvertTo-Json -Compress -Depth 20 -InputObject $AcceptedPaths) -Force } - $DropDeleted = { + if ($Prior) { $Prior | Add-Member -NotePropertyName 'AcceptedPaths' -NotePropertyValue (ConvertTo-Json -Compress -Depth 20 -InputObject $AcceptedPaths) -Force } + $DropDeleted = { param($Entries) @($Entries | Where-Object { $Property = $_.Property @@ -598,7 +565,7 @@ function Invoke-CIPPBaselineStandard { $Result.Remediated = $true $Compliant = ($Differences.Count -eq 0) $PathAccepted = $PreFilterDifferences.Count -gt $Differences.Count - if ($DenyDeleteKeys.Count -eq 0 -and $PriorStatus -eq 'Denied - Delete Pending') { $PriorStatus = 'Drift' } + if ($DenyDeleteKeys.Count -eq 0 -and $PriorStatus -eq 'Denied - Delete Pending') { $PriorStatus = 'Drift' } } } From 92dece6a246ea9034c14938051d684bf7aeeebb9 Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Sat, 15 Aug 2026 16:24:04 +0200 Subject: [PATCH 060/226] new caches --- backend/Config/CIPPDBCacheTypes.json | 245 ++++++++++++++++++ .../Set-CIPPDBCacheAdminReportSettings.ps1 | 30 +++ ...CIPPDBCacheAuthenticationMethodsPolicy.ps1 | 16 ++ .../Set-CIPPDBCacheAutopatchGroups.ps1 | 45 ++++ ...CIPPDBCacheAutopilotDeploymentProfiles.ps1 | 36 +++ .../Set-CIPPDBCacheB2BManagementPolicy.ps1 | 21 +- ...CIPPDBCacheComplianceRetentionPolicies.ps1 | 46 ++++ ...et-CIPPDBCacheComplianceRetentionRules.ps1 | 46 ++++ .../Set-CIPPDBCacheCopilotAdminSettings.ps1 | 32 +++ .../Set-CIPPDBCacheCopilotPolicySettings.ps1 | 59 +++++ ...PDBCacheDeviceEnrollmentConfigurations.ps1 | 45 ++++ .../Public/DBCache/Set-CIPPDBCacheDevices.ps1 | 2 +- .../Set-CIPPDBCacheDlpCompliancePolicies.ps1 | 27 +- ...et-CIPPDBCacheExoDlpSensitiveInfoTypes.ps1 | 47 ++++ ...CIPPDBCacheExoDynamicDistributionGroup.ps1 | 31 +++ .../Set-CIPPDBCacheExoExternalInOutlook.ps1 | 37 +++ ...t-CIPPDBCacheExoGlobalQuarantinePolicy.ps1 | 35 +++ ...DBCacheExoHostedConnectionFilterPolicy.ps1 | 32 +++ ...-CIPPDBCacheExoHostedContentFilterRule.ps1 | 32 +++ .../DBCache/Set-CIPPDBCacheExoLabels.ps1 | 47 ++++ .../Set-CIPPDBCacheExoMailContacts.ps1 | 96 +++++++ .../Set-CIPPDBCacheExoMailboxPlans.ps1 | 65 +++++ .../Set-CIPPDBCacheExoOMEConfiguration.ps1 | 33 +++ .../Set-CIPPDBCacheExoOutboundConnector.ps1 | 32 +++ .../Set-CIPPDBCacheExoPhishSimConfig.ps1 | 58 +++++ .../Set-CIPPDBCacheExoRetentionPolicies.ps1 | 36 +++ .../Set-CIPPDBCacheExoRetentionPolicyTags.ps1 | 46 ++++ ...Set-CIPPDBCacheExoRoleAssignmentPolicy.ps1 | 32 +++ ...et-CIPPDBCacheExoTeamsProtectionPolicy.ps1 | 33 +++ ...CacheExoTenantAllowBlockListSpoofItems.ps1 | 49 ++++ .../DBCache/Set-CIPPDBCacheFormsSettings.ps1 | 36 +++ ...et-CIPPDBCacheHomeRealmDiscoveryPolicy.ps1 | 52 ++++ .../Set-CIPPDBCacheIntuneApplications.ps1 | 10 + .../Set-CIPPDBCacheIntuneBrandingProfile.ps1 | 38 +++ ...PPDBCacheIntuneDataProcessorOnboarding.ps1 | 41 +++ ...heIntuneDeviceEnrollmentConfigurations.ps1 | 76 ++++++ ...PDBCacheIntuneDeviceManagementSettings.ps1 | 41 +++ .../DBCache/Set-CIPPDBCacheMailboxes.ps1 | 9 +- ...t-CIPPDBCacheManagedDeviceCleanupRules.ps1 | 41 +++ ...PDBCacheMobileDeviceManagementPolicies.ps1 | 38 +++ .../DBCache/Set-CIPPDBCacheMoeraDmarc.ps1 | 72 +++++ .../Set-CIPPDBCacheNamePronunciation.ps1 | 30 +++ .../Set-CIPPDBCacheOrganizationBranding.ps1 | 37 +++ .../DBCache/Set-CIPPDBCachePeopleInsights.ps1 | 37 +++ ...Set-CIPPDBCachePermissionGrantPolicies.ps1 | 31 +++ .../Set-CIPPDBCachePhotoUpdateSettings.ps1 | 31 +++ .../DBCache/Set-CIPPDBCachePronouns.ps1 | 30 +++ .../Set-CIPPDBCacheReportSubmissionRule.ps1 | 33 +++ ...-CIPPDBCacheSecureScoreControlProfiles.ps1 | 36 +++ ...CIPPDBCacheSelfServicePurchaseProducts.ps1 | 61 +++++ ...Set-CIPPDBCacheSharePointAdminSettings.ps1 | 30 +++ .../Set-CIPPDBCacheTeamsResourceAccounts.ps1 | 65 +++++ 52 files changed, 2258 insertions(+), 8 deletions(-) create mode 100644 backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheAdminReportSettings.ps1 create mode 100644 backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheAutopatchGroups.ps1 create mode 100644 backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheAutopilotDeploymentProfiles.ps1 create mode 100644 backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheComplianceRetentionPolicies.ps1 create mode 100644 backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheComplianceRetentionRules.ps1 create mode 100644 backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheCopilotAdminSettings.ps1 create mode 100644 backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheCopilotPolicySettings.ps1 create mode 100644 backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheDeviceEnrollmentConfigurations.ps1 create mode 100644 backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoDlpSensitiveInfoTypes.ps1 create mode 100644 backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoDynamicDistributionGroup.ps1 create mode 100644 backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoExternalInOutlook.ps1 create mode 100644 backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoGlobalQuarantinePolicy.ps1 create mode 100644 backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoHostedConnectionFilterPolicy.ps1 create mode 100644 backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoHostedContentFilterRule.ps1 create mode 100644 backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoLabels.ps1 create mode 100644 backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoMailContacts.ps1 create mode 100644 backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoMailboxPlans.ps1 create mode 100644 backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoOMEConfiguration.ps1 create mode 100644 backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoOutboundConnector.ps1 create mode 100644 backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoPhishSimConfig.ps1 create mode 100644 backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoRetentionPolicies.ps1 create mode 100644 backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoRetentionPolicyTags.ps1 create mode 100644 backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoRoleAssignmentPolicy.ps1 create mode 100644 backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoTeamsProtectionPolicy.ps1 create mode 100644 backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoTenantAllowBlockListSpoofItems.ps1 create mode 100644 backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheFormsSettings.ps1 create mode 100644 backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheHomeRealmDiscoveryPolicy.ps1 create mode 100644 backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheIntuneBrandingProfile.ps1 create mode 100644 backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheIntuneDataProcessorOnboarding.ps1 create mode 100644 backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheIntuneDeviceEnrollmentConfigurations.ps1 create mode 100644 backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheIntuneDeviceManagementSettings.ps1 create mode 100644 backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheManagedDeviceCleanupRules.ps1 create mode 100644 backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheMobileDeviceManagementPolicies.ps1 create mode 100644 backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheMoeraDmarc.ps1 create mode 100644 backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheNamePronunciation.ps1 create mode 100644 backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheOrganizationBranding.ps1 create mode 100644 backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCachePeopleInsights.ps1 create mode 100644 backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCachePermissionGrantPolicies.ps1 create mode 100644 backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCachePhotoUpdateSettings.ps1 create mode 100644 backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCachePronouns.ps1 create mode 100644 backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheReportSubmissionRule.ps1 create mode 100644 backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheSecureScoreControlProfiles.ps1 create mode 100644 backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheSelfServicePurchaseProducts.ps1 create mode 100644 backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheSharePointAdminSettings.ps1 create mode 100644 backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheTeamsResourceAccounts.ps1 diff --git a/backend/Config/CIPPDBCacheTypes.json b/backend/Config/CIPPDBCacheTypes.json index 7645912031..b166c8b7ad 100644 --- a/backend/Config/CIPPDBCacheTypes.json +++ b/backend/Config/CIPPDBCacheTypes.json @@ -408,5 +408,250 @@ "type": "DefenderCVEs", "friendlyName": "Defender CVEs", "description": "All Defender CVEs for Devices" + }, + { + "type": "AdminReportSettings", + "friendlyName": "Admin Report Settings", + "description": "Microsoft 365 admin report settings including display of concealed names in reports" + }, + { + "type": "SharePointAdminSettings", + "friendlyName": "SharePoint Admin Settings", + "description": "SharePoint tenant admin settings including sharing capability, site creation, sync, timezone and excluded file extensions" + }, + { + "type": "PeopleInsights", + "friendlyName": "People Insights Settings", + "description": "Organization-level people insights (Viva Insights) settings" + }, + { + "type": "Pronouns", + "friendlyName": "Pronouns Settings", + "description": "Tenant pronouns feature settings for user profiles" + }, + { + "type": "NamePronunciation", + "friendlyName": "Name Pronunciation Settings", + "description": "Tenant name pronunciation feature settings for user profiles" + }, + { + "type": "PhotoUpdateSettings", + "friendlyName": "Photo Update Settings", + "description": "Profile photo update settings including allowed roles and source" + }, + { + "type": "OrganizationBranding", + "friendlyName": "Organization Branding", + "description": "Organization branding localizations including sign-in page text, username hints and login page layout" + }, + { + "type": "HomeRealmDiscoveryPolicy", + "friendlyName": "Home Realm Discovery Policies", + "description": "Home realm discovery policies with normalized alternate ID login (email as alternate login ID) state" + }, + { + "type": "MobileDeviceManagementPolicies", + "friendlyName": "Mobile Device Management Policies", + "description": "Intune MDM application policy including user scope, enrollment URLs, MDM enrollment during registration and included groups" + }, + { + "type": "CopilotAdminSettings", + "friendlyName": "Copilot Admin Settings", + "description": "Microsoft 365 Copilot admin limited mode settings for Teams meetings" + }, + { + "type": "CopilotPolicySettings", + "friendlyName": "Copilot Policy Settings", + "description": "Microsoft 365 Copilot tenant policy settings including chat pinning, open file access, image generation, web search and admin center Copilot" + }, + { + "type": "SecureScoreControlProfiles", + "friendlyName": "Secure Score Control Profiles", + "description": "Microsoft Secure Score control profiles with control metadata and remediation details" + }, + { + "type": "FormsSettings", + "friendlyName": "Forms Settings", + "description": "Microsoft Forms tenant settings including external sharing and collaboration options" + }, + { + "type": "PermissionGrantPolicies", + "friendlyName": "Permission Grant Policies", + "description": "Permission grant policies with their includes/excludes condition sets for OAuth app consent" + }, + { + "type": "AutopilotDeploymentProfiles", + "friendlyName": "Autopilot Deployment Profiles", + "description": "Windows Autopilot deployment profiles with assignments" + }, + { + "type": "DeviceEnrollmentConfigurations", + "friendlyName": "Device Enrollment Configurations", + "description": "All Intune device enrollment configurations with full settings payloads" + }, + { + "type": "IntuneDeviceEnrollmentConfigurations", + "friendlyName": "Intune Device Enrollment Configurations", + "description": "Device enrollment configurations with assignments (legacy IntunePolicies cache type)" + }, + { + "type": "IntuneDeviceManagementSettings", + "friendlyName": "Intune Device Management Settings", + "description": "Tenant-wide Intune device management settings (secureByDefault, compliance check-in threshold)" + }, + { + "type": "IntuneDataProcessorOnboarding", + "friendlyName": "Windows Data Processor Onboarding", + "description": "Windows diagnostic data processor service onboarding state" + }, + { + "type": "IntuneBrandingProfile", + "friendlyName": "Intune Branding Profiles", + "description": "Intune Company Portal branding profiles" + }, + { + "type": "ManagedDeviceCleanupRules", + "friendlyName": "Managed Device Cleanup Rules", + "description": "Intune managed device cleanup rules (device retirement days)" + }, + { + "type": "AutopatchGroups", + "friendlyName": "Windows Autopatch Groups", + "description": "Windows Autopatch groups with deployment ring settings" + }, + { + "type": "IntuneMobileAppsAll", + "friendlyName": "All Intune Mobile Apps", + "description": "Unfiltered mobile apps list (id, displayName, odata type) for presence checks" + }, + { + "type": "ExoHostedConnectionFilterPolicy", + "friendlyName": "Exchange Hosted Connection Filter Policies", + "description": "Exchange Online anti-spam connection filter policies (safe list, IP allow/block lists)" + }, + { + "type": "ExoExternalInOutlook", + "friendlyName": "Exchange External Sender Identification", + "description": "Exchange Online external sender identification (ExternalInOutlook) configuration" + }, + { + "type": "ExoTeamsProtectionPolicy", + "friendlyName": "Teams Protection Policies", + "description": "Microsoft Teams protection policies including Zero-hour auto purge (ZAP) settings" + }, + { + "type": "ExoOutboundConnector", + "friendlyName": "Exchange Outbound Connectors", + "description": "Exchange Online outbound connectors" + }, + { + "type": "ExoRoleAssignmentPolicy", + "friendlyName": "Exchange Role Assignment Policies", + "description": "Exchange Online role assignment policies including assigned roles and default policy flag" + }, + { + "type": "ExoHostedContentFilterRule", + "friendlyName": "Exchange Hosted Content Filter Rules", + "description": "Exchange Online anti-spam (hosted content filter) rules including state, priority and recipient domains" + }, + { + "type": "ReportSubmissionRule", + "friendlyName": "Exchange Report Submission Rules", + "description": "Exchange Online user-reported message submission rules" + }, + { + "type": "ExoOMEConfiguration", + "friendlyName": "Exchange OME Configurations", + "description": "Exchange Online Message Encryption (OME) branding configurations" + }, + { + "type": "ExoMailboxPlans", + "friendlyName": "Exchange Mailbox Plans", + "description": "Mailbox plans with recipient limits and send/receive sizes normalized to MB" + }, + { + "type": "ExoRetentionPolicyTags", + "friendlyName": "Exchange Retention Policy Tags", + "description": "Retention policy tags with retention action, age limit and derived day count" + }, + { + "type": "ExoRetentionPolicies", + "friendlyName": "Exchange Retention Policies", + "description": "Retention policies including linked retention policy tags" + }, + { + "type": "ExoDynamicDistributionGroup", + "friendlyName": "Dynamic Distribution Groups", + "description": "Exchange Online dynamic distribution groups with recipient filters" + }, + { + "type": "ExoMailContacts", + "friendlyName": "Mail Contacts", + "description": "Mail contacts merged with extended directory contact properties" + }, + { + "type": "ExoTenantAllowBlockListSpoofItems", + "friendlyName": "Tenant Allow/Block List Spoof Items", + "description": "Spoofed sender allow/block entries from the Tenant Allow/Block List" + }, + { + "type": "ExoPhishSimOverridePolicy", + "friendlyName": "Phishing Simulation Override Policy", + "description": "Third-party phishing simulation override policy" + }, + { + "type": "ExoPhishSimOverrideRule", + "friendlyName": "Phishing Simulation Override Rule", + "description": "Phishing simulation override rule with sender IP ranges and domains" + }, + { + "type": "ExoPhishSimUrlAllowItems", + "friendlyName": "Phishing Simulation URL Allow Items", + "description": "Advanced delivery URL allow entries for phishing simulations" + }, + { + "type": "ComplianceRetentionPolicies", + "friendlyName": "Retention Compliance Policies", + "description": "Microsoft Purview retention compliance policies from the compliance portal" + }, + { + "type": "ComplianceRetentionRules", + "friendlyName": "Retention Compliance Rules", + "description": "Microsoft Purview retention compliance rules from the compliance portal" + }, + { + "type": "ExoDlpSensitiveInfoTypes", + "friendlyName": "Sensitive Information Type Rule Packages", + "description": "Microsoft Purview Sensitive Information Type rule packages including the classification rule XML" + }, + { + "type": "ExoLabels", + "friendlyName": "Compliance Sensitivity Labels", + "description": "Microsoft Purview sensitivity labels from the Security & Compliance endpoint" + }, + { + "type": "DlpComplianceRules", + "friendlyName": "DLP Compliance Rules", + "description": "Data Loss Prevention compliance rules from the Purview compliance portal" + }, + { + "type": "TeamsResourceAccounts", + "friendlyName": "Teams Resource Accounts", + "description": "Teams Auto Attendant and Call Queue resource accounts from Teams.PlatformService" + }, + { + "type": "Fido2Configuration", + "friendlyName": "FIDO2 Authentication Method Configuration", + "description": "FIDO2 passkey authentication method configuration including passkey profiles" + }, + { + "type": "MoeraDmarc", + "friendlyName": "MOERA Domain DMARC", + "description": "Live DNS DMARC state for onmicrosoft.com (MOERA) domains" + }, + { + "type": "SelfServicePurchaseProducts", + "friendlyName": "Self-Service Purchase Products", + "description": "AllowSelfServicePurchase product policies and trial autoclaim policy" } ] diff --git a/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheAdminReportSettings.ps1 b/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheAdminReportSettings.ps1 new file mode 100644 index 0000000000..051c944c22 --- /dev/null +++ b/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheAdminReportSettings.ps1 @@ -0,0 +1,30 @@ +function Set-CIPPDBCacheAdminReportSettings { + <# + .SYNOPSIS + Caches admin report settings for a tenant + + .PARAMETER TenantFilter + The tenant to cache admin report settings for + + .PARAMETER QueueId + The queue ID to update with total tasks (optional) + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter, + [string]$QueueId + ) + + try { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching admin report settings' -sev Debug + $ReportSettings = New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/admin/reportSettings' -tenantid $TenantFilter -AsApp $true + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'AdminReportSettings' -Data @($ReportSettings) -AddCount + $ReportSettings = $null + + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached admin report settings successfully' -sev Debug + + } catch { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Failed to cache admin report settings: $($_.Exception.Message)" -sev Error + } +} diff --git a/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheAuthenticationMethodsPolicy.ps1 b/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheAuthenticationMethodsPolicy.ps1 index e1b63f15b8..3f26788dbc 100644 --- a/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheAuthenticationMethodsPolicy.ps1 +++ b/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheAuthenticationMethodsPolicy.ps1 @@ -24,6 +24,22 @@ function Set-CIPPDBCacheAuthenticationMethodsPolicy { Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached authentication methods policy successfully' -sev Debug + # The fido2 entry embedded in the policy above carries defaultPasskeyProfile (structural + # property) but NOT passkeyProfiles: in the Graph beta metadata passkeyProfiles is a + # navigation property, so it is only serialized on a direct GET of the fido2 configuration. + # FIDO2PasskeyProfiles needs both, so fetch and cache the fido2 configuration directly, + # app-only to match how that standard reads it. + try { + $Fido2Configuration = New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/policies/authenticationMethodsPolicy/authenticationMethodConfigurations/fido2' -tenantid $TenantFilter -AsApp $true + if ($Fido2Configuration) { + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'Fido2Configuration' -Data @($Fido2Configuration) -AddCount + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached FIDO2 authentication method configuration successfully' -sev Debug + } + $Fido2Configuration = $null + } catch { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Failed to cache FIDO2 authentication method configuration: $($_.Exception.Message)" -sev Warning + } + } catch { Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Failed to cache authentication methods policy: $($_.Exception.Message)" -sev Error } diff --git a/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheAutopatchGroups.ps1 b/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheAutopatchGroups.ps1 new file mode 100644 index 0000000000..ad14fb8ca1 --- /dev/null +++ b/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheAutopatchGroups.ps1 @@ -0,0 +1,45 @@ +function Set-CIPPDBCacheAutopatchGroups { + <# + .SYNOPSIS + Caches Windows Autopatch groups for a tenant + + .DESCRIPTION + Caches the Autopatch group list (name, id and deploymentGroups settings) from the + Microsoft Autopatch API proxy used by the AutopatchGroup standard. The proxy exists + until native Graph API support for Autopatch groups is available. + + .PARAMETER TenantFilter + The tenant to cache Autopatch groups for + + .PARAMETER QueueId + The queue ID to update with total tasks (optional) + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter, + [string]$QueueId + ) + + try { + $TestResult = Test-CIPPStandardLicense -StandardName 'AutopatchGroupsCache' -TenantFilter $TenantFilter -Preset Intune -SkipLog + if ($TestResult -eq $false) { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Tenant does not have Intune license, skipping Autopatch groups cache' -sev Debug + return + } + + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching Autopatch groups' -sev Debug + + # Same URI and auth as the AutopatchGroup standard: the Microsoft-provided Autopatch API + # proxy accepts the app-only Graph token issued by New-GraphGetRequest. + $AutopatchProxyBase = 'https://intuneautopatchbeta-bwhtaqgefgcyaaa8.westeurope-01.azurewebsites.net/api/autoPatch' + $AutopatchGroups = New-GraphGetRequest -uri $AutopatchProxyBase -tenantid $TenantFilter -AsApp $true + if (-not $AutopatchGroups) { $AutopatchGroups = @() } + + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'AutopatchGroups' -Data @($AutopatchGroups) -AddCount + + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Cached $(($AutopatchGroups | Measure-Object).Count) Autopatch groups" -sev Debug + } catch { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Failed to cache Autopatch groups: $($_.Exception.Message)" -sev Error + } +} diff --git a/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheAutopilotDeploymentProfiles.ps1 b/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheAutopilotDeploymentProfiles.ps1 new file mode 100644 index 0000000000..2dec2aee67 --- /dev/null +++ b/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheAutopilotDeploymentProfiles.ps1 @@ -0,0 +1,36 @@ +function Set-CIPPDBCacheAutopilotDeploymentProfiles { + <# + .SYNOPSIS + Caches Windows Autopilot deployment profiles for a tenant + + .PARAMETER TenantFilter + The tenant to cache Autopilot deployment profiles for + + .PARAMETER QueueId + The queue ID to update with total tasks (optional) + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter, + [string]$QueueId + ) + + try { + $TestResult = Test-CIPPStandardLicense -StandardName 'AutopilotDeploymentProfilesCache' -TenantFilter $TenantFilter -Preset Intune -SkipLog + if ($TestResult -eq $false) { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Tenant does not have Intune license, skipping Autopilot deployment profiles cache' -sev Debug + return + } + + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching Autopilot deployment profiles' -sev Debug + + $Profiles = New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/deviceManagement/windowsAutopilotDeploymentProfiles?$top=999&$expand=assignments' -tenantid $TenantFilter + if (-not $Profiles) { $Profiles = @() } + + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'AutopilotDeploymentProfiles' -Data @($Profiles) -AddCount + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Cached $(($Profiles | Measure-Object).Count) Autopilot deployment profiles" -sev Debug + } catch { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Failed to cache Autopilot deployment profiles: $($_.Exception.Message)" -sev Error + } +} diff --git a/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheB2BManagementPolicy.ps1 b/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheB2BManagementPolicy.ps1 index 0a3b901af6..36255c5e96 100644 --- a/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheB2BManagementPolicy.ps1 +++ b/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheB2BManagementPolicy.ps1 @@ -20,7 +20,26 @@ function Set-CIPPDBCacheB2BManagementPolicy { Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching B2B management policy' -sev Debug $LegacyPolicies = New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/policies/b2bManagementPolicies' -tenantid $TenantFilter - $B2BManagementPolicy = $LegacyPolicies + + # Keep the raw rows, but project the settings buried in the definition JSON blob so consumers + # (e.g. the CollaborationDomainRestriction compare) do not have to re-parse it. The actual + # settings live in definition[0] as a JSON string: + # {"B2BManagementPolicy":{"InvitationsAllowedAndBlockedDomainsPolicy":{"AllowedDomains":[],"BlockedDomains":[]},...}} + $B2BManagementPolicy = foreach ($Policy in @($LegacyPolicies)) { + if ($null -eq $Policy) { continue } + $ParsedDefinition = $null + if ($Policy.definition) { + try { $ParsedDefinition = @($Policy.definition)[0] | ConvertFrom-Json } catch { $ParsedDefinition = $null } + } + $DomainPolicy = $ParsedDefinition.B2BManagementPolicy.InvitationsAllowedAndBlockedDomainsPolicy + $AllowedDomains = @($DomainPolicy.AllowedDomains) + $BlockedDomains = @($DomainPolicy.BlockedDomains) + $Policy | Add-Member -NotePropertyName 'parsedDefinition' -NotePropertyValue $ParsedDefinition -Force + $Policy | Add-Member -NotePropertyName 'allowedDomains' -NotePropertyValue $AllowedDomains -Force + $Policy | Add-Member -NotePropertyName 'blockedDomains' -NotePropertyValue $BlockedDomains -Force + $Policy | Add-Member -NotePropertyName 'hasRestrictions' -NotePropertyValue (($AllowedDomains.Count -gt 0) -or ($BlockedDomains.Count -gt 0)) -Force + $Policy + } if ($B2BManagementPolicy) { Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'B2BManagementPolicy' -Data @($B2BManagementPolicy) -AddCount diff --git a/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheComplianceRetentionPolicies.ps1 b/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheComplianceRetentionPolicies.ps1 new file mode 100644 index 0000000000..a51e14ccc9 --- /dev/null +++ b/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheComplianceRetentionPolicies.ps1 @@ -0,0 +1,46 @@ +function Set-CIPPDBCacheComplianceRetentionPolicies { + <# + .SYNOPSIS + Caches Purview retention compliance policies for a tenant (requires Purview/AIP license) + + .DESCRIPTION + Calls Get-RetentionCompliancePolicy against the Security & Compliance endpoint and writes the + results into the CIPP database under Type 'ComplianceRetentionPolicies'. Uses the application + token (-AsApp) because retention cmdlets are restricted for GDAP delegated identities. + + .PARAMETER TenantFilter + The tenant to cache retention compliance policies for + + .PARAMETER QueueId + The queue ID to update with total tasks (optional) + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter, + [string]$QueueId + ) + + try { + $LicenseCheck = Test-CIPPStandardLicense -StandardName 'ComplianceRetentionPoliciesCache' -TenantFilter $TenantFilter -Preset Compliance -SkipLog + + if ($LicenseCheck -eq $false) { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Tenant does not have a Purview/AIP license, skipping retention compliance policies' -sev Debug + return + } + + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching retention compliance policies' -sev Debug + + $Tenant = Get-Tenants -TenantFilter $TenantFilter | Select-Object -First 1 + $Policies = New-ExoRequest -TenantId $Tenant.customerId -cmdlet 'Get-RetentionCompliancePolicy' -Compliance -AsApp | Select-Object * -ExcludeProperty '*odata*', '*data.type*' + + if ($Policies) { + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'ComplianceRetentionPolicies' -Data @($Policies) -AddCount + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Cached $(@($Policies).Count) retention compliance policies" -sev Debug + } + + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Failed to cache retention compliance policies: $($ErrorMessage.NormalizedError)" -sev Warning -LogData $ErrorMessage + } +} diff --git a/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheComplianceRetentionRules.ps1 b/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheComplianceRetentionRules.ps1 new file mode 100644 index 0000000000..712ad8a419 --- /dev/null +++ b/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheComplianceRetentionRules.ps1 @@ -0,0 +1,46 @@ +function Set-CIPPDBCacheComplianceRetentionRules { + <# + .SYNOPSIS + Caches Purview retention compliance rules for a tenant (requires Purview/AIP license) + + .DESCRIPTION + Calls Get-RetentionComplianceRule against the Security & Compliance endpoint and writes the + results into the CIPP database under Type 'ComplianceRetentionRules'. Uses the application + token (-AsApp) because retention cmdlets are restricted for GDAP delegated identities. + + .PARAMETER TenantFilter + The tenant to cache retention compliance rules for + + .PARAMETER QueueId + The queue ID to update with total tasks (optional) + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter, + [string]$QueueId + ) + + try { + $LicenseCheck = Test-CIPPStandardLicense -StandardName 'ComplianceRetentionRulesCache' -TenantFilter $TenantFilter -Preset Compliance -SkipLog + + if ($LicenseCheck -eq $false) { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Tenant does not have a Purview/AIP license, skipping retention compliance rules' -sev Debug + return + } + + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching retention compliance rules' -sev Debug + + $Tenant = Get-Tenants -TenantFilter $TenantFilter | Select-Object -First 1 + $Rules = New-ExoRequest -TenantId $Tenant.customerId -cmdlet 'Get-RetentionComplianceRule' -Compliance -AsApp | Select-Object * -ExcludeProperty '*odata*', '*data.type*' + + if ($Rules) { + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'ComplianceRetentionRules' -Data @($Rules) -AddCount + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Cached $(@($Rules).Count) retention compliance rules" -sev Debug + } + + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Failed to cache retention compliance rules: $($ErrorMessage.NormalizedError)" -sev Warning -LogData $ErrorMessage + } +} diff --git a/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheCopilotAdminSettings.ps1 b/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheCopilotAdminSettings.ps1 new file mode 100644 index 0000000000..ae45e1b064 --- /dev/null +++ b/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheCopilotAdminSettings.ps1 @@ -0,0 +1,32 @@ +function Set-CIPPDBCacheCopilotAdminSettings { + <# + .SYNOPSIS + Caches Copilot admin limited mode settings for a tenant + + .PARAMETER TenantFilter + The tenant to cache Copilot admin settings for + + .PARAMETER QueueId + The queue ID to update with total tasks (optional) + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter, + [string]$QueueId + ) + + try { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching Copilot admin settings' -sev Debug + + # The Copilot admin settings API currently requires delegated auth (no -AsApp) + $LimitedMode = New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/copilot/admin/settings/limitedMode' -tenantid $TenantFilter + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'CopilotAdminSettings' -Data @($LimitedMode) -AddCount + $LimitedMode = $null + + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached Copilot admin settings successfully' -sev Debug + + } catch { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Failed to cache Copilot admin settings: $($_.Exception.Message)" -sev Error + } +} diff --git a/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheCopilotPolicySettings.ps1 b/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheCopilotPolicySettings.ps1 new file mode 100644 index 0000000000..d1ded16501 --- /dev/null +++ b/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheCopilotPolicySettings.ps1 @@ -0,0 +1,59 @@ +function Set-CIPPDBCacheCopilotPolicySettings { + <# + .SYNOPSIS + Caches Copilot admin policy settings for a tenant + + .DESCRIPTION + Caches the five supported Copilot policy settings (Copilot Chat pinning, block access to open + files, image generation, web search and admin center Copilot) as one row per setting with + id, value and policyId. + + .PARAMETER TenantFilter + The tenant to cache Copilot policy settings for + + .PARAMETER QueueId + The queue ID to update with total tasks (optional) + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter, + [string]$QueueId + ) + + try { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching Copilot policy settings' -sev Debug + + $SettingIds = @( + 'microsoft.copilot.copilotchatpinning' + 'microsoft.copilot.blockaccesstoopenfiles' + 'microsoft.copilot.imagegeneration' + 'microsoft.copilot.allowwebsearch' + 'microsoft.copilot.allowinadmincenters' + ) + + # The Copilot policySettings API currently requires delegated auth (no -AsApp). The entity + # carries a scalar 'value' property that is data rather than a collection envelope, so + # -SkipValueExtraction returns the entity intact. + $PolicySettings = foreach ($SettingId in $SettingIds) { + try { + $Current = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/copilot/admin/policySettings/$SettingId" -tenantid $TenantFilter -SkipValueExtraction + [PSCustomObject]@{ + id = $SettingId + value = $Current.value + policyId = $Current.policyId + } + } catch { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Failed to get Copilot policy setting '$SettingId': $($_.Exception.Message)" -sev Warning + } + } + + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'CopilotPolicySettings' -Data @($PolicySettings) -AddCount + $PolicySettings = $null + + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached Copilot policy settings successfully' -sev Debug + + } catch { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Failed to cache Copilot policy settings: $($_.Exception.Message)" -sev Error + } +} diff --git a/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheDeviceEnrollmentConfigurations.ps1 b/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheDeviceEnrollmentConfigurations.ps1 new file mode 100644 index 0000000000..fb23fea07a --- /dev/null +++ b/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheDeviceEnrollmentConfigurations.ps1 @@ -0,0 +1,45 @@ +function Set-CIPPDBCacheDeviceEnrollmentConfigurations { + <# + .SYNOPSIS + Caches all Intune device enrollment configurations for a tenant + + .DESCRIPTION + Caches every deviceEnrollmentConfiguration row with its full settings payload + (id, deviceEnrollmentConfigurationType, priority and all type-specific settings). + This single cache serves AutopilotStatusPage, DefaultPlatformRestrictions and + EnrollmentWindowsHelloForBusinessConfiguration. + + .PARAMETER TenantFilter + The tenant to cache device enrollment configurations for + + .PARAMETER QueueId + The queue ID to update with total tasks (optional) + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter, + [string]$QueueId + ) + + try { + $TestResult = Test-CIPPStandardLicense -StandardName 'DeviceEnrollmentConfigurationsCache' -TenantFilter $TenantFilter -Preset Intune -SkipLog + if ($TestResult -eq $false) { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Tenant does not have Intune license, skipping device enrollment configurations cache' -sev Debug + return + } + + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching device enrollment configurations' -sev Debug + + # -AsApp matches the old DefaultPlatformRestrictions / EnrollmentWindowsHelloForBusinessConfiguration + # standards; app-only reads every configuration type regardless of delegated scopes. + $Configurations = New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/deviceManagement/deviceEnrollmentConfigurations?$top=999' -tenantid $TenantFilter -AsApp $true + if (-not $Configurations) { $Configurations = @() } + + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'DeviceEnrollmentConfigurations' -Data @($Configurations) -AddCount + + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Cached $(($Configurations | Measure-Object).Count) device enrollment configurations" -sev Debug + } catch { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Failed to cache device enrollment configurations: $($_.Exception.Message)" -sev Error + } +} diff --git a/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheDevices.ps1 b/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheDevices.ps1 index a51fd7ce1b..62e7fbe041 100644 --- a/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheDevices.ps1 +++ b/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheDevices.ps1 @@ -19,7 +19,7 @@ function Set-CIPPDBCacheDevices { try { Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching Azure AD devices' -sev Debug - New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/devices?$top=999&$select=id,displayName,operatingSystem,operatingSystemVersion,trustType,accountEnabled,approximateLastSignInDateTime' -tenantid $TenantFilter -Stream | + New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/devices?$top=999&$select=id,displayName,operatingSystem,operatingSystemVersion,trustType,accountEnabled,approximateLastSignInDateTime,onPremisesSyncEnabled,isManaged,isCompliant,physicalIds,enrollmentProfileName,managementType,profileType' -tenantid $TenantFilter -Stream | Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'Devices' -AddCount Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached Azure AD devices successfully' -sev Debug diff --git a/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheDlpCompliancePolicies.ps1 b/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheDlpCompliancePolicies.ps1 index 13d9a99273..2186b844e1 100644 --- a/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheDlpCompliancePolicies.ps1 +++ b/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheDlpCompliancePolicies.ps1 @@ -1,7 +1,13 @@ function Set-CIPPDBCacheDlpCompliancePolicies { <# .SYNOPSIS - Caches DLP compliance policies for a tenant (requires AIP/Purview license) + Caches DLP compliance policies and rules for a tenant (requires AIP/Purview license) + + .DESCRIPTION + Caches the full Get-DlpCompliancePolicy objects under Type 'DlpCompliancePolicies' and the full + Get-DlpComplianceRule objects under Type 'DlpComplianceRules', so template drift comparison + (Compare-CIPPDlpCompliancePolicy, which allowlist-filters via Get-CIPPDlpComplianceFieldList and + matches rules on ParentPolicyName) can run off cache. .PARAMETER TenantFilter The tenant to cache DLP policies for @@ -27,15 +33,26 @@ function Set-CIPPDBCacheDlpCompliancePolicies { Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching DLP compliance policies' -sev Debug $Tenant = Get-Tenants -TenantFilter $TenantFilter | Select-Object -First 1 - $Policies = New-ExoRequest -TenantId $Tenant.customerId -cmdlet 'Get-DlpCompliancePolicy' -Compliance -Select 'Name,DisplayName,Mode,Enabled,Workload,CreatedBy,WhenCreatedUTC,WhenChangedUTC' + # Full objects (no -Select): the template compare needs every field in the + # Get-CIPPDlpComplianceFieldList Policy allowlist (Comment, Mode, all *Location* fields, ...). + $Policies = New-ExoRequest -TenantId $Tenant.customerId -cmdlet 'Get-DlpCompliancePolicy' -Compliance | Select-Object * -ExcludeProperty '*odata*', '*data.type*' if ($Policies) { - Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'DlpCompliancePolicies' -Data $Policies -AddCount - Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Cached $($Policies.Count) DLP compliance policies" -sev Debug + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'DlpCompliancePolicies' -Data @($Policies) -AddCount + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Cached $(@($Policies).Count) DLP compliance policies" -sev Debug + } + + # Full rule objects: the compare needs the Rule allowlist fields (AdvancedRule, conditions, + # actions, ...) plus ParentPolicyName to match rules to their parent policy. + $Rules = New-ExoRequest -TenantId $Tenant.customerId -cmdlet 'Get-DlpComplianceRule' -Compliance | Select-Object * -ExcludeProperty '*odata*', '*data.type*' + + if ($Rules) { + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'DlpComplianceRules' -Data @($Rules) -AddCount + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Cached $(@($Rules).Count) DLP compliance rules" -sev Debug } } catch { $ErrorMessage = Get-CippException -Exception $_ - Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Failed to cache DLP compliance policies: $($ErrorMessage.NormalizedError)" -sev Warning -LogData $ErrorMessage + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Failed to cache DLP compliance policies/rules: $($ErrorMessage.NormalizedError)" -sev Warning -LogData $ErrorMessage } } diff --git a/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoDlpSensitiveInfoTypes.ps1 b/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoDlpSensitiveInfoTypes.ps1 new file mode 100644 index 0000000000..dd640f3440 --- /dev/null +++ b/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoDlpSensitiveInfoTypes.ps1 @@ -0,0 +1,47 @@ +function Set-CIPPDBCacheExoDlpSensitiveInfoTypes { + <# + .SYNOPSIS + Caches Purview Sensitive Information Type rule packages for a tenant (requires Purview/AIP license) + + .DESCRIPTION + Calls Get-DlpSensitiveInformationTypeRulePackage against the Security & Compliance endpoint and + writes the raw rule packages (including the ClassificationRuleCollectionXml the SIT drift + comparer parses via ConvertTo-CIPPSitComparable) into the CIPP database under Type + 'ExoDlpSensitiveInfoTypes'. + + .PARAMETER TenantFilter + The tenant to cache SIT rule packages for + + .PARAMETER QueueId + The queue ID to update with total tasks (optional) + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter, + [string]$QueueId + ) + + try { + $LicenseCheck = Test-CIPPStandardLicense -StandardName 'ExoDlpSensitiveInfoTypesCache' -TenantFilter $TenantFilter -Preset Compliance -SkipLog + + if ($LicenseCheck -eq $false) { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Tenant does not have a Purview/AIP license, skipping sensitive information type rule packages' -sev Debug + return + } + + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching sensitive information type rule packages' -sev Debug + + $Tenant = Get-Tenants -TenantFilter $TenantFilter | Select-Object -First 1 + $RulePackages = New-ExoRequest -TenantId $Tenant.customerId -cmdlet 'Get-DlpSensitiveInformationTypeRulePackage' -Compliance | Select-Object * -ExcludeProperty '*odata*', '*data.type*' + + if ($RulePackages) { + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'ExoDlpSensitiveInfoTypes' -Data @($RulePackages) -AddCount + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Cached $(@($RulePackages).Count) sensitive information type rule packages" -sev Debug + } + + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Failed to cache sensitive information type rule packages: $($ErrorMessage.NormalizedError)" -sev Warning -LogData $ErrorMessage + } +} diff --git a/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoDynamicDistributionGroup.ps1 b/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoDynamicDistributionGroup.ps1 new file mode 100644 index 0000000000..edc32d0044 --- /dev/null +++ b/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoDynamicDistributionGroup.ps1 @@ -0,0 +1,31 @@ +function Set-CIPPDBCacheExoDynamicDistributionGroup { + <# + .SYNOPSIS + Caches Exchange Online Dynamic Distribution Groups + + .PARAMETER TenantFilter + The tenant to cache dynamic distribution groups for + + .PARAMETER QueueId + The queue ID to update with total tasks (optional) + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter, + [string]$QueueId + ) + + try { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching Dynamic Distribution Groups' -sev Debug + + $DynamicGroups = @(New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-DynamicDistributionGroup' -Select 'Identity,Name,Alias,RecipientFilter,PrimarySmtpAddress,RequireSenderAuthenticationEnabled') + + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'ExoDynamicDistributionGroup' -Data $DynamicGroups -AddCount + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Cached $($DynamicGroups.Count) Dynamic Distribution Groups" -sev Debug + $DynamicGroups = $null + + } catch { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Failed to cache Dynamic Distribution Groups: $($_.Exception.Message)" -sev Error + } +} diff --git a/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoExternalInOutlook.ps1 b/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoExternalInOutlook.ps1 new file mode 100644 index 0000000000..14236ee775 --- /dev/null +++ b/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoExternalInOutlook.ps1 @@ -0,0 +1,37 @@ +function Set-CIPPDBCacheExoExternalInOutlook { + <# + .SYNOPSIS + Caches Exchange Online external sender identification (ExternalInOutlook) configuration + + .PARAMETER TenantFilter + The tenant to cache ExternalInOutlook configuration for + + .PARAMETER QueueId + The queue ID to update with total tasks (optional) + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter, + [string]$QueueId + ) + + try { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching Exchange ExternalInOutlook configuration' -sev Debug + + $ExternalInOutlook = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-ExternalInOutlook' + if ($ExternalInOutlook) { + # Sanitize AllowList - the API may return @('') instead of @() for an empty list + foreach ($Config in $ExternalInOutlook) { + $Config.AllowList = @($Config.AllowList | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) + } + $ExternalInOutlookArray = @($ExternalInOutlook) + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'ExoExternalInOutlook' -Data $ExternalInOutlookArray -AddCount + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached Exchange ExternalInOutlook configuration' -sev Debug + } + $ExternalInOutlook = $null + + } catch { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Failed to cache ExternalInOutlook configuration: $($_.Exception.Message)" -sev Error + } +} diff --git a/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoGlobalQuarantinePolicy.ps1 b/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoGlobalQuarantinePolicy.ps1 new file mode 100644 index 0000000000..67617a5757 --- /dev/null +++ b/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoGlobalQuarantinePolicy.ps1 @@ -0,0 +1,35 @@ +function Set-CIPPDBCacheExoGlobalQuarantinePolicy { + <# + .SYNOPSIS + Caches the Exchange Online global quarantine policy (global quarantine notification settings) + + .PARAMETER TenantFilter + The tenant to cache global quarantine policy data for + + .PARAMETER QueueId + The queue ID to update with total tasks (optional) + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter, + [string]$QueueId + ) + + try { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching Exchange global quarantine policy' -sev Debug + + $GlobalQuarantinePolicy = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-QuarantinePolicy' -cmdParams @{ QuarantinePolicyType = 'GlobalQuarantinePolicy' } | + Select-Object -ExcludeProperty '*data.type' + if ($GlobalQuarantinePolicy) { + # Global quarantine policy returns a single object, wrap in array for consistency + $GlobalQuarantinePolicyArray = @($GlobalQuarantinePolicy) + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'ExoGlobalQuarantinePolicy' -Data $GlobalQuarantinePolicyArray -AddCount + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached Exchange global quarantine policy' -sev Debug + } + $GlobalQuarantinePolicy = $null + + } catch { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Failed to cache global quarantine policy: $($_.Exception.Message)" -sev Error + } +} diff --git a/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoHostedConnectionFilterPolicy.ps1 b/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoHostedConnectionFilterPolicy.ps1 new file mode 100644 index 0000000000..ec9531d112 --- /dev/null +++ b/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoHostedConnectionFilterPolicy.ps1 @@ -0,0 +1,32 @@ +function Set-CIPPDBCacheExoHostedConnectionFilterPolicy { + <# + .SYNOPSIS + Caches Exchange Online hosted connection filter policies + + .PARAMETER TenantFilter + The tenant to cache hosted connection filter policy data for + + .PARAMETER QueueId + The queue ID to update with total tasks (optional) + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter, + [string]$QueueId + ) + + try { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching Exchange hosted connection filter policies' -sev Debug + + $ConnectionFilterPolicies = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-HostedConnectionFilterPolicy' + if ($ConnectionFilterPolicies) { + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'ExoHostedConnectionFilterPolicy' -Data $ConnectionFilterPolicies -AddCount + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Cached $($ConnectionFilterPolicies.Count) hosted connection filter policies" -sev Debug + } + $ConnectionFilterPolicies = $null + + } catch { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Failed to cache hosted connection filter policy data: $($_.Exception.Message)" -sev Error + } +} diff --git a/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoHostedContentFilterRule.ps1 b/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoHostedContentFilterRule.ps1 new file mode 100644 index 0000000000..a0834481ee --- /dev/null +++ b/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoHostedContentFilterRule.ps1 @@ -0,0 +1,32 @@ +function Set-CIPPDBCacheExoHostedContentFilterRule { + <# + .SYNOPSIS + Caches Exchange Online hosted content filter (anti-spam) rules + + .PARAMETER TenantFilter + The tenant to cache hosted content filter rule data for + + .PARAMETER QueueId + The queue ID to update with total tasks (optional) + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter, + [string]$QueueId + ) + + try { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching Exchange hosted content filter rules' -sev Debug + + $HostedContentFilterRules = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-HostedContentFilterRule' + if ($HostedContentFilterRules) { + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'ExoHostedContentFilterRule' -Data $HostedContentFilterRules -AddCount + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Cached $($HostedContentFilterRules.Count) hosted content filter rules" -sev Debug + } + $HostedContentFilterRules = $null + + } catch { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Failed to cache hosted content filter rule data: $($_.Exception.Message)" -sev Error + } +} diff --git a/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoLabels.ps1 b/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoLabels.ps1 new file mode 100644 index 0000000000..93a551ae3f --- /dev/null +++ b/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoLabels.ps1 @@ -0,0 +1,47 @@ +function Set-CIPPDBCacheExoLabels { + <# + .SYNOPSIS + Caches Purview sensitivity labels from the Security & Compliance endpoint (requires Purview/AIP license) + + .DESCRIPTION + Calls Get-Label against the Security & Compliance endpoint and writes the results into the + CIPP database under Type 'ExoLabels'. Selects Name and DisplayName - the fields the + SensitivityLabelTemplate standard matches deployed labels on. Distinct from the + 'SensitivityLabels' type, which caches the Graph informationProtection view. + + .PARAMETER TenantFilter + The tenant to cache labels for + + .PARAMETER QueueId + The queue ID to update with total tasks (optional) + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter, + [string]$QueueId + ) + + try { + $LicenseCheck = Test-CIPPStandardLicense -StandardName 'ExoLabelsCache' -TenantFilter $TenantFilter -Preset Compliance -SkipLog + + if ($LicenseCheck -eq $false) { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Tenant does not have a Purview/AIP license, skipping compliance labels' -sev Debug + return + } + + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching compliance labels' -sev Debug + + $Tenant = Get-Tenants -TenantFilter $TenantFilter | Select-Object -First 1 + $Labels = New-ExoRequest -TenantId $Tenant.customerId -cmdlet 'Get-Label' -Compliance -Select 'Name,DisplayName' + + if ($Labels) { + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'ExoLabels' -Data @($Labels) -AddCount + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Cached $(@($Labels).Count) compliance labels" -sev Debug + } + + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Failed to cache compliance labels: $($ErrorMessage.NormalizedError)" -sev Warning -LogData $ErrorMessage + } +} diff --git a/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoMailContacts.ps1 b/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoMailContacts.ps1 new file mode 100644 index 0000000000..424f41cf9d --- /dev/null +++ b/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoMailContacts.ps1 @@ -0,0 +1,96 @@ +function Set-CIPPDBCacheExoMailContacts { + <# + .SYNOPSIS + Caches Exchange Online Mail Contacts + + .DESCRIPTION + Unified collector serving both the DeployMailContact and DeployContactTemplates + baselines. Get-MailContact carries the mail-specific properties (ExternalEmailAddress, + MailTip, HiddenFromAddressListsEnabled) while the extended directory properties + (FirstName, Company, City, Phone, etc.) only exist on Get-Contact, so both are fetched + in one bulk request and merged per contact. + + ExternalEmailAddress is normalized: the 'SMTP:'/'smtp:' prefix is stripped and the value + lowercased, because Exchange re-cases the domain part when it creates a contact + (support@mydomain.com becomes support@Mydomain.com) — the old DeployMailContact standard + lowercased both sides of the compare for exactly this reason. + + .PARAMETER TenantFilter + The tenant to cache mail contacts for + + .PARAMETER QueueId + The queue ID to update with total tasks (optional) + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter, + [string]$QueueId + ) + + try { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching Mail Contacts' -sev Debug + + $BulkRequests = @( + @{ CmdletInput = @{ CmdletName = 'Get-MailContact'; Parameters = @{ ResultSize = 'Unlimited' } } } + @{ CmdletInput = @{ CmdletName = 'Get-Contact'; Parameters = @{ ResultSize = 'Unlimited' } } } + ) + $BulkResults = New-ExoBulkRequest -tenantid $TenantFilter -cmdletArray $BulkRequests -useSystemMailbox $true -ReturnWithCommand $true + + # Build lookups from Get-Contact results: primary key ExternalDirectoryObjectId, + # fallback Identity for contacts without a directory object id. + $ContactByDirectoryId = @{} + $ContactByIdentity = @{} + foreach ($Contact in @($BulkResults.'Get-Contact')) { + if ($Contact.ExternalDirectoryObjectId) { + $ContactByDirectoryId[[string]$Contact.ExternalDirectoryObjectId] = $Contact + } + if ($Contact.Identity) { + $ContactByIdentity[[string]$Contact.Identity] = $Contact + } + } + + $MailContacts = [System.Collections.Generic.List[PSObject]]::new() + foreach ($MailContact in @($BulkResults.'Get-MailContact')) { + $MatchedContact = $null + if ($MailContact.ExternalDirectoryObjectId -and $ContactByDirectoryId.ContainsKey([string]$MailContact.ExternalDirectoryObjectId)) { + $MatchedContact = $ContactByDirectoryId[[string]$MailContact.ExternalDirectoryObjectId] + } elseif ($MailContact.Identity -and $ContactByIdentity.ContainsKey([string]$MailContact.Identity)) { + $MatchedContact = $ContactByIdentity[[string]$MailContact.Identity] + } + + $MailContacts.Add([PSCustomObject]@{ + Identity = $MailContact.Identity + Guid = $MailContact.Guid + ExternalDirectoryObjectId = $MailContact.ExternalDirectoryObjectId + DisplayName = $MailContact.DisplayName + ExternalEmailAddress = ([string]($MailContact.ExternalEmailAddress -replace '^SMTP:', '' -replace '^smtp:', '')).ToLower() + MailTip = $MailContact.MailTip + HiddenFromAddressListsEnabled = $MailContact.HiddenFromAddressListsEnabled + FirstName = $MatchedContact.FirstName + LastName = $MatchedContact.LastName + Company = $MatchedContact.Company + StateOrProvince = $MatchedContact.StateOrProvince + StreetAddress = $MatchedContact.StreetAddress + Phone = $MatchedContact.Phone + WebPage = $MatchedContact.WebPage + Title = $MatchedContact.Title + City = $MatchedContact.City + PostalCode = $MatchedContact.PostalCode + CountryOrRegion = $MatchedContact.CountryOrRegion + MobilePhone = $MatchedContact.MobilePhone + }) + } + + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'ExoMailContacts' -Data @($MailContacts) -AddCount + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Cached $($MailContacts.Count) Mail Contacts" -sev Debug + + $BulkResults = $null + $ContactByDirectoryId = $null + $ContactByIdentity = $null + $MailContacts = $null + + } catch { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Failed to cache Mail Contacts: $($_.Exception.Message)" -sev Error + } +} diff --git a/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoMailboxPlans.ps1 b/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoMailboxPlans.ps1 new file mode 100644 index 0000000000..8e113ae394 --- /dev/null +++ b/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoMailboxPlans.ps1 @@ -0,0 +1,65 @@ +function Set-CIPPDBCacheExoMailboxPlans { + <# + .SYNOPSIS + Caches Exchange Online Mailbox Plans + + .DESCRIPTION + Caches Get-MailboxPlan output with MaxSendSize/MaxReceiveSize normalized to integer MB + so baseline compares are numeric. Exchange returns sizes as strings like + '35 MB (36,700,160 bytes)' or 'Unlimited'; the same parsing the old + SendReceiveLimitTenant standard used extracts the byte count, which is then rounded + to whole MB. 'Unlimited' normalizes to $null. + + .PARAMETER TenantFilter + The tenant to cache mailbox plans for + + .PARAMETER QueueId + The queue ID to update with total tasks (optional) + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter, + [string]$QueueId + ) + + try { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching Exchange Mailbox Plans' -sev Debug + + $MailboxPlans = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-MailboxPlan' -cmdParams @{ ResultSize = 'Unlimited' } + + # Same extraction as the old SendReceiveLimitTenant standard: pull the byte count out of + # the '... (n bytes)' suffix, then normalize to whole MB. 'Unlimited' becomes $null. + $ConvertSizeToMB = { + param($SizeString) + if ([string]::IsNullOrWhiteSpace($SizeString) -or $SizeString -match 'Unlimited') { + return $null + } + try { + $Bytes = [int64]($SizeString -replace '.*\(([\d,]+).*', '$1' -replace ',', '') + return [int][math]::Round($Bytes / 1MB) + } catch { + return $null + } + } + + $Plans = [System.Collections.Generic.List[PSObject]]::new() + foreach ($Plan in @($MailboxPlans)) { + $Plans.Add([PSCustomObject]@{ + Guid = $Plan.Guid + DisplayName = $Plan.DisplayName + MaxRecipientsPerMessage = $Plan.MaxRecipientsPerMessage + MaxSendSize = & $ConvertSizeToMB $Plan.MaxSendSize + MaxReceiveSize = & $ConvertSizeToMB $Plan.MaxReceiveSize + }) + } + + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'ExoMailboxPlans' -Data @($Plans) -AddCount + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Cached $($Plans.Count) Mailbox Plans" -sev Debug + $MailboxPlans = $null + $Plans = $null + + } catch { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Failed to cache Mailbox Plans: $($_.Exception.Message)" -sev Error + } +} diff --git a/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoOMEConfiguration.ps1 b/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoOMEConfiguration.ps1 new file mode 100644 index 0000000000..0d5524c56d --- /dev/null +++ b/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoOMEConfiguration.ps1 @@ -0,0 +1,33 @@ +function Set-CIPPDBCacheExoOMEConfiguration { + <# + .SYNOPSIS + Caches Exchange Online Message Encryption (OME) configurations + + .PARAMETER TenantFilter + The tenant to cache OME configuration data for + + .PARAMETER QueueId + The queue ID to update with total tasks (optional) + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter, + [string]$QueueId + ) + + try { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching Exchange OME configurations' -sev Debug + + $OMEConfigurations = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-OMEConfiguration' + if ($OMEConfigurations) { + $OMEConfigurationArray = @($OMEConfigurations) + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'ExoOMEConfiguration' -Data $OMEConfigurationArray -AddCount + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Cached $($OMEConfigurationArray.Count) OME configurations" -sev Debug + } + $OMEConfigurations = $null + + } catch { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Failed to cache OME configuration data: $($_.Exception.Message)" -sev Error + } +} diff --git a/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoOutboundConnector.ps1 b/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoOutboundConnector.ps1 new file mode 100644 index 0000000000..0253c3d512 --- /dev/null +++ b/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoOutboundConnector.ps1 @@ -0,0 +1,32 @@ +function Set-CIPPDBCacheExoOutboundConnector { + <# + .SYNOPSIS + Caches Exchange Online outbound connectors + + .PARAMETER TenantFilter + The tenant to cache outbound connector data for + + .PARAMETER QueueId + The queue ID to update with total tasks (optional) + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter, + [string]$QueueId + ) + + try { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching Exchange outbound connectors' -sev Debug + + $OutboundConnectors = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-OutboundConnector' + if ($OutboundConnectors) { + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'ExoOutboundConnector' -Data $OutboundConnectors -AddCount + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Cached $($OutboundConnectors.Count) outbound connectors" -sev Debug + } + $OutboundConnectors = $null + + } catch { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Failed to cache outbound connector data: $($_.Exception.Message)" -sev Error + } +} diff --git a/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoPhishSimConfig.ps1 b/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoPhishSimConfig.ps1 new file mode 100644 index 0000000000..37f4711d40 --- /dev/null +++ b/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoPhishSimConfig.ps1 @@ -0,0 +1,58 @@ +function Set-CIPPDBCacheExoPhishSimConfig { + <# + .SYNOPSIS + Caches Exchange Online Phishing Simulation configuration + + .DESCRIPTION + One collector, one bulk request, three typed writes serving the PhishingSimulations + baseline: + - Get-PhishSimOverridePolicy -> ExoPhishSimOverridePolicy + - Get-ExoPhishSimOverrideRule -> ExoPhishSimOverrideRule + - Get-TenantAllowBlockListItems (ListType Url, ListSubType AdvancedDelivery) + -> ExoPhishSimUrlAllowItems + + Empty results are written as empty arrays: the absence of a phish sim override + policy/rule is meaningful state, not a failed collection. + + .PARAMETER TenantFilter + The tenant to cache phishing simulation configuration for + + .PARAMETER QueueId + The queue ID to update with total tasks (optional) + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter, + [string]$QueueId + ) + + try { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching Phishing Simulation configuration' -sev Debug + + $BulkRequests = @( + @{ CmdletInput = @{ CmdletName = 'Get-PhishSimOverridePolicy'; Parameters = @{} } } + @{ CmdletInput = @{ CmdletName = 'Get-ExoPhishSimOverrideRule'; Parameters = @{} } } + @{ CmdletInput = @{ CmdletName = 'Get-TenantAllowBlockListItems'; Parameters = @{ ListType = 'Url'; ListSubType = 'AdvancedDelivery' } } } + ) + $BulkResults = New-ExoBulkRequest -tenantid $TenantFilter -cmdletArray $BulkRequests -useSystemMailbox $true -ReturnWithCommand $true + + $PhishSimPolicies = @($BulkResults.'Get-PhishSimOverridePolicy' | Where-Object { $_ }) + $PhishSimRules = @($BulkResults.'Get-ExoPhishSimOverrideRule' | Where-Object { $_ }) + $PhishSimUrlAllowItems = @($BulkResults.'Get-TenantAllowBlockListItems' | Where-Object { $_ }) + + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'ExoPhishSimOverridePolicy' -Data $PhishSimPolicies -AddCount + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'ExoPhishSimOverrideRule' -Data $PhishSimRules -AddCount + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'ExoPhishSimUrlAllowItems' -Data $PhishSimUrlAllowItems -AddCount + + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Cached Phishing Simulation configuration: $($PhishSimPolicies.Count) policies, $($PhishSimRules.Count) rules, $($PhishSimUrlAllowItems.Count) URL allow items" -sev Debug + + $BulkResults = $null + $PhishSimPolicies = $null + $PhishSimRules = $null + $PhishSimUrlAllowItems = $null + + } catch { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Failed to cache Phishing Simulation configuration: $($_.Exception.Message)" -sev Error + } +} diff --git a/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoRetentionPolicies.ps1 b/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoRetentionPolicies.ps1 new file mode 100644 index 0000000000..d3cfbacb69 --- /dev/null +++ b/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoRetentionPolicies.ps1 @@ -0,0 +1,36 @@ +function Set-CIPPDBCacheExoRetentionPolicies { + <# + .SYNOPSIS + Caches Exchange Online Retention Policies + + .DESCRIPTION + Caches Get-RetentionPolicy output including RetentionPolicyTagLinks, which the old + RetentionPolicyTag standard checked to confirm a tag is linked to the + 'Default MRM Policy'. + + .PARAMETER TenantFilter + The tenant to cache retention policies for + + .PARAMETER QueueId + The queue ID to update with total tasks (optional) + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter, + [string]$QueueId + ) + + try { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching Retention Policies' -sev Debug + + $RetentionPolicies = @(New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-RetentionPolicy') + + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'ExoRetentionPolicies' -Data $RetentionPolicies -AddCount + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Cached $($RetentionPolicies.Count) Retention Policies" -sev Debug + $RetentionPolicies = $null + + } catch { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Failed to cache Retention Policies: $($_.Exception.Message)" -sev Error + } +} diff --git a/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoRetentionPolicyTags.ps1 b/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoRetentionPolicyTags.ps1 new file mode 100644 index 0000000000..5f6ec11d94 --- /dev/null +++ b/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoRetentionPolicyTags.ps1 @@ -0,0 +1,46 @@ +function Set-CIPPDBCacheExoRetentionPolicyTags { + <# + .SYNOPSIS + Caches Exchange Online Retention Policy Tags + + .DESCRIPTION + Caches Get-RetentionPolicyTag output. Each tag carries the raw properties the old + RetentionPolicyTag standard compared (Name, RetentionEnabled, RetentionAction, + AgeLimitForRetention, Type) plus a derived AgeLimitForRetentionDays integer so + baseline compares against a day count are numeric (AgeLimitForRetention itself is a + timespan string like '30.00:00:00'). + + .PARAMETER TenantFilter + The tenant to cache retention policy tags for + + .PARAMETER QueueId + The queue ID to update with total tasks (optional) + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter, + [string]$QueueId + ) + + try { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching Retention Policy Tags' -sev Debug + + $RetentionPolicyTags = @(New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-RetentionPolicyTag') + + foreach ($Tag in $RetentionPolicyTags) { + $AgeLimitDays = $null + if ($Tag.AgeLimitForRetention) { + try { $AgeLimitDays = [int]([timespan]$Tag.AgeLimitForRetention).TotalDays } catch { $AgeLimitDays = $null } + } + $Tag | Add-Member -NotePropertyName 'AgeLimitForRetentionDays' -NotePropertyValue $AgeLimitDays -Force + } + + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'ExoRetentionPolicyTags' -Data $RetentionPolicyTags -AddCount + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Cached $($RetentionPolicyTags.Count) Retention Policy Tags" -sev Debug + $RetentionPolicyTags = $null + + } catch { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Failed to cache Retention Policy Tags: $($_.Exception.Message)" -sev Error + } +} diff --git a/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoRoleAssignmentPolicy.ps1 b/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoRoleAssignmentPolicy.ps1 new file mode 100644 index 0000000000..93b57c264e --- /dev/null +++ b/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoRoleAssignmentPolicy.ps1 @@ -0,0 +1,32 @@ +function Set-CIPPDBCacheExoRoleAssignmentPolicy { + <# + .SYNOPSIS + Caches Exchange Online role assignment policies + + .PARAMETER TenantFilter + The tenant to cache role assignment policy data for + + .PARAMETER QueueId + The queue ID to update with total tasks (optional) + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter, + [string]$QueueId + ) + + try { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching Exchange role assignment policies' -sev Debug + + $RoleAssignmentPolicies = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-RoleAssignmentPolicy' + if ($RoleAssignmentPolicies) { + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'ExoRoleAssignmentPolicy' -Data $RoleAssignmentPolicies -AddCount + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Cached $($RoleAssignmentPolicies.Count) role assignment policies" -sev Debug + } + $RoleAssignmentPolicies = $null + + } catch { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Failed to cache role assignment policy data: $($_.Exception.Message)" -sev Error + } +} diff --git a/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoTeamsProtectionPolicy.ps1 b/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoTeamsProtectionPolicy.ps1 new file mode 100644 index 0000000000..1b32996aa9 --- /dev/null +++ b/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoTeamsProtectionPolicy.ps1 @@ -0,0 +1,33 @@ +function Set-CIPPDBCacheExoTeamsProtectionPolicy { + <# + .SYNOPSIS + Caches Exchange Online Teams protection policies + + .PARAMETER TenantFilter + The tenant to cache Teams protection policy data for + + .PARAMETER QueueId + The queue ID to update with total tasks (optional) + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter, + [string]$QueueId + ) + + try { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching Teams protection policies' -sev Debug + + $TeamsProtectionPolicies = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-TeamsProtectionPolicy' + if ($TeamsProtectionPolicies) { + $TeamsProtectionPolicyArray = @($TeamsProtectionPolicies) + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'ExoTeamsProtectionPolicy' -Data $TeamsProtectionPolicyArray -AddCount + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Cached $($TeamsProtectionPolicyArray.Count) Teams protection policies" -sev Debug + } + $TeamsProtectionPolicies = $null + + } catch { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Failed to cache Teams protection policy data: $($_.Exception.Message)" -sev Error + } +} diff --git a/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoTenantAllowBlockListSpoofItems.ps1 b/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoTenantAllowBlockListSpoofItems.ps1 new file mode 100644 index 0000000000..3522d8a10a --- /dev/null +++ b/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoTenantAllowBlockListSpoofItems.ps1 @@ -0,0 +1,49 @@ +function Set-CIPPDBCacheExoTenantAllowBlockListSpoofItems { + <# + .SYNOPSIS + Caches Exchange Online Tenant Allow/Block List Spoof Items + + .DESCRIPTION + Caches Get-TenantAllowBlockListSpoofItems output (Identity, SendingInfrastructure, + SpoofType, Action). Spoof items live on a separate cmdlet from the entry-based + allow/block lists cached by Set-CIPPDBCacheExoTenantAllowBlockList, hence the + dedicated collector and type. + + .PARAMETER TenantFilter + The tenant to cache spoof items for + + .PARAMETER QueueId + The queue ID to update with total tasks (optional) + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter, + [string]$QueueId + ) + + try { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching Tenant Allow/Block List Spoof Items' -sev Debug + + $SpoofItems = @(New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-TenantAllowBlockListSpoofItems') + + $Items = [System.Collections.Generic.List[PSObject]]::new() + foreach ($SpoofItem in $SpoofItems) { + $Items.Add([PSCustomObject]@{ + Identity = $SpoofItem.Identity + SendingInfrastructure = $SpoofItem.SendingInfrastructure + SpoofType = $SpoofItem.SpoofType + Action = $SpoofItem.Action + }) + } + + # Even if empty, store an empty array so tests know the cache was populated + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'ExoTenantAllowBlockListSpoofItems' -Data @($Items) -AddCount + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Cached $($Items.Count) Tenant Allow/Block List Spoof Items" -sev Debug + $SpoofItems = $null + $Items = $null + + } catch { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Failed to cache Tenant Allow/Block List Spoof Items: $($_.Exception.Message)" -sev Error + } +} diff --git a/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheFormsSettings.ps1 b/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheFormsSettings.ps1 new file mode 100644 index 0000000000..b0a2389da4 --- /dev/null +++ b/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheFormsSettings.ps1 @@ -0,0 +1,36 @@ +function Set-CIPPDBCacheFormsSettings { + <# + .SYNOPSIS + Caches Microsoft Forms settings for a tenant + + .DESCRIPTION + Forms settings are normally cached by Set-CIPPDBCacheSettings as part of its bulk request. + This collector re-runs the same fetch and writes the same Type so the engine's on-miss + Set-CIPPDBCache lookup resolves for 'FormsSettings'. + + .PARAMETER TenantFilter + The tenant to cache Forms settings for + + .PARAMETER QueueId + The queue ID to update with total tasks (optional) + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter, + [string]$QueueId + ) + + try { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching Forms settings' -sev Debug + + $FormsSettings = New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/admin/forms/settings' -tenantid $TenantFilter + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'FormsSettings' -Data @($FormsSettings) -AddCount + $FormsSettings = $null + + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached Forms settings successfully' -sev Debug + + } catch { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Failed to cache Forms settings: $($_.Exception.Message)" -sev Error + } +} diff --git a/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheHomeRealmDiscoveryPolicy.ps1 b/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheHomeRealmDiscoveryPolicy.ps1 new file mode 100644 index 0000000000..36d23a0db6 --- /dev/null +++ b/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheHomeRealmDiscoveryPolicy.ps1 @@ -0,0 +1,52 @@ +function Set-CIPPDBCacheHomeRealmDiscoveryPolicy { + <# + .SYNOPSIS + Caches home realm discovery policies for a tenant + + .DESCRIPTION + Caches all home realm discovery policies. Each cached row keeps the raw policy properties + and additionally carries a normalized alternateIdLoginEnabled field parsed from the + policy definition JSON (HomeRealmDiscoveryPolicy.AlternateIdLogin.Enabled). + + .PARAMETER TenantFilter + The tenant to cache home realm discovery policies for + + .PARAMETER QueueId + The queue ID to update with total tasks (optional) + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter, + [string]$QueueId + ) + + try { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching home realm discovery policies' -sev Debug + + $Policies = @(New-GraphGetRequest -uri 'https://graph.microsoft.com/v1.0/policies/homeRealmDiscoveryPolicies' -tenantid $TenantFilter) + + $CachedPolicies = foreach ($Policy in $Policies) { + $Definition = if ($Policy.definition) { + ($Policy.definition | Select-Object -First 1) | ConvertFrom-Json -ErrorAction SilentlyContinue + } else { + $null + } + $AlternateIdLoginEnabledRaw = $Definition.HomeRealmDiscoveryPolicy.AlternateIdLogin.Enabled + $AlternateIdLoginEnabled = if ($null -eq $AlternateIdLoginEnabledRaw) { $false } else { [bool]$AlternateIdLoginEnabledRaw } + + # Keep the raw row (id, displayName, isOrganizationDefault, definition, etc.) and project the normalized field onto it + $Policy | Add-Member -MemberType NoteProperty -Name 'alternateIdLoginEnabled' -Value $AlternateIdLoginEnabled -Force + $Policy + } + + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'HomeRealmDiscoveryPolicy' -Data @($CachedPolicies) -AddCount + $CachedPolicies = $null + $Policies = $null + + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached home realm discovery policies successfully' -sev Debug + + } catch { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Failed to cache home realm discovery policies: $($_.Exception.Message)" -sev Error + } +} diff --git a/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheIntuneApplications.ps1 b/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheIntuneApplications.ps1 index 8594bc6401..4218dfdc13 100644 --- a/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheIntuneApplications.ps1 +++ b/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheIntuneApplications.ps1 @@ -25,17 +25,27 @@ function Set-CIPPDBCacheIntuneApplications { method = 'GET' url = '/deviceAppManagement/mobileApps?$top=999&$expand=assignments&$filter=(microsoft.graph.managedApp/appAvailability%20eq%20null%20or%20microsoft.graph.managedApp/appAvailability%20eq%20%27lineOfBusiness%27%20or%20isAssigned%20eq%20true)&$orderby=displayName' } + # Unfiltered lightweight list: the filtered 'Apps' fetch above excludes unassigned + # store/web apps, so presence checks need this full snapshot. + @{ + id = 'AllApps' + method = 'GET' + url = '/deviceAppManagement/mobileApps?$top=999&$select=id,displayName' + } ) $BulkResults = New-GraphBulkRequest -Requests $BulkRequests -tenantid $TenantFilter $Groups = ($BulkResults | Where-Object { $_.id -eq 'Groups' }).body.value $Apps = ($BulkResults | Where-Object { $_.id -eq 'Apps' }).body.value + $AllApps = ($BulkResults | Where-Object { $_.id -eq 'AllApps' }).body.value if (-not $Groups) { $Groups = @() } if (-not $Apps) { $Apps = @() } + if (-not $AllApps) { $AllApps = @() } Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'IntuneApplicationGroups' -Data @($Groups) -AddCount Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'IntuneApplications' -Data @($Apps) -AddCount + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'IntuneMobileAppsAll' -Data @($AllApps) -AddCount Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Cached $(($Apps | Measure-Object).Count) Intune applications" -sev Debug } catch { diff --git a/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheIntuneBrandingProfile.ps1 b/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheIntuneBrandingProfile.ps1 new file mode 100644 index 0000000000..60c054954e --- /dev/null +++ b/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheIntuneBrandingProfile.ps1 @@ -0,0 +1,38 @@ +function Set-CIPPDBCacheIntuneBrandingProfile { + <# + .SYNOPSIS + Caches Intune Company Portal branding profiles for a tenant + + .PARAMETER TenantFilter + The tenant to cache Intune branding profiles for + + .PARAMETER QueueId + The queue ID to update with total tasks (optional) + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter, + [string]$QueueId + ) + + try { + $TestResult = Test-CIPPStandardLicense -StandardName 'IntuneBrandingProfileCache' -TenantFilter $TenantFilter -Preset Intune -SkipLog + if ($TestResult -eq $false) { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Tenant does not have Intune license, skipping Intune branding profile cache' -sev Debug + return + } + + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching Intune branding profiles' -sev Debug + + # -AsApp matches the old intuneBrandingProfile standard, which reads this endpoint app-only. + $BrandingProfiles = New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/deviceManagement/intuneBrandingProfiles' -tenantid $TenantFilter -AsApp $true + if (-not $BrandingProfiles) { $BrandingProfiles = @() } + + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'IntuneBrandingProfile' -Data @($BrandingProfiles) -AddCount + + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Cached $(($BrandingProfiles | Measure-Object).Count) Intune branding profiles" -sev Debug + } catch { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Failed to cache Intune branding profiles: $($_.Exception.Message)" -sev Error + } +} diff --git a/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheIntuneDataProcessorOnboarding.ps1 b/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheIntuneDataProcessorOnboarding.ps1 new file mode 100644 index 0000000000..904e274db7 --- /dev/null +++ b/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheIntuneDataProcessorOnboarding.ps1 @@ -0,0 +1,41 @@ +function Set-CIPPDBCacheIntuneDataProcessorOnboarding { + <# + .SYNOPSIS + Caches the Windows data processor service onboarding state for a tenant + + .DESCRIPTION + Caches deviceManagement/dataProcessorServiceForWindowsFeaturesOnboarding + (areDataProcessorServiceForWindowsFeaturesEnabled, hasValidWindowsLicense) + used by the IntuneWindowsDiagnostic standard. + + .PARAMETER TenantFilter + The tenant to cache the data processor onboarding state for + + .PARAMETER QueueId + The queue ID to update with total tasks (optional) + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter, + [string]$QueueId + ) + + try { + $TestResult = Test-CIPPStandardLicense -StandardName 'IntuneDataProcessorOnboardingCache' -TenantFilter $TenantFilter -Preset Intune -SkipLog + if ($TestResult -eq $false) { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Tenant does not have Intune license, skipping data processor onboarding cache' -sev Debug + return + } + + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching Windows data processor service onboarding state' -sev Debug + + $Onboarding = New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/deviceManagement/dataProcessorServiceForWindowsFeaturesOnboarding' -tenantid $TenantFilter + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'IntuneDataProcessorOnboarding' -Data @($Onboarding) -AddCount + $Onboarding = $null + + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached Windows data processor service onboarding state successfully' -sev Debug + } catch { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Failed to cache Windows data processor service onboarding state: $($_.Exception.Message)" -sev Error + } +} diff --git a/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheIntuneDeviceEnrollmentConfigurations.ps1 b/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheIntuneDeviceEnrollmentConfigurations.ps1 new file mode 100644 index 0000000000..a7416e164a --- /dev/null +++ b/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheIntuneDeviceEnrollmentConfigurations.ps1 @@ -0,0 +1,76 @@ +function Set-CIPPDBCacheIntuneDeviceEnrollmentConfigurations { + <# + .SYNOPSIS + Caches Intune device enrollment configurations under the legacy IntuneDeviceEnrollmentConfigurations type + + .DESCRIPTION + Thin shim so the engine's on-miss lookup can refresh the 'IntuneDeviceEnrollmentConfigurations' + cache type on its own. Mirrors exactly what Set-CIPPDBCacheIntunePolicies writes for this type + today: the delegated deviceEnrollmentConfigurations list with per-configuration assignments + attached as an 'assignments' property. + + .PARAMETER TenantFilter + The tenant to cache device enrollment configurations for + + .PARAMETER QueueId + The queue ID to update with total tasks (optional) + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter, + [string]$QueueId + ) + + try { + $TestResult = Test-CIPPStandardLicense -StandardName 'IntuneDeviceEnrollmentConfigurationsCache' -TenantFilter $TenantFilter -Preset Intune -SkipLog + if ($TestResult -eq $false) { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Tenant does not have Intune license, skipping Intune device enrollment configurations cache' -sev Debug + return + } + + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching Intune device enrollment configurations' -sev Debug + + # Same fetch as Set-CIPPDBCacheIntunePolicies performs for this cache type: delegated auth, + # $top=999, assignments fanned out per configuration because the list endpoint does not + # support $expand=assignments. + $Configurations = @(New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/deviceManagement/deviceEnrollmentConfigurations?$top=999' -tenantid $TenantFilter) + + if ($Configurations.Count -gt 0) { + $AssignmentRequests = @($Configurations | ForEach-Object { + [PSCustomObject]@{ + id = $_.id + method = 'GET' + url = "/deviceManagement/deviceEnrollmentConfigurations/$($_.id)/assignments" + } + }) + + try { + $AssignmentResults = @(New-GraphBulkRequest -Requests $AssignmentRequests -tenantid $TenantFilter) + foreach ($AssignResult in $AssignmentResults) { + if ($null -eq $AssignResult.status) { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "No HTTP status was returned while fetching assignments for enrollment configuration $($AssignResult.id)" -sev Warning + continue + } elseif ([int]$AssignResult.status -lt 200 -or [int]$AssignResult.status -ge 300) { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Failed to fetch assignments for enrollment configuration $($AssignResult.id): HTTP $($AssignResult.status)" -sev Warning + continue + } + + $Configuration = $Configurations | Where-Object { $_.id -eq $AssignResult.id } | Select-Object -First 1 + if ($Configuration) { + $Assignments = @($AssignResult.body.value) + $Configuration | Add-Member -NotePropertyName assignments -NotePropertyValue $Assignments -Force + } + } + } catch { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Failed to fetch assignments for device enrollment configurations: $($_.Exception.Message)" -sev Warning + } + } + + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'IntuneDeviceEnrollmentConfigurations' -Data @($Configurations) -AddCount -ClearOnEmpty + + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Cached $($Configurations.Count) Intune device enrollment configurations" -sev Debug + } catch { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Failed to cache Intune device enrollment configurations: $($_.Exception.Message)" -sev Error + } +} diff --git a/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheIntuneDeviceManagementSettings.ps1 b/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheIntuneDeviceManagementSettings.ps1 new file mode 100644 index 0000000000..e55bad9d2f --- /dev/null +++ b/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheIntuneDeviceManagementSettings.ps1 @@ -0,0 +1,41 @@ +function Set-CIPPDBCacheIntuneDeviceManagementSettings { + <# + .SYNOPSIS + Caches tenant-wide Intune device management settings + + .DESCRIPTION + Caches the deviceManagement/settings singleton (secureByDefault, + deviceComplianceCheckinThresholdDays and sibling properties) used by the + IntuneComplianceSettings standard. + + .PARAMETER TenantFilter + The tenant to cache Intune device management settings for + + .PARAMETER QueueId + The queue ID to update with total tasks (optional) + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter, + [string]$QueueId + ) + + try { + $TestResult = Test-CIPPStandardLicense -StandardName 'IntuneDeviceManagementSettingsCache' -TenantFilter $TenantFilter -Preset Intune -SkipLog + if ($TestResult -eq $false) { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Tenant does not have Intune license, skipping Intune device management settings cache' -sev Debug + return + } + + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching Intune device management settings' -sev Debug + + $DeviceManagementSettings = New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/deviceManagement/settings' -tenantid $TenantFilter + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'IntuneDeviceManagementSettings' -Data @($DeviceManagementSettings) -AddCount + $DeviceManagementSettings = $null + + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached Intune device management settings successfully' -sev Debug + } catch { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Failed to cache Intune device management settings: $($_.Exception.Message)" -sev Error + } +} diff --git a/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheMailboxes.ps1 b/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheMailboxes.ps1 index 0f845f5e75..74f56ffc3e 100644 --- a/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheMailboxes.ps1 +++ b/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheMailboxes.ps1 @@ -27,7 +27,7 @@ function Set-CIPPDBCacheMailboxes { # Get mailboxes and user details in a single bulk request $ZeroArchiveGuid = '00000000-0000-0000-0000-000000000000' - $Select = 'id,ExchangeGuid,ArchiveGuid,UserPrincipalName,DisplayName,PrimarySMTPAddress,RecipientType,RecipientTypeDetails,EmailAddresses,WhenSoftDeleted,IsInactiveMailbox,ForwardingSmtpAddress,DeliverToMailboxAndForward,ForwardingAddress,HiddenFromAddressListsEnabled,ExternalDirectoryObjectId,MessageCopyForSendOnBehalfEnabled,MessageCopyForSentAsEnabled,GrantSendOnBehalfTo,PersistedCapabilities,LitigationHoldEnabled,LitigationHoldDate,LitigationHoldDuration,ComplianceTagHoldApplied,RetentionHoldEnabled,InPlaceHolds,RetentionPolicy,RemotePowerShellEnabled,Guid,Identity,AutoExpandingArchiveEnabled' + $Select = 'id,ExchangeGuid,ArchiveGuid,UserPrincipalName,DisplayName,PrimarySMTPAddress,RecipientType,RecipientTypeDetails,EmailAddresses,WhenSoftDeleted,IsInactiveMailbox,ForwardingSmtpAddress,DeliverToMailboxAndForward,ForwardingAddress,HiddenFromAddressListsEnabled,ExternalDirectoryObjectId,MessageCopyForSendOnBehalfEnabled,MessageCopyForSentAsEnabled,GrantSendOnBehalfTo,PersistedCapabilities,LitigationHoldEnabled,LitigationHoldDate,LitigationHoldDuration,ComplianceTagHoldApplied,RetentionHoldEnabled,InPlaceHolds,RetentionPolicy,RemotePowerShellEnabled,Guid,Identity,AutoExpandingArchiveEnabled,IsExchangeCloudManaged,IsDirSynced,MailboxPlan,MailboxPlanId,RecipientLimits,AccountDisabled' $BulkRequests = @( @{ CmdletInput = @{ CmdletName = 'Get-Mailbox'; Parameters = @{} } } @{ CmdletInput = @{ CmdletName = 'Get-User'; Parameters = @{} } } @@ -87,6 +87,13 @@ function Set-CIPPDBCacheMailboxes { InPlaceHolds, RetentionPolicy, GrantSendOnBehalfTo, + IsExchangeCloudManaged, + IsDirSynced, + MailboxPlan, + MailboxPlanId, + PersistedCapabilities, + RecipientLimits, + AccountDisabled, @{ Name = 'RemotePowerShellEnabled'; Expression = { $MatchedUser.RemotePowerShellEnabled } }, @{ Name = 'Guid'; Expression = { $MatchedUser.Guid } }, @{ Name = 'Identity'; Expression = { $MatchedUser.Identity } })) diff --git a/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheManagedDeviceCleanupRules.ps1 b/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheManagedDeviceCleanupRules.ps1 new file mode 100644 index 0000000000..225a36899f --- /dev/null +++ b/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheManagedDeviceCleanupRules.ps1 @@ -0,0 +1,41 @@ +function Set-CIPPDBCacheManagedDeviceCleanupRules { + <# + .SYNOPSIS + Caches Intune managed device cleanup rules for a tenant + + .DESCRIPTION + Caches deviceManagement/managedDeviceCleanupRules (deviceInactivityBeforeRetirementInDays + and related settings) used by the intuneDeviceRetirementDays standard. + + .PARAMETER TenantFilter + The tenant to cache managed device cleanup rules for + + .PARAMETER QueueId + The queue ID to update with total tasks (optional) + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter, + [string]$QueueId + ) + + try { + $TestResult = Test-CIPPStandardLicense -StandardName 'ManagedDeviceCleanupRulesCache' -TenantFilter $TenantFilter -Preset Intune -SkipLog + if ($TestResult -eq $false) { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Tenant does not have Intune license, skipping managed device cleanup rules cache' -sev Debug + return + } + + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching managed device cleanup rules' -sev Debug + + $CleanupRules = New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/deviceManagement/managedDeviceCleanupRules' -tenantid $TenantFilter + if (-not $CleanupRules) { $CleanupRules = @() } + + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'ManagedDeviceCleanupRules' -Data @($CleanupRules) -AddCount + + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Cached $(($CleanupRules | Measure-Object).Count) managed device cleanup rules" -sev Debug + } catch { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Failed to cache managed device cleanup rules: $($_.Exception.Message)" -sev Error + } +} diff --git a/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheMobileDeviceManagementPolicies.ps1 b/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheMobileDeviceManagementPolicies.ps1 new file mode 100644 index 0000000000..969f496cb5 --- /dev/null +++ b/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheMobileDeviceManagementPolicies.ps1 @@ -0,0 +1,38 @@ +function Set-CIPPDBCacheMobileDeviceManagementPolicies { + <# + .SYNOPSIS + Caches the Microsoft Intune mobile device management policy for a tenant + + .DESCRIPTION + Caches the Microsoft Intune MDM application policy (0000000a-0000-0000-c000-000000000000), + including appliesTo, isMdmEnrollmentDuringRegistrationDisabled, the discovery/compliance/terms + of use URLs and the included groups (displayName). + + .PARAMETER TenantFilter + The tenant to cache the MDM policy for + + .PARAMETER QueueId + The queue ID to update with total tasks (optional) + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter, + [string]$QueueId + ) + + try { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching mobile device management policies' -sev Debug + + # Full entity (no $select) so isMdmEnrollmentDuringRegistrationDisabled, appliesTo and the + # termsOfUseUrl/discoveryUrl/complianceUrl properties are all included, plus included groups + $MDMPolicy = New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/policies/mobileDeviceManagementPolicies/0000000a-0000-0000-c000-000000000000?$expand=includedGroups($select=displayName)' -tenantid $TenantFilter + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'MobileDeviceManagementPolicies' -Data @($MDMPolicy) -AddCount + $MDMPolicy = $null + + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached mobile device management policies successfully' -sev Debug + + } catch { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Failed to cache mobile device management policies: $($_.Exception.Message)" -sev Error + } +} diff --git a/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheMoeraDmarc.ps1 b/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheMoeraDmarc.ps1 new file mode 100644 index 0000000000..eb4efddfe1 --- /dev/null +++ b/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheMoeraDmarc.ps1 @@ -0,0 +1,72 @@ +function Set-CIPPDBCacheMoeraDmarc { + <# + .SYNOPSIS + Caches DMARC state for MOERA (onmicrosoft.com) domains for a tenant + + .DESCRIPTION + Resolves the tenant's MOERA domains (*.onmicrosoft.com, excluding *.mail.onmicrosoft.com) + from Graph and reads each domain's live DMARC policy over DNS via the DNSHealth module, + the same lookup Invoke-CIPPStandardAddDMARCToMOERA performs. A domain whose DNS query + fails is skipped rather than cached as a false "no DMARC" negative; on a partial run the + successful rows are appended without deleting previously cached rows for the failed domains. + + .PARAMETER TenantFilter + The tenant to cache MOERA DMARC state for + + .PARAMETER QueueId + The queue ID to update with total tasks (optional) + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter, + [string]$QueueId + ) + + try { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching MOERA domain DMARC state' -sev Debug + + $Domains = New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/domains' -tenantid $TenantFilter + $MoeraDomains = @($Domains | Where-Object { $_.id -like '*.onmicrosoft.com' -and $_.id -notlike '*.mail.onmicrosoft.com' } | Select-Object -ExpandProperty id) + + $Results = [System.Collections.Generic.List[object]]::new() + $FailedDomains = [System.Collections.Generic.List[string]]::new() + + foreach ($Domain in $MoeraDomains) { + try { + $DmarcPolicy = Read-DmarcPolicy -Domain $Domain + $Results.Add([PSCustomObject]@{ + id = $Domain + domain = $Domain + hasDmarc = -not [string]::IsNullOrEmpty($DmarcPolicy.Record) + record = $DmarcPolicy.Record + policy = $DmarcPolicy.Policy + subdomainPolicy = $DmarcPolicy.SubdomainPolicy + percent = $DmarcPolicy.Percent + }) + } catch { + # A resolver failure is not evidence the record is missing - skip the domain + # so the cache never holds a false negative for it. + $FailedDomains.Add($Domain) + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Failed to read DMARC policy for MOERA domain $($Domain): $($_.Exception.Message)" -sev Error + } + } + + if ($FailedDomains.Count -eq 0) { + # Full authoritative run: write everything and allow cleanup of rows for + # domains that no longer exist (including clearing on a genuinely empty set). + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'MoeraDmarc' -Data $Results -AddCount -ClearOnEmpty + } elseif ($Results.Count -gt 0) { + # Partial run: append the successful rows only, so previously cached rows for + # the failed domains are not deleted by the orphan cleanup. + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'MoeraDmarc' -Data $Results -AddCount -Append + } else { + throw "DMARC resolution failed for all $($FailedDomains.Count) MOERA domains: $($FailedDomains -join ', ')" + } + + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached MOERA domain DMARC state successfully' -sev Debug + + } catch { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Failed to cache MOERA domain DMARC state: $($_.Exception.Message)" -sev Error + } +} diff --git a/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheNamePronunciation.ps1 b/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheNamePronunciation.ps1 new file mode 100644 index 0000000000..9c436d203b --- /dev/null +++ b/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheNamePronunciation.ps1 @@ -0,0 +1,30 @@ +function Set-CIPPDBCacheNamePronunciation { + <# + .SYNOPSIS + Caches name pronunciation settings for a tenant + + .PARAMETER TenantFilter + The tenant to cache name pronunciation settings for + + .PARAMETER QueueId + The queue ID to update with total tasks (optional) + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter, + [string]$QueueId + ) + + try { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching name pronunciation settings' -sev Debug + $NamePronunciation = New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/admin/people/namePronunciation' -tenantid $TenantFilter + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'NamePronunciation' -Data @($NamePronunciation) -AddCount + $NamePronunciation = $null + + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached name pronunciation settings successfully' -sev Debug + + } catch { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Failed to cache name pronunciation settings: $($_.Exception.Message)" -sev Error + } +} diff --git a/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheOrganizationBranding.ps1 b/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheOrganizationBranding.ps1 new file mode 100644 index 0000000000..fdd7cccbce --- /dev/null +++ b/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheOrganizationBranding.ps1 @@ -0,0 +1,37 @@ +function Set-CIPPDBCacheOrganizationBranding { + <# + .SYNOPSIS + Caches organization branding localizations for a tenant + + .PARAMETER TenantFilter + The tenant to cache organization branding for + + .PARAMETER QueueId + The queue ID to update with total tasks (optional) + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter, + [string]$QueueId + ) + + try { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching organization branding localizations' -sev Debug + + $TenantObject = Get-Tenants -TenantFilter $TenantFilter + $CustomerId = $TenantObject.customerId + if ([string]::IsNullOrWhiteSpace($CustomerId)) { + throw "Could not resolve customerId for tenant $TenantFilter" + } + + $Localizations = @(New-GraphGetRequest -uri "https://graph.microsoft.com/beta/organization/$CustomerId/branding/localizations" -tenantid $TenantFilter -AsApp $true) + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'OrganizationBranding' -Data @($Localizations) -AddCount + $Localizations = $null + + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached organization branding localizations successfully' -sev Debug + + } catch { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Failed to cache organization branding localizations: $($_.Exception.Message)" -sev Error + } +} diff --git a/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCachePeopleInsights.ps1 b/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCachePeopleInsights.ps1 new file mode 100644 index 0000000000..7eb773191e --- /dev/null +++ b/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCachePeopleInsights.ps1 @@ -0,0 +1,37 @@ +function Set-CIPPDBCachePeopleInsights { + <# + .SYNOPSIS + Caches people insights (Viva insights) organization settings for a tenant + + .PARAMETER TenantFilter + The tenant to cache people insights settings for + + .PARAMETER QueueId + The queue ID to update with total tasks (optional) + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter, + [string]$QueueId + ) + + try { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching people insights settings' -sev Debug + + $TenantObject = Get-Tenants -TenantFilter $TenantFilter + $CustomerId = $TenantObject.customerId + if ([string]::IsNullOrWhiteSpace($CustomerId)) { + throw "Could not resolve customerId for tenant $TenantFilter" + } + + $PeopleInsights = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/organization/$CustomerId/settings/peopleInsights" -tenantid $TenantFilter -AsApp $true + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'PeopleInsights' -Data @($PeopleInsights) -AddCount + $PeopleInsights = $null + + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached people insights settings successfully' -sev Debug + + } catch { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Failed to cache people insights settings: $($_.Exception.Message)" -sev Error + } +} diff --git a/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCachePermissionGrantPolicies.ps1 b/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCachePermissionGrantPolicies.ps1 new file mode 100644 index 0000000000..aa88aae385 --- /dev/null +++ b/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCachePermissionGrantPolicies.ps1 @@ -0,0 +1,31 @@ +function Set-CIPPDBCachePermissionGrantPolicies { + <# + .SYNOPSIS + Caches permission grant policies for a tenant + + .PARAMETER TenantFilter + The tenant to cache permission grant policies for + + .PARAMETER QueueId + The queue ID to update with total tasks (optional) + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter, + [string]$QueueId + ) + + try { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching permission grant policies' -sev Debug + + $PermissionGrantPolicies = @(New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/policies/permissionGrantPolicies?$expand=includes' -tenantid $TenantFilter) + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'PermissionGrantPolicies' -Data @($PermissionGrantPolicies) -AddCount + $PermissionGrantPolicies = $null + + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached permission grant policies successfully' -sev Debug + + } catch { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Failed to cache permission grant policies: $($_.Exception.Message)" -sev Error + } +} diff --git a/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCachePhotoUpdateSettings.ps1 b/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCachePhotoUpdateSettings.ps1 new file mode 100644 index 0000000000..9c38b92a65 --- /dev/null +++ b/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCachePhotoUpdateSettings.ps1 @@ -0,0 +1,31 @@ +function Set-CIPPDBCachePhotoUpdateSettings { + <# + .SYNOPSIS + Caches profile photo update settings for a tenant + + .PARAMETER TenantFilter + The tenant to cache photo update settings for + + .PARAMETER QueueId + The queue ID to update with total tasks (optional) + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter, + [string]$QueueId + ) + + try { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching photo update settings' -sev Debug + # The old ProfilePhotos standard reads this endpoint with the default delegated token (AsApp is only used for writes) + $PhotoUpdateSettings = New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/admin/people/photoUpdateSettings' -tenantid $TenantFilter + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'PhotoUpdateSettings' -Data @($PhotoUpdateSettings) -AddCount + $PhotoUpdateSettings = $null + + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached photo update settings successfully' -sev Debug + + } catch { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Failed to cache photo update settings: $($_.Exception.Message)" -sev Error + } +} diff --git a/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCachePronouns.ps1 b/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCachePronouns.ps1 new file mode 100644 index 0000000000..87ea93e87f --- /dev/null +++ b/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCachePronouns.ps1 @@ -0,0 +1,30 @@ +function Set-CIPPDBCachePronouns { + <# + .SYNOPSIS + Caches pronouns settings for a tenant + + .PARAMETER TenantFilter + The tenant to cache pronouns settings for + + .PARAMETER QueueId + The queue ID to update with total tasks (optional) + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter, + [string]$QueueId + ) + + try { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching pronouns settings' -sev Debug + $Pronouns = New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/admin/people/pronouns' -tenantid $TenantFilter + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'Pronouns' -Data @($Pronouns) -AddCount + $Pronouns = $null + + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached pronouns settings successfully' -sev Debug + + } catch { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Failed to cache pronouns settings: $($_.Exception.Message)" -sev Error + } +} diff --git a/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheReportSubmissionRule.ps1 b/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheReportSubmissionRule.ps1 new file mode 100644 index 0000000000..5a4ae9e018 --- /dev/null +++ b/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheReportSubmissionRule.ps1 @@ -0,0 +1,33 @@ +function Set-CIPPDBCacheReportSubmissionRule { + <# + .SYNOPSIS + Caches Exchange Online report submission rules + + .PARAMETER TenantFilter + The tenant to cache report submission rule data for + + .PARAMETER QueueId + The queue ID to update with total tasks (optional) + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter, + [string]$QueueId + ) + + try { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching Exchange report submission rules' -sev Debug + + $ReportSubmissionRules = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-ReportSubmissionRule' + if ($ReportSubmissionRules) { + $ReportSubmissionRuleArray = @($ReportSubmissionRules) + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'ReportSubmissionRule' -Data $ReportSubmissionRuleArray -AddCount + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Cached $($ReportSubmissionRuleArray.Count) report submission rules" -sev Debug + } + $ReportSubmissionRules = $null + + } catch { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Failed to cache report submission rule data: $($_.Exception.Message)" -sev Error + } +} diff --git a/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheSecureScoreControlProfiles.ps1 b/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheSecureScoreControlProfiles.ps1 new file mode 100644 index 0000000000..be01d912b0 --- /dev/null +++ b/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheSecureScoreControlProfiles.ps1 @@ -0,0 +1,36 @@ +function Set-CIPPDBCacheSecureScoreControlProfiles { + <# + .SYNOPSIS + Caches secure score control profiles for a tenant + + .DESCRIPTION + The control profiles are normally cached by Set-CIPPDBCacheSecureScore alongside the score + history. This collector re-runs the same fetch and writes the same Type so the engine's + on-miss Set-CIPPDBCache lookup resolves for 'SecureScoreControlProfiles'. + + .PARAMETER TenantFilter + The tenant to cache secure score control profiles for + + .PARAMETER QueueId + The queue ID to update with total tasks (optional) + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter, + [string]$QueueId + ) + + try { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching secure score control profiles' -sev Debug + + $Profiles = New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/security/secureScoreControlProfiles' -tenantid $TenantFilter + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'SecureScoreControlProfiles' -Data $Profiles -AddCount + $Profiles = $null + + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached secure score control profiles successfully' -sev Debug + + } catch { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Failed to cache secure score control profiles: $($_.Exception.Message)" -sev Error + } +} diff --git a/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheSelfServicePurchaseProducts.ps1 b/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheSelfServicePurchaseProducts.ps1 new file mode 100644 index 0000000000..a96cef3be7 --- /dev/null +++ b/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheSelfServicePurchaseProducts.ps1 @@ -0,0 +1,61 @@ +function Set-CIPPDBCacheSelfServicePurchaseProducts { + <# + .SYNOPSIS + Caches self-service purchase product policies for a tenant + + .DESCRIPTION + Reads the AllowSelfServicePurchase product policy list from the M365 licensing service + (licensing.m365.microsoft.com, scope aeb86249-8ea3-49e2-900b-54cc8e308f85/.default) and + the trial autoclaim policy from admin.microsoft.com, the same calls + Invoke-CIPPStandardDisableSelfServiceLicenses makes. Requires the tenant GDAP + relationship to include the 'Billing Administrator' role. The autoclaim policy is + cached as an extra row (productId 'autoclaim') and is non-fatal if unreachable. + + .PARAMETER TenantFilter + The tenant to cache self-service purchase products for + + .PARAMETER QueueId + The queue ID to update with total tasks (optional) + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter, + [string]$QueueId + ) + + try { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching self-service purchase products' -sev Debug + + $SelfServiceItems = (New-GraphGetRequest -scope 'aeb86249-8ea3-49e2-900b-54cc8e308f85/.default' -uri 'https://licensing.m365.microsoft.com/v1.0/policies/AllowSelfServicePurchase/products' -tenantid $TenantFilter).items + + $Results = [System.Collections.Generic.List[object]]::new() + foreach ($Item in $SelfServiceItems) { + $Results.Add([PSCustomObject]@{ + id = $Item.productId + productId = $Item.productId + productName = $Item.productName + policyValue = $Item.policyValue + }) + } + + try { + $AutoClaimPolicy = New-GraphGetRequest -scope 'https://admin.microsoft.com/.default' -tenantid $TenantFilter -uri 'https://admin.microsoft.com/fd/m365licensing/v1/policies/autoclaim' + $Results.Add([PSCustomObject]@{ + id = 'autoclaim' + productId = 'autoclaim' + productName = 'Trial Autoclaim' + policyValue = $AutoClaimPolicy.tenantPolicyValue ?? 'Disabled' + }) + } catch { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Failed to cache trial autoclaim policy: $($_.Exception.Message)" -sev Error + } + + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'SelfServicePurchaseProducts' -Data $Results -AddCount + + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached self-service purchase products successfully' -sev Debug + + } catch { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Failed to cache self-service purchase products: $($_.Exception.Message)" -sev Error + } +} diff --git a/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheSharePointAdminSettings.ps1 b/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheSharePointAdminSettings.ps1 new file mode 100644 index 0000000000..72e16e325f --- /dev/null +++ b/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheSharePointAdminSettings.ps1 @@ -0,0 +1,30 @@ +function Set-CIPPDBCacheSharePointAdminSettings { + <# + .SYNOPSIS + Caches SharePoint tenant admin settings for a tenant + + .PARAMETER TenantFilter + The tenant to cache SharePoint admin settings for + + .PARAMETER QueueId + The queue ID to update with total tasks (optional) + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter, + [string]$QueueId + ) + + try { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching SharePoint admin settings' -sev Debug + $SharePointSettings = New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/admin/sharepoint/settings' -tenantid $TenantFilter -AsApp $true + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'SharePointAdminSettings' -Data @($SharePointSettings) -AddCount + $SharePointSettings = $null + + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached SharePoint admin settings successfully' -sev Debug + + } catch { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Failed to cache SharePoint admin settings: $($_.Exception.Message)" -sev Error + } +} diff --git a/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheTeamsResourceAccounts.ps1 b/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheTeamsResourceAccounts.ps1 new file mode 100644 index 0000000000..e94d58d2ce --- /dev/null +++ b/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheTeamsResourceAccounts.ps1 @@ -0,0 +1,65 @@ +function Set-CIPPDBCacheTeamsResourceAccounts { + <# + .SYNOPSIS + Caches Teams resource accounts (Auto Attendant / Call Queue) for a tenant + + .DESCRIPTION + Walks the paged Teams.PlatformService/v2/ApplicationInstances surface via New-TeamsRequestV2 + (the only surface that returns resource accounts; Graph's admin/teams/userConfigurations does + not) and writes displayName, userPrincipalName, objectId and applicationId per account into + the CIPP database under Type 'TeamsResourceAccounts'. + + .PARAMETER TenantFilter + The tenant to cache Teams resource accounts for + + .PARAMETER QueueId + The queue ID to update with total tasks (optional) + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter, + [string]$QueueId + ) + + try { + $LicenseCheck = Test-CIPPStandardLicense -StandardName 'TeamsResourceAccountsCache' -TenantFilter $TenantFilter -Preset Teams -SkipLog + + if ($LicenseCheck -eq $false) { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Tenant does not have a Teams license, skipping Teams resource accounts' -sev Debug + return + } + + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching Teams resource accounts' -sev Debug + + $ResourceAccounts = [System.Collections.Generic.List[object]]::new() + $SkipToken = $null + do { + $QueryParameters = @{ pageSize = 100 } + if ($SkipToken) { $QueryParameters['skipToken'] = $SkipToken } + $Page = New-TeamsRequestV2 -TenantFilter $TenantFilter -Path 'Teams.PlatformService/v2/ApplicationInstances' -QueryParameters $QueryParameters + foreach ($Instance in @($Page.applicationInstances)) { + if ($null -ne $Instance) { + $ResourceAccounts.Add([PSCustomObject]@{ + displayName = $Instance.displayName + userPrincipalName = $Instance.userPrincipalName + objectId = $Instance.objectId + applicationId = $Instance.applicationId + }) + } + } + $SkipToken = $Page.skipToken + } while ($SkipToken) + + if ($ResourceAccounts.Count -gt 0) { + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'TeamsResourceAccounts' -Data @($ResourceAccounts) -AddCount + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Cached $($ResourceAccounts.Count) Teams resource accounts" -sev Debug + } else { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'No Teams resource accounts found' -sev Debug + } + + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Failed to cache Teams resource accounts: $($ErrorMessage.NormalizedError)" -sev Warning -LogData $ErrorMessage + } +} From 7d2a9c3f78b9b2dee447448b89b54e94272e683d Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Sat, 15 Aug 2026 16:31:00 +0200 Subject: [PATCH 061/226] update batches --- backend/Config/CIPPDBCacheTypes.json | 5 --- .../Public/Invoke-CIPPDBCacheCollection.ps1 | 41 +++++++++++++++++ .../Set-CIPPDBCacheAutopatchGroups.ps1 | 45 ------------------- 3 files changed, 41 insertions(+), 50 deletions(-) delete mode 100644 backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheAutopatchGroups.ps1 diff --git a/backend/Config/CIPPDBCacheTypes.json b/backend/Config/CIPPDBCacheTypes.json index b166c8b7ad..59f6eae7f6 100644 --- a/backend/Config/CIPPDBCacheTypes.json +++ b/backend/Config/CIPPDBCacheTypes.json @@ -514,11 +514,6 @@ "friendlyName": "Managed Device Cleanup Rules", "description": "Intune managed device cleanup rules (device retirement days)" }, - { - "type": "AutopatchGroups", - "friendlyName": "Windows Autopatch Groups", - "description": "Windows Autopatch groups with deployment ring settings" - }, { "type": "IntuneMobileAppsAll", "friendlyName": "All Intune Mobile Apps", diff --git a/backend/Modules/CIPPCore/Public/Invoke-CIPPDBCacheCollection.ps1 b/backend/Modules/CIPPCore/Public/Invoke-CIPPDBCacheCollection.ps1 index ec8f7b6446..f21c9c8a19 100644 --- a/backend/Modules/CIPPCore/Public/Invoke-CIPPDBCacheCollection.ps1 +++ b/backend/Modules/CIPPCore/Public/Invoke-CIPPDBCacheCollection.ps1 @@ -70,6 +70,19 @@ function Invoke-CIPPDBCacheCollection { 'AppRoleAssignments' 'LicenseOverview' 'BitlockerKeys' + 'AdminReportSettings' + 'PeopleInsights' + 'Pronouns' + 'NamePronunciation' + 'PhotoUpdateSettings' + 'OrganizationBranding' + 'HomeRealmDiscoveryPolicy' + 'MobileDeviceManagementPolicies' + 'PermissionGrantPolicies' + 'CopilotAdminSettings' + 'CopilotPolicySettings' + 'SelfServicePurchaseProducts' + 'MoeraDmarc' ) ExchangeConfig = @( 'ExoAntiPhishPolicies' @@ -93,7 +106,23 @@ function Invoke-CIPPDBCacheCollection { 'ExoProtectionAlert' 'OwaMailboxPolicy' 'ReportSubmissionPolicy' + 'ReportSubmissionRule' 'ExoTransportConfig' + 'ExoHostedConnectionFilterPolicy' + 'ExoExternalInOutlook' + 'ExoTeamsProtectionPolicy' + 'ExoOutboundConnector' + 'ExoRoleAssignmentPolicy' + 'ExoHostedContentFilterRule' + 'ExoGlobalQuarantinePolicy' + 'ExoOMEConfiguration' + 'ExoMailboxPlans' + 'ExoRetentionPolicyTags' + 'ExoRetentionPolicies' + 'ExoDynamicDistributionGroup' + 'ExoMailContacts' + 'ExoTenantAllowBlockListSpoofItems' + 'ExoPhishSimConfig' ) ExchangeData = @( 'CASMailboxes' @@ -128,10 +157,20 @@ function Invoke-CIPPDBCacheCollection { 'DetectedApps' 'IntuneAppInstallStatus' 'MDEOnboarding' + 'AutopilotDeploymentProfiles' + 'DeviceEnrollmentConfigurations' + 'IntuneDeviceManagementSettings' + 'IntuneDataProcessorOnboarding' + 'IntuneBrandingProfile' + 'ManagedDeviceCleanupRules' ) Compliance = @( 'SensitivityLabels' 'DlpCompliancePolicies' + 'ComplianceRetentionPolicies' + 'ComplianceRetentionRules' + 'ExoDlpSensitiveInfoTypes' + 'ExoLabels' ) CopilotUsage = @( 'CopilotUsageUserDetail' @@ -142,6 +181,7 @@ function Invoke-CIPPDBCacheCollection { SharePoint = @( 'SPOTenant' 'SPOTenantSyncClientRestriction' + 'SharePointAdminSettings' 'SharePointSiteUsage' 'SiteActivity' 'OneDriveUsage' @@ -157,6 +197,7 @@ function Invoke-CIPPDBCacheCollection { 'Teams' 'TeamsActivity' 'TeamsVoice' + 'TeamsResourceAccounts' ) Defender = @( 'DefenderCVEs' diff --git a/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheAutopatchGroups.ps1 b/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheAutopatchGroups.ps1 deleted file mode 100644 index ad14fb8ca1..0000000000 --- a/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheAutopatchGroups.ps1 +++ /dev/null @@ -1,45 +0,0 @@ -function Set-CIPPDBCacheAutopatchGroups { - <# - .SYNOPSIS - Caches Windows Autopatch groups for a tenant - - .DESCRIPTION - Caches the Autopatch group list (name, id and deploymentGroups settings) from the - Microsoft Autopatch API proxy used by the AutopatchGroup standard. The proxy exists - until native Graph API support for Autopatch groups is available. - - .PARAMETER TenantFilter - The tenant to cache Autopatch groups for - - .PARAMETER QueueId - The queue ID to update with total tasks (optional) - #> - [CmdletBinding()] - param( - [Parameter(Mandatory = $true)] - [string]$TenantFilter, - [string]$QueueId - ) - - try { - $TestResult = Test-CIPPStandardLicense -StandardName 'AutopatchGroupsCache' -TenantFilter $TenantFilter -Preset Intune -SkipLog - if ($TestResult -eq $false) { - Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Tenant does not have Intune license, skipping Autopatch groups cache' -sev Debug - return - } - - Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching Autopatch groups' -sev Debug - - # Same URI and auth as the AutopatchGroup standard: the Microsoft-provided Autopatch API - # proxy accepts the app-only Graph token issued by New-GraphGetRequest. - $AutopatchProxyBase = 'https://intuneautopatchbeta-bwhtaqgefgcyaaa8.westeurope-01.azurewebsites.net/api/autoPatch' - $AutopatchGroups = New-GraphGetRequest -uri $AutopatchProxyBase -tenantid $TenantFilter -AsApp $true - if (-not $AutopatchGroups) { $AutopatchGroups = @() } - - Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'AutopatchGroups' -Data @($AutopatchGroups) -AddCount - - Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Cached $(($AutopatchGroups | Measure-Object).Count) Autopatch groups" -sev Debug - } catch { - Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Failed to cache Autopatch groups: $($_.Exception.Message)" -sev Error - } -} From 9a0aac50bfd1b47b453801c485d6fbef243146ec Mon Sep 17 00:00:00 2001 From: John Duprey Date: Sat, 15 Aug 2026 10:35:34 -0400 Subject: [PATCH 062/226] feat(mobile-ux): improve mobile search, nav, and dialogs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A broad mobile UX pass across several components: - **CippUniversalSearchV2**: replace the desktop scope dropdown with inline chips on mobile, render results in-flow (not in a portal), show bookmarks in the empty state, fix outside-click closing results before navigation - **CippBreadcrumbNav**: hide the rail on mobile when there is no hierarchy to show (single crumb, dashboard views); move gutter/divider chrome into the component via `withRail` prop - **CippTabPicker**: restyle the non-compact variant as a heading with the chevron beside the text - **CippReportToolbar**: replace the suite autocomplete with a bottom-sheet trigger on mobile; add a dedicated suite-picker sheet - **CippDataTable**: add `orderColumnsBySelection` so $select column order wins in card view; skip the generic property list when the offCanvas provides its own body - **CippTestDetailOffCanvas**: lay out the four stat chips 2×2 on mobile instead of four full-width rows - **ReleaseNotesDialog**: mobile header becomes a sheet trigger; low-emphasis actions (GitHub, permanent dismiss) move behind a kebab sheet; add `useHistoryDismiss` for back-gesture support; fix release label duplication when name already contains the tag - **tab-navigation-context**: treat an aliased route (e.g. `/` → dashboard) as the first tab so the picker shows a selection - New unit and story tests for all of the above --- .../CippCards/CippUniversalSearchV2.jsx | 197 ++++++++++--- .../CippComponents/CippBreadcrumbNav.jsx | 34 ++- .../CippComponents/CippReportToolbar.jsx | 76 ++++- .../CippComponents/CippTabPicker.jsx | 22 +- .../src/components/CippTable/CippDataTable.js | 27 ++ .../CippTestDetailOffCanvas.jsx | 30 +- frontend/src/components/ReleaseNotesDialog.js | 265 ++++++++++++++---- frontend/src/layouts/index.js | 13 +- .../src/layouts/tab-navigation-context.js | 11 +- frontend/src/pages/dashboardv2/index.js | 13 +- .../CippUniversalSearchV2.stories.jsx | 62 ++++ .../CippCards/CippUniversalSearchV2.test.jsx | 125 +++++++++ .../CippComponents/CippBreadcrumbNav.test.jsx | 53 +++- .../CippComponents/CippReportToolbar.test.jsx | 33 ++- .../CippComponents/CippTabPicker.stories.jsx | 9 +- .../CippTable/CippDataTable.test.jsx | 32 +++ .../order-columns-by-selection.test.js | 36 +++ .../components/ReleaseNotesDialog.test.jsx | 60 +++- frontend/tests/layouts/TabbedLayout.test.jsx | 23 ++ 19 files changed, 947 insertions(+), 174 deletions(-) create mode 100644 frontend/tests/components/CippCards/CippUniversalSearchV2.stories.jsx create mode 100644 frontend/tests/components/CippCards/CippUniversalSearchV2.test.jsx create mode 100644 frontend/tests/components/CippTable/order-columns-by-selection.test.js diff --git a/frontend/src/components/CippCards/CippUniversalSearchV2.jsx b/frontend/src/components/CippCards/CippUniversalSearchV2.jsx index 95cb54f1ca..9834347687 100644 --- a/frontend/src/components/CippCards/CippUniversalSearchV2.jsx +++ b/frontend/src/components/CippCards/CippUniversalSearchV2.jsx @@ -11,8 +11,14 @@ import { InputAdornment, Portal, Button, + Chip, + List, + ListItemButton, + ListItemIcon, + ListSubheader, + Stack, } from "@mui/material"; -import { Search as SearchIcon } from "@mui/icons-material"; +import { Search as SearchIcon, Star as StarIcon } from "@mui/icons-material"; import { ApiGetCall } from "../../api/ApiCall"; import { useRouter } from "next/router"; import { BulkActionsMenu } from "../bulk-actions-menu"; @@ -21,6 +27,7 @@ import { CippBitlockerKeySearch } from "../CippComponents/CippBitlockerKeySearch import { nativeMenuItems } from "../../layouts/config"; import { usePermissions } from "../../hooks/use-permissions"; import { useIsMobileLayout } from "../../hooks/use-breakpoint"; +import { useUserBookmarks } from "../../hooks/use-user-bookmarks"; import { searchLocalLicenseCatalog } from "../../utils/get-cipp-license-catalog"; function getLeafItems(items = []) { @@ -142,6 +149,7 @@ export const CippUniversalSearchV2 = React.forwardRef( const router = useRouter(); const { userPermissions, userRoles } = usePermissions(); const isMobile = useIsMobileLayout(); + const { bookmarks } = useUserBookmarks(); const universalSearch = ApiGetCall({ url: `/api/ExecUniversalSearchV2`, @@ -473,7 +481,10 @@ export const CippUniversalSearchV2 = React.forwardRef( if ( containerRef.current && !containerRef.current.contains(event.target) && - !event.target.closest("[data-dropdown-portal]") + !event.target.closest("[data-dropdown-portal]") && + // the in-flow mobile results are outside the joined control — this ran on + // mousedown and unmounted a row before its click could navigate + !event.target.closest("[data-search-results]") ) { setShowDropdown(false); } @@ -577,6 +588,51 @@ export const CippUniversalSearchV2 = React.forwardRef( return "Search"; }; + // One results body, two surfaces: desktop anchors it under the joined control as a + // floating panel; the phone dialog IS the surface, so it renders in flow. + const resultsBody = ( + <> + {activeSearch?.isFetching ? ( + + + + + ) : hasResults ? ( + searchType === "BitLocker" ? ( + + ) : searchType === "Pages" ? ( + + ) : ( + + ) + ) : ( + + + No results found. + + + )} + + ); + return ( <> {/* One joined control: the scope button, the field and the search button share a @@ -607,17 +663,20 @@ export const CippUniversalSearchV2 = React.forwardRef( "& .MuiOutlinedInput-root.Mui-focused": { zIndex: 1 }, }} > - - {searchType === "BitLocker" && ( + {!isMobile && ( + + )} + {!isMobile && searchType === "BitLocker" && ( )} { textFieldRef.current = node; if (typeof ref === "function") { @@ -670,7 +729,90 @@ export const CippUniversalSearchV2 = React.forwardRef( )} - {shouldShowDropdown && ( + {/* One tap to any scope — the desktop dropdown cost two, and the recorded mobile + gap was that entity search had no direct entry point at all. */} + {isMobile && ( + + {typeMenuActions.map((action) => { + const active = action.label === searchType; + return ( + + ); + })} + + )} + {isMobile && searchType === "BitLocker" && ( + + {bitlockerLookupActions.map((action) => { + const active = + (action.label === "Device ID") === (bitlockerLookupType === "deviceId"); + return ( + + ); + })} + + )} + {/* The phone dialog is the surface: results render in flow, and the dead space + before a query becomes the user's bookmarks. */} + {isMobile && shouldShowDropdown && ( + + {resultsBody} + + )} + {isMobile && !shouldShowDropdown && (bookmarks?.length ?? 0) > 0 && ( + + Bookmarks + + } + > + {bookmarks.map((bookmark) => ( + { + router.push(bookmark.path); + onConfirm(bookmark); + }} + > + + + + + + ))} + + )} + {!isMobile && shouldShowDropdown && ( - {activeSearch?.isFetching ? ( - - - - - ) : hasResults ? ( - searchType === "BitLocker" ? ( - - ) : searchType === "Pages" ? ( - - ) : ( - - ) - ) : ( - - - No results found. - - - )} + {resultsBody} )} diff --git a/frontend/src/components/CippComponents/CippBreadcrumbNav.jsx b/frontend/src/components/CippComponents/CippBreadcrumbNav.jsx index 18c23806a9..05b197398a 100644 --- a/frontend/src/components/CippComponents/CippBreadcrumbNav.jsx +++ b/frontend/src/components/CippComponents/CippBreadcrumbNav.jsx @@ -1,10 +1,11 @@ import { useEffect, useState, useRef } from 'react' import { useRouter } from 'next/router' -import { Breadcrumbs, Link, Typography, Box, IconButton, Tooltip, useMediaQuery } from '@mui/material' +import { Breadcrumbs, Divider, Link, Typography, Box, IconButton, Tooltip, useMediaQuery } from '@mui/material' import { History, AccountTree } from '@mui/icons-material' import { nativeMenuItems } from '../../layouts/config' import { useSettings } from '../../hooks/use-settings' import { CippBookmarkStar } from './CippBookmarkStar' +import { useIsMobileLayout } from '../../hooks/use-breakpoint' const MAX_HISTORY_STORAGE = 20 // Maximum number of pages to keep in history const MAX_BREADCRUMB_DISPLAY = 5 // Maximum number of breadcrumbs to display at once @@ -36,12 +37,13 @@ const loadTabOptions = () => { }) } -export const CippBreadcrumbNav = () => { +export const CippBreadcrumbNav = ({ withRail = false } = {}) => { const router = useRouter() const settings = useSettings() // Phones get one line: leading crumbs collapse behind MUI's ellipsis button instead of // the trail wrapping to two rows of chrome above every table. const mdDown = useMediaQuery((theme) => theme.breakpoints.down('md')) + const isMobileLayout = useIsMobileLayout() const [history, setHistory] = useState([]) const [mode, setMode] = useState(settings.breadcrumbMode || 'hierarchical') const [tabOptions] = useState(loadTabOptions) @@ -608,6 +610,19 @@ export const CippBreadcrumbNav = () => { const bookmarkCategory = trail.length > 1 ? crumbTitle(trail[0]) : '' const bookmarkStar = + // The layout's rail chrome (gutter box + divider) travels with the nav so that when the + // nav renders nothing — error routes, or a single crumb on a phone — no stray hairline is + // left where the rail was. The AllTenants interstitial renders the nav bare (no withRail). + const rail = (node) => + withRail ? ( + <> + {node} + + + ) : ( + node + ) + // Render based on mode if (mode === 'hierarchical') { const breadcrumbs = trail @@ -617,7 +632,18 @@ export const CippBreadcrumbNav = () => { return null } - return ( + // On phones the rail stands down (taking the mode toggle and bookmark star with it) when + // it has nothing the page doesn't already say: a single crumb is no hierarchy, and the + // dashboard's whole trail ("Overview > Identity") is just its own tab set — the exact + // list the view picker beneath it presents. Desktop keeps the rail everywhere. + const isHomeSurface = breadcrumbs.every( + (crumb) => crumb.path === '/' || crumb.path?.startsWith('/dashboardv2') + ) + if (isMobileLayout && (breadcrumbs.length < 2 || isHomeSurface)) { + return null + } + + return rail( { // Show only the last MAX_BREADCRUMB_DISPLAY items const visibleHistory = history.slice(-MAX_BREADCRUMB_DISPLAY) - return ( + return rail( { const [deleteDialog, setDeleteDialog] = useState({ open: false }) const [refreshDialog, setRefreshDialog] = useState({ open: false }) const [actionSheetOpen, setActionSheetOpen] = useState(false) + const [suiteSheetOpen, setSuiteSheetOpen] = useState(false) // Every row here opens a drawer or dialog — let the sheet close first const actionSheet = useSheetHandoff(() => setActionSheetOpen(false)) const [createDrawerOpen, setCreateDrawerOpen] = useState(false) @@ -145,10 +151,36 @@ export const CippReportToolbar = () => { return ( <> {isMobile ? ( - // Selector + kebab only; suite actions live in the bottom sheet. The overlays they - // open are mounted below, outside the sheet, so closing it doesn't unmount them. + // Trigger + kebab only; picking a suite and the suite actions are both bottom + // sheets — the house pick-one pattern, so no keyboard is summoned for a list nobody + // types into. The overlays the actions open are mounted below, outside the sheet. - {suiteSelector(false)} + setSuiteSheetOpen(true)} + aria-haspopup="dialog" + sx={{ + flex: 1, + minWidth: 0, + height: 44, + display: 'flex', + alignItems: 'center', + gap: 0.75, + px: 1.5, + borderRadius: 1, + border: 1, + borderColor: 'divider', + bgcolor: 'background.paper', + textAlign: 'left', + }} + > + + {selectedReportObject?.name ?? 'Select a test suite'} + + + switch test suite + + + setActionSheetOpen(true)} @@ -223,6 +255,44 @@ export const CippReportToolbar = () => { )} + {isMobile && ( + setSuiteSheetOpen(false)} + title="Test suite" + > + + {reports.map((report) => { + const selected = report.id === selectedReport + return ( + { + setSuiteSheetOpen(false) + if (!selected) { + // Same write the autocomplete made — the routing effect owns the push + formControl.setValue('reportId', { value: report.id, label: report.name }) + } + }} + > + + {selected && } + + ) + })} + + + )} {isMobile && ( <> { bgcolor: 'action.hover', } : { + // Full-width tap target, heading clothes: the chevron is the affordance. width: '100%', - // Matches the mobile table controls' search field rather than the filled chip: - // a full-width filled block reads as a banner, an outlined one as a control. - height: 44, - px: 1.5, - border: 1, - borderColor: 'divider', - bgcolor: 'background.paper', + minHeight: 44, + justifyContent: 'flex-start', }), ...sx, }} @@ -77,7 +73,11 @@ export const CippTabPicker = (props) => { fontSize: 'small', sx: { flexShrink: 0, color: 'text.secondary' }, })} - + {label} {/* Not an aria-label: overriding the name would leave the visible text out of it, and @@ -86,10 +86,10 @@ export const CippTabPicker = (props) => { switch view - {/* Pinned to the control's edge so it reads as the affordance rather than punctuation - trailing whatever the current view happens to be called. */} + {/* Compact rides the control's right edge; the heading form keeps the chevron + beside the text, where a title's disclosure affordance belongs. */} setOpen(false)} title="Views"> diff --git a/frontend/src/components/CippTable/CippDataTable.js b/frontend/src/components/CippTable/CippDataTable.js index 90b43ebc17..b05d973b95 100644 --- a/frontend/src/components/CippTable/CippDataTable.js +++ b/frontend/src/components/CippTable/CippDataTable.js @@ -105,6 +105,21 @@ const scrollNodeToScrollableAncestorTop = (node) => { window.scrollTo(0, window.scrollY + node.getBoundingClientRect().top) } +/** + * Column order for a user-curated selection: the selected ids first, in selection order, + * then everything else in its existing order. Exported for tests. + * + * MRT only reads initialState.columnOrder once, so when the graph filter swaps in a new + * \$select list after mount, the new columns (dot-delimited nested fields included) were + * appended last — and the card view's three detail slots are filled in column order, so a + * field the user explicitly selected was exactly the one that overflowed into "+N more". + */ +export const orderColumnsBySelection = (allIds, selectedIds) => { + const selected = selectedIds.filter((id) => allIds.includes(id)) + const rest = allIds.filter((id) => !selected.includes(id)) + return [...selected, ...rest] +} + // ── Module-level constants ────────────────────────────────────────────────── // These never change between renders, so extracting them avoids creating new // object references on every render cycle. @@ -444,6 +459,7 @@ export const CippDataTable = (props) => { useState(simpleColumns) const [usedData, setUsedData] = useState(data) const [usedColumns, setUsedColumns] = useState([]) + const lastOrderedSelectionRef = useRef(simpleColumns) const [offcanvasVisible, setOffcanvasVisible] = useState(false) const [offCanvasData, setOffCanvasData] = useState({}) const [offCanvasRowIndex, setOffCanvasRowIndex] = useState(0) @@ -655,6 +671,13 @@ export const CippDataTable = (props) => { newVisibility[col.id] = finalResolvedColumns.includes(col.id) } }) + // Selection order wins over data-key order — but only when the selection itself + // changed, so a data refetch doesn't stomp a manual column reorder. + if (lastOrderedSelectionRef.current !== configuredSimpleColumns) { + lastOrderedSelectionRef.current = configuredSimpleColumns + const allIds = finalColumns.map((col) => col.id).filter(Boolean) + table.setColumnOrder(orderColumnsBySelection(allIds, finalResolvedColumns)) + } } else { const providedColumnKeys = new Set( columns.map((col) => col.id || col.header) @@ -1227,6 +1250,10 @@ export const CippDataTable = (props) => { // desktop those columns are still on screen in the table, on mobile they are not. const cardInfoFields = useMemo(() => { if (!isCardView) return undefined + // A page that renders its own drawer body (offCanvas.children — the test-detail pages) + // is the authority on what the drawer shows; prepending the generic property list on + // top of it repeated Risk/Status above a body that already presents them. + if (offCanvas?.children) return undefined const visible = table .getVisibleLeafColumns() .map((column) => column.id) diff --git a/frontend/src/components/CippTestDetail/CippTestDetailOffCanvas.jsx b/frontend/src/components/CippTestDetail/CippTestDetailOffCanvas.jsx index 45e153d2ec..7bed81eb87 100644 --- a/frontend/src/components/CippTestDetail/CippTestDetailOffCanvas.jsx +++ b/frontend/src/components/CippTestDetail/CippTestDetailOffCanvas.jsx @@ -144,16 +144,16 @@ export const CippTestDetailOffCanvas = ({ row }) => { + {/* short label + chip pairs: full-width rows left 80% of a phone empty — 2x2 there, + the same 4-across strip on desktop. two-up by design: mobile-layout-ok */} ({ xs: `1px solid ${theme.palette.divider}`, md: "none", }), - borderRight: (theme) => ({ - md: `1px solid ${theme.palette.divider}`, - }), + borderRight: (theme) => `1px solid ${theme.palette.divider}`, }} > @@ -167,8 +167,9 @@ export const CippTestDetailOffCanvas = ({ row }) => { + {/* two-up by design: mobile-layout-ok */} ({ xs: `1px solid ${theme.palette.divider}`, @@ -194,16 +195,11 @@ export const CippTestDetailOffCanvas = ({ row }) => { + {/* two-up by design: mobile-layout-ok */} ({ - xs: `1px solid ${theme.palette.divider}`, - md: "none", - }), - borderRight: (theme) => ({ - md: `1px solid ${theme.palette.divider}`, - }), + borderRight: (theme) => `1px solid ${theme.palette.divider}`, }} > @@ -221,12 +217,8 @@ export const CippTestDetailOffCanvas = ({ row }) => { - + {/* two-up by design: mobile-layout-ok */} + diff --git a/frontend/src/components/ReleaseNotesDialog.js b/frontend/src/components/ReleaseNotesDialog.js index 0e08fc52fd..08811b2286 100644 --- a/frontend/src/components/ReleaseNotesDialog.js +++ b/frontend/src/components/ReleaseNotesDialog.js @@ -10,6 +10,12 @@ } from 'react' import { Box, + ButtonBase, + IconButton, + List, + ListItemButton, + ListItemIcon, + ListItemText, Button, CircularProgress, Dialog, @@ -20,14 +26,18 @@ import { Stack, Typography, } from '@mui/material' +import { visuallyHidden } from '@mui/utils' import ReactMarkdown from 'react-markdown' +import { useHistoryDismiss } from '../hooks/use-history-dismiss' +import { CippBottomSheet } from './CippComponents/CippBottomSheet' +import { useIsMobileLayout } from '../hooks/use-breakpoint' import remarkGfm from 'remark-gfm' import remarkParse from 'remark-parse' import rehypeRaw from 'rehype-raw' import { unified } from 'unified' import packageInfo from '../../public/version.json' import { ApiGetCall } from '../api/ApiCall' -import { GitHub } from '@mui/icons-material' +import { Check, Close, GitHub, KeyboardArrowDown, MoreHoriz } from '@mui/icons-material' import { CippAutoComplete } from './CippComponents/CippAutocomplete' const RELEASE_COOKIE_KEY = 'cipp_release_notice' @@ -153,10 +163,13 @@ export const ReleaseNotesDialog = forwardRef((_props, ref) => { const [open, setOpen] = useState(false) const [isExpanded, setIsExpanded] = useState(false) const [manualOpenRequested, setManualOpenRequested] = useState(false) + const [moreActionsOpen, setMoreActionsOpen] = useState(false) + const [releasePickerOpen, setReleasePickerOpen] = useState(false) // Left unset until the catalog loads so pickDisplayRelease chooses; seeding it with // the running tag meant a hotfix build always displayed its own thin release notes. const [selectedReleaseTag, setSelectedReleaseTag] = useState(null) const hasOpenedRef = useRef(false) + const isMobile = useIsMobileLayout() useEffect(() => { hasOpenedRef.current = false @@ -224,7 +237,13 @@ export const ReleaseNotesDialog = forwardRef((_props, ref) => { const releaseOptions = useMemo(() => { const mapped = releaseCatalog.map((release) => { const tag = release.releaseTag ?? release.tagName - const label = release.name ? `${release.name} (${tag})` : tag + // GitHub release names usually start with the tag ("v10.8.0 - Ramos Melon Fizz"), + // so the parenthetical only earns its width when the name doesn't carry it. + const label = release.name + ? release.name.includes(tag) + ? release.name + : `${release.name} (${tag})` + : tag return { label, value: tag, @@ -320,6 +339,10 @@ export const ReleaseNotesDialog = forwardRef((_props, ref) => { setManualOpenRequested(false) } + // Phone back gesture dismisses the dialog instead of navigating the page away — same + // remind-later semantics as the ✕, the backdrop and Esc. + useHistoryDismiss(open, handleRemindLater, isMobile) + const toggleExpanded = () => { setIsExpanded((prev) => !prev) } @@ -380,39 +403,72 @@ export const ReleaseNotesDialog = forwardRef((_props, ref) => { }, }} > - - - - {`Release notes for ${releaseHeading}`} - - - - + + {selectedReleaseValue?.label ?? 'Release notes'} + + + switch release + + + + ) : ( + + + {`Release notes for ${releaseHeading}`} + + + + + )} + {/* Phones drop the "Remind me next time" button — closing IS remind-later + (onClose runs the same handler) — so the ✕ is the visible way to do it. */} + + + @@ -509,34 +565,37 @@ export const ReleaseNotesDialog = forwardRef((_props, ref) => { sx={{ alignItems: { xs: 'stretch', md: 'center' }, display: 'flex', - // Stacked on phones with the primary dismissal last, so it sits in thumb reach + // Stacked on phones with the primary dismissal last, so it sits in thumb reach. + // Four stacked rows ate ~240px of a phone screen, so the two low-emphasis actions + // (GitHub, permanent dismiss) share one small row there — and on desktop that row + // dissolves (display: contents) back into this flex row, unchanged. flexDirection: { xs: 'column', md: 'row' }, flexWrap: 'wrap', gap: 1, - justifyContent: 'space-between', px: { xs: 2, md: 3 }, - py: 2, - '& .MuiButton-root': { minHeight: { xs: 44, md: 'auto' } }, + py: { xs: 1.5, md: 2 }, }} > - - + - - + + - + setMoreActionsOpen(true)} + sx={{ + display: { xs: 'inline-flex', md: 'none' }, + minWidth: 44, + minHeight: 44, + border: 1, + borderColor: 'divider', + borderRadius: 1, + }} + > + + + + setReleasePickerOpen(false)} + title="Release" + > + + {releaseOptions.map((option) => { + const selected = option.value === selectedReleaseTag + return ( + { + setReleasePickerOpen(false) + if (!selected) handleReleaseChange(option) + }} + > + + {selected && } + + ) + })} + + + setMoreActionsOpen(false)} + title="Release notes" + > + + setMoreActionsOpen(false)} + sx={{ minHeight: 48 }} + > + + + + + + { + setMoreActionsOpen(false) + handleDismissPermanently() + }} + sx={{ minHeight: 48 }} + > + + + + + + + ) }) diff --git a/frontend/src/layouts/index.js b/frontend/src/layouts/index.js index 4a1dd989c7..b10bfc41ac 100644 --- a/frontend/src/layouts/index.js +++ b/frontend/src/layouts/index.js @@ -355,16 +355,9 @@ export const Layout = (props) => { ) : ( - {showBreadcrumb && ( - <> - {/* Breadcrumbs sit directly under the fixed top nav — a slim rail, not a - spaced section. The old mt:3 left a 24px dead band on every page. */} - - - - - - )} + {/* The nav carries its own rail chrome (gutter + divider) so that when it + renders nothing — a single crumb on a phone — no hairline is left behind. */} + {showBreadcrumb && } {children} )} diff --git a/frontend/src/layouts/tab-navigation-context.js b/frontend/src/layouts/tab-navigation-context.js index 185998e033..d11d1beff1 100644 --- a/frontend/src/layouts/tab-navigation-context.js +++ b/frontend/src/layouts/tab-navigation-context.js @@ -57,6 +57,13 @@ export const useTabNavigationValue = ({ }) => { const [claims, setClaims] = useState([]) + // An aliased route (pages/index.js re-exports the dashboard, so it renders at "/") matches + // no tab path — which left the picker labelled "Views" with nothing checked. The page an + // alias re-exports is one of these tabs, and in practice the first: treat it as current. + const resolvedPath = tabs?.some((tab) => tab.path === currentPath) + ? currentPath + : (tabs?.[0]?.path ?? currentPath) + const claim = useCallback((id) => { setClaims((prev) => (prev.includes(id) ? prev : [...prev, id])) }, []) @@ -69,7 +76,7 @@ export const useTabNavigationValue = ({ () => ({ enabled, tabs, - currentPath, + currentPath: resolvedPath, onNavigate, actions, providesGutters, @@ -80,7 +87,7 @@ export const useTabNavigationValue = ({ [ enabled, tabs, - currentPath, + resolvedPath, onNavigate, actions, providesGutters, diff --git a/frontend/src/pages/dashboardv2/index.js b/frontend/src/pages/dashboardv2/index.js index 9b5c199555..f34494503f 100644 --- a/frontend/src/pages/dashboardv2/index.js +++ b/frontend/src/pages/dashboardv2/index.js @@ -196,12 +196,15 @@ const Page = () => { return ( // Both branches sit under the same TabbedLayout tab bar; the per-tenant mt: 12 is legacy - // desktop spacing kept for now. On mobile it becomes a thin rail — enough to lift the - // test-suite selector off the breadcrumb divider without the old 96px dead band. - + // desktop spacing kept for now. Mobile adds nothing — the breadcrumb rail no longer + // renders on the dashboard, and the sibling views (identity/devices/custom) start their + // toolbar straight after the layout's own 16px gap, so this view must too. + - + {/* xs has a single item (the portals cell is desktop-only), so grid spacing would + only pad the toolbar down away from the title. */} + {!isMobile && ( { )} - + diff --git a/frontend/tests/components/CippCards/CippUniversalSearchV2.stories.jsx b/frontend/tests/components/CippCards/CippUniversalSearchV2.stories.jsx new file mode 100644 index 0000000000..3111dfff96 --- /dev/null +++ b/frontend/tests/components/CippCards/CippUniversalSearchV2.stories.jsx @@ -0,0 +1,62 @@ +import React from 'react' +import { within, waitFor, expect } from 'storybook/test' +import { CippUniversalSearchV2 } from '../../../src/components/CippCards/CippUniversalSearchV2' +import { shrinkToPhoneViewport, growToDesktopViewport } from '../../viewport' + +export default { + title: 'Components/CippCards/CippUniversalSearchV2', + component: CippUniversalSearchV2, + tags: ['autodocs'], +} + +// Desktop: the scope button, field and search button are one joined bordered control. The +// theme defaults TextField to the filled variant, whose own rounded border ignores every +// join rule (they target .MuiOutlinedInput-root) — which once rendered the scope button and +// field as two separate boxes. +export const JoinedControlOnDesktop = { + render: () => ( + + ), + play: async ({ canvasElement, step }) => { + const onDesktop = await growToDesktopViewport() + if (!onDesktop) return + const canvas = within(canvasElement) + + await step('the scope button and the field share one border, no gap', async () => { + const scope = canvas.getByRole('button', { name: /pages/i }) + const field = canvasElement.querySelector('.MuiOutlinedInput-root') + await waitFor(() => { + expect(field).not.toBeNull() + const gap = field.getBoundingClientRect().left - scope.getBoundingClientRect().right + expect(Math.abs(gap)).toBeLessThanOrEqual(1) + expect(getComputedStyle(field).borderTopLeftRadius).toBe('0px') + expect(getComputedStyle(scope).borderTopRightRadius).toBe('0px') + }) + }) + }, +} + +// Phones: no scope button in the group — the field spans the row and each scope is a chip, +// one tap away, so entity search has a direct entry point. +export const ScopeChipsAtPhoneWidth = { + render: () => ( + + ), + play: async ({ canvasElement, step }) => { + const onAPhone = await shrinkToPhoneViewport() + if (!onAPhone) return + const canvas = within(canvasElement) + + await step('the field takes the full row and every scope is a visible chip', async () => { + const field = canvasElement.querySelector('.MuiOutlinedInput-root') + await waitFor(() => { + expect(field).not.toBeNull() + for (const label of ['Users', 'Groups', 'Applications', 'Licenses', 'BitLocker', 'Pages']) { + expect(canvas.getByText(label)).toBeInTheDocument() + } + }) + const host = canvasElement + expect(host.scrollWidth).toBeLessThanOrEqual(host.clientWidth) + }) + }, +} diff --git a/frontend/tests/components/CippCards/CippUniversalSearchV2.test.jsx b/frontend/tests/components/CippCards/CippUniversalSearchV2.test.jsx new file mode 100644 index 0000000000..2f6cdc92f3 --- /dev/null +++ b/frontend/tests/components/CippCards/CippUniversalSearchV2.test.jsx @@ -0,0 +1,125 @@ +import React from 'react' +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { renderWithProviders } from '../../test-utils' + +// jsdom has no width-based matchMedia, so the mobile branch is driven by mocking the hook +const layoutState = vi.hoisted(() => ({ isMobile: false })) +vi.mock('../../../src/hooks/use-breakpoint', async (importOriginal) => ({ + ...(await importOriginal()), + useIsMobileLayout: () => layoutState.isMobile, +})) + +const bookmarkState = vi.hoisted(() => ({ bookmarks: [] })) +vi.mock('../../../src/hooks/use-user-bookmarks', () => ({ + useUserBookmarks: () => ({ bookmarks: bookmarkState.bookmarks, setBookmarks: () => {} }), +})) + +const idle = vi.hoisted(() => ({ + isSuccess: false, + isFetching: false, + isLoading: false, + isError: false, + data: undefined, + refetch: () => {}, +})) +vi.mock('../../../src/api/ApiCall', () => ({ + ApiGetCall: () => idle, + ApiPostCall: () => idle, + ApiGetCallWithPagination: () => ({ ...idle, fetchNextPage: () => {} }), +})) + +const routerState = vi.hoisted(() => ({ push: vi.fn() })) +vi.mock('next/router', () => ({ + useRouter: () => ({ + pathname: '/', + query: {}, + isReady: true, + push: routerState.push, + events: { on: () => {}, off: () => {} }, + }), +})) + +vi.mock('../../../src/hooks/use-permissions', () => ({ + // the page index filters by permission; 'Identity.User.Read' satisfies the config's + // 'Identity.User.*' requirement so the Users pages exist to be found + usePermissions: () => ({ userPermissions: ['Identity.User.Read'], userRoles: ['superadmin'] }), +})) + +import { CippUniversalSearchV2 } from '../../../src/components/CippCards/CippUniversalSearchV2' + +describe('CippUniversalSearchV2 mobile layout', () => { + beforeEach(() => { + layoutState.isMobile = false + bookmarkState.bookmarks = [] + routerState.push = vi.fn() + }) + + it('keeps the scope dropdown on desktop, no chips', () => { + renderWithProviders() + expect(screen.getByRole('button', { name: /pages/i })).toBeInTheDocument() + expect(screen.queryByText('Users', { selector: '.MuiChip-label' })).not.toBeInTheDocument() + }) + + // The desktop scope dropdown cost two taps, and entity search had no direct mobile entry + // point at all — one chip per scope closes that. + it('renders one chip per scope on mobile and switches with a tap', async () => { + layoutState.isMobile = true + const user = userEvent.setup() + renderWithProviders() + + for (const label of ['Users', 'Groups', 'Applications', 'Licenses', 'BitLocker', 'Pages']) { + expect(screen.getByText(label, { selector: '.MuiChip-label' })).toBeInTheDocument() + } + + await user.click(screen.getByText('Users', { selector: '.MuiChip-label' })) + expect(screen.getByPlaceholderText(/search users/i)).toBeInTheDocument() + + // BitLocker reveals its lookup sub-choice as a second chip row + await user.click(screen.getByText('BitLocker', { selector: '.MuiChip-label' })) + expect(screen.getByText('Key ID', { selector: '.MuiChip-label' })).toBeInTheDocument() + expect(screen.getByText('Device ID', { selector: '.MuiChip-label' })).toBeInTheDocument() + }) + + it('fills the empty state with bookmarks that navigate and close', async () => { + layoutState.isMobile = true + bookmarkState.bookmarks = [ + { label: 'GDAP Relationships', path: '/tenant/gdap-management/relationships', category: 'Tenant' }, + ] + const onConfirm = vi.fn() + const user = userEvent.setup() + renderWithProviders() + + expect(screen.getByText('Bookmarks')).toBeInTheDocument() + await user.click(screen.getByText('GDAP Relationships')) + expect(routerState.push).toHaveBeenCalledWith('/tenant/gdap-management/relationships') + expect(onConfirm).toHaveBeenCalled() + }) + + // userEvent.click fires mousedown -> click; the outside-click closer ran on mousedown, + // unmounted the row, and the click landed on nothing — results vanished, no navigation. + it('navigates when a page result is tapped, instead of just closing', async () => { + layoutState.isMobile = true + const onConfirm = vi.fn() + const user = userEvent.setup() + renderWithProviders() + + await user.type(screen.getByPlaceholderText(/search pages/i), 'users') + const result = await screen.findAllByRole('menuitem') + await user.click(result[0]) + + expect(routerState.push).toHaveBeenCalled() + expect(onConfirm).toHaveBeenCalled() + }) + + it('renders page results in flow on mobile, not in a portal panel', async () => { + layoutState.isMobile = true + const user = userEvent.setup() + renderWithProviders() + + await user.type(screen.getByPlaceholderText(/search pages/i), 'users') + // the floating panel marks itself; in-flow results must not + expect(document.querySelector('[data-dropdown-portal]')).toBeNull() + }) +}) diff --git a/frontend/tests/components/CippComponents/CippBreadcrumbNav.test.jsx b/frontend/tests/components/CippComponents/CippBreadcrumbNav.test.jsx index 06c181b553..79794a7045 100644 --- a/frontend/tests/components/CippComponents/CippBreadcrumbNav.test.jsx +++ b/frontend/tests/components/CippComponents/CippBreadcrumbNav.test.jsx @@ -5,10 +5,16 @@ import { CippBreadcrumbNav } from '../../../src/components/CippComponents/CippBr // second require.context consumer, this one globs every pages/**/tabOptions.json. covers the // subdirectory + regex arms of the polyfill that the tutorial glob (flat, no subdirs) doesn't. // 'Groups' only reaches the trail through src/pages/tenant/administration/tenants/tabOptions.json +const routerState = vi.hoisted(() => ({ pathname: '/tenant/administration/tenants/groups' })) +const layoutState = vi.hoisted(() => ({ isMobile: false })) +vi.mock('../../../src/hooks/use-breakpoint', async (importOriginal) => ({ + ...(await importOriginal()), + useIsMobileLayout: () => layoutState.isMobile, +})) vi.mock('next/router', () => ({ useRouter: () => ({ - pathname: '/tenant/administration/tenants/groups', - asPath: '/tenant/administration/tenants/groups', + pathname: routerState.pathname, + asPath: routerState.pathname, query: {}, isReady: true, push: () => Promise.resolve(), @@ -18,6 +24,49 @@ vi.mock('next/router', () => ({ })) describe('CippBreadcrumbNav', () => { + beforeEach(() => { + routerState.pathname = '/tenant/administration/tenants/groups' + layoutState.isMobile = false + }) + + // The dashboard's rail is one crumb saying "Overview" directly above a picker saying + // "Overview" — a single crumb is no hierarchy, so on phones the rail stands down. + it('hides the rail on mobile when there is no hierarchy to show', () => { + routerState.pathname = '/' + layoutState.isMobile = true + renderWithProviders() + + expect(screen.queryByLabelText('page hierarchy')).not.toBeInTheDocument() + }) + + // "Overview > Identity" is the dashboard's own tab set — the exact list the view picker + // beneath it presents, so on phones it says nothing the page doesn't. + it('hides the rail on mobile across all dashboard views, not just the root', () => { + routerState.pathname = '/dashboardv2/identity' + layoutState.isMobile = true + renderWithProviders() + + expect(screen.queryByLabelText('page hierarchy')).not.toBeInTheDocument() + }) + + it('keeps the dashboard rail on desktop', () => { + routerState.pathname = '/dashboardv2/identity' + renderWithProviders() + expect(screen.getByLabelText('page hierarchy')).toBeInTheDocument() + }) + + it('keeps a single-crumb rail on desktop, and deep rails on mobile', () => { + routerState.pathname = '/' + renderWithProviders() + expect(screen.getByLabelText('page hierarchy')).toBeInTheDocument() + }) + + it('keeps a multi-crumb rail on mobile', () => { + layoutState.isMobile = true + renderWithProviders() + expect(screen.getByText('Groups')).toBeInTheDocument() + }) + it('labels the tab crumb from the tabOptions require.context', () => { renderWithProviders() diff --git a/frontend/tests/components/CippComponents/CippReportToolbar.test.jsx b/frontend/tests/components/CippComponents/CippReportToolbar.test.jsx index fb102677d8..3d94facfa0 100644 --- a/frontend/tests/components/CippComponents/CippReportToolbar.test.jsx +++ b/frontend/tests/components/CippComponents/CippReportToolbar.test.jsx @@ -132,15 +132,44 @@ describe("CippReportToolbar", () => { expect(screen.queryByRole("button", { name: "Test suite actions" })).not.toBeInTheDocument(); }); - it("collapses to selector + kebab on mobile", () => { + it("collapses to a sheet trigger + kebab on mobile — no text input, no keyboard", () => { layoutState.isMobile = true; + routerState.query = { reportId: "ztna" }; renderWithProviders(); expect(screen.getByRole("button", { name: "Test suite actions" })).toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Delete" })).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Refresh" })).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Refresh test suites" })).not.toBeInTheDocument(); - expect(screen.getByRole("combobox")).toBeInTheDocument(); + // the house pick-one pattern: a trigger, not an autocomplete + expect(screen.queryByRole("combobox")).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: /switch test suite/i })).toHaveTextContent( + "Zero Trust Network Access Tests" + ); + }); + + it("switches suite from the bottom sheet, routing shallowly", async () => { + layoutState.isMobile = true; + routerState.query = { reportId: "ztna" }; + const user = userEvent.setup(); + renderWithProviders(); + + await user.click(screen.getByRole("button", { name: /switch test suite/i })); + const sheet = within((await screen.findByText("Test suite")).closest(".MuiDrawer-paper")); + // descriptions ride as secondary text, the current suite is checked + expect(sheet.getByText("custom")).toBeInTheDocument(); + expect(sheet.getByText("Zero Trust Network Access Tests").closest('[role="button"]')).toHaveClass( + "Mui-selected" + ); + + await user.click(sheet.getByText("My Custom Suite")); + await waitFor(() => + expect(routerState.push).toHaveBeenCalledWith( + expect.objectContaining({ query: expect.objectContaining({ reportId: "custom-1" }) }), + undefined, + { shallow: true } + ) + ); }); it("offers all five suite actions in the sheet", async () => { diff --git a/frontend/tests/components/CippComponents/CippTabPicker.stories.jsx b/frontend/tests/components/CippComponents/CippTabPicker.stories.jsx index ee0d05e299..4303a3b6cf 100644 --- a/frontend/tests/components/CippComponents/CippTabPicker.stories.jsx +++ b/frontend/tests/components/CippComponents/CippTabPicker.stories.jsx @@ -73,10 +73,13 @@ export const BlockAtPhoneWidth = { await expect(picker.getBoundingClientRect().width).toBeGreaterThan(content - 1) }) - await step('the chevron stays pinned to the right edge', async () => { + // Heading clothes: the chevron rides beside the text like a title's disclosure + // affordance, not pinned to the far edge like a form field's. + await step('the chevron sits beside the label, not at the far edge', async () => { const chevron = picker.querySelector('svg:last-of-type') - const gap = picker.getBoundingClientRect().right - chevron.getBoundingClientRect().right - await expect(gap).toBeLessThan(20) + const labelEl = within(picker).getByText('Policies and Settings Deployed') + const gapToLabel = chevron.getBoundingClientRect().left - labelEl.getBoundingClientRect().right + await expect(gapToLabel).toBeLessThan(24) }) }, } diff --git a/frontend/tests/components/CippTable/CippDataTable.test.jsx b/frontend/tests/components/CippTable/CippDataTable.test.jsx index 67e439e1f1..573e12c7ab 100644 --- a/frontend/tests/components/CippTable/CippDataTable.test.jsx +++ b/frontend/tests/components/CippTable/CippDataTable.test.jsx @@ -343,6 +343,38 @@ describe('CippDataTable card view without an offCanvas', () => { expect(screen.getAllByText(/Seattle/).length).toBeGreaterThan(0) }) + // The test-detail pages render their own drawer body (offCanvas.children) — prepending + // the generic property list on top of it repeated Risk/Status above a body that already + // presents them. + it('lets a custom drawer body own the drawer, without the generic property list', async () => { + const user = userEvent.setup() + renderWithProviders( +
    rich detail body
    , + }} + /> + ) + + await waitFor(() => + expect(screen.getByText('Applications do not have client secrets configured')).toBeInTheDocument() + ) + await user.click(screen.getByText('Applications do not have client secrets configured')) + + // scope to the drawer that holds the body — the toolbar's Edit Filters offcanvas is + // also a mounted .MuiDrawer-paper and sorts first in the DOM + const body = await screen.findByTestId('rich-body') + const drawer = body.closest('.MuiDrawer-paper') + expect(drawer.textContent).toContain('rich detail body') + // no generic property list stacked above the page's own body + expect(drawer.textContent).not.toMatch(/Risk/) + }) + it('formats fallback values the way their table cells do', async () => { const user = userEvent.setup() renderWithProviders( diff --git a/frontend/tests/components/CippTable/order-columns-by-selection.test.js b/frontend/tests/components/CippTable/order-columns-by-selection.test.js new file mode 100644 index 0000000000..c5618a0c8c --- /dev/null +++ b/frontend/tests/components/CippTable/order-columns-by-selection.test.js @@ -0,0 +1,36 @@ +import { describe, it, expect } from 'vitest' +import { orderColumnsBySelection } from '../../../src/components/CippTable/CippDataTable' + +// MRT reads initialState.columnOrder once; when the graph filter swaps the $select list +// after mount, new columns appended last — and the card view fills its three detail slots +// in column order, so the field the user just selected was the one overflowing into +// "+N more". Selection order has to win. +describe('orderColumnsBySelection', () => { + const all = ['displayName', 'userPrincipalName', 'mail', 'signInActivity.lastSuccessfulSignInDateTime', 'proxyAddresses'] + + it('puts the selection first, in selection order', () => { + expect( + orderColumnsBySelection(all, ['signInActivity.lastSuccessfulSignInDateTime', 'displayName']) + ).toEqual([ + 'signInActivity.lastSuccessfulSignInDateTime', + 'displayName', + 'userPrincipalName', + 'mail', + 'proxyAddresses', + ]) + }) + + it('ignores selected ids that have no column, keeps the rest stable', () => { + expect(orderColumnsBySelection(all, ['nope', 'mail'])).toEqual([ + 'mail', + 'displayName', + 'userPrincipalName', + 'signInActivity.lastSuccessfulSignInDateTime', + 'proxyAddresses', + ]) + }) + + it('is a no-op shape when nothing is selected', () => { + expect(orderColumnsBySelection(all, [])).toEqual(all) + }) +}) diff --git a/frontend/tests/components/ReleaseNotesDialog.test.jsx b/frontend/tests/components/ReleaseNotesDialog.test.jsx index 7ac561aa64..2468b0f51a 100644 --- a/frontend/tests/components/ReleaseNotesDialog.test.jsx +++ b/frontend/tests/components/ReleaseNotesDialog.test.jsx @@ -1,5 +1,5 @@ import React from 'react' -import { screen, waitFor } from '@testing-library/react' +import { screen, waitFor, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { renderWithProviders } from '../test-utils' @@ -8,6 +8,13 @@ import { renderWithProviders } from '../test-utils' const versionState = vi.hoisted(() => ({ version: '10.8.2' })) vi.mock('../../public/version.json', () => ({ default: versionState })) +// jsdom has no width-based matchMedia, so the mobile branch is driven by mocking the hook +const layoutState = vi.hoisted(() => ({ isMobile: false })) +vi.mock('../../src/hooks/use-breakpoint', async (importOriginal) => ({ + ...(await importOriginal()), + useIsMobileLayout: () => layoutState.isMobile, +})) + vi.mock('../../src/api/ApiCall', async () => (await import('../mocks/api-call')).apiCallMock()) import { api, getResult } from '../mocks/api-call' @@ -48,6 +55,7 @@ const PERMANENT_HIDE_KEY = 'cipp_release_notice_permanently_hidden' const flushEffects = () => new Promise((resolve) => setTimeout(resolve, 0)) beforeEach(() => { + layoutState.isMobile = false versionState.version = '10.8.2' api.get = catalogResult window.localStorage.clear() @@ -62,7 +70,7 @@ describe('ReleaseNotesDialog', () => { renderWithProviders() expect( - await screen.findByText('Release notes for v10.9.0 - Something Newer') + await screen.findByDisplayValue('v10.9.0 - Something Newer') ).toBeInTheDocument() expect(screen.queryByText('Notes for the hotfix that is actually running')).toBeNull() }) @@ -70,10 +78,10 @@ describe('ReleaseNotesDialog', () => { it('still lets you pick a hotfix release from the picker', async () => { const user = userEvent.setup() renderWithProviders() - await screen.findByText('Release notes for v10.9.0 - Something Newer') + await screen.findByDisplayValue('v10.9.0 - Something Newer') await user.click(screen.getByRole('combobox')) - await user.click(await screen.findByText('v10.8.2 - Hotfix (v10.8.2)')) + await user.click(await screen.findByText('v10.8.2 - Hotfix')) expect( await screen.findByText('Notes for the hotfix that is actually running') @@ -84,7 +92,7 @@ describe('ReleaseNotesDialog', () => { const user = userEvent.setup() const { unmount } = renderWithProviders() - await screen.findByText('Release notes for v10.9.0 - Something Newer') + await screen.findByDisplayValue('v10.9.0 - Something Newer') await user.click(screen.getByRole('button', { name: "Don't show until next release" })) await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()) @@ -105,7 +113,45 @@ describe('ReleaseNotesDialog', () => { renderWithProviders() - expect(await screen.findByText('Release notes for v10.9.0 - Something Newer')).toBeInTheDocument() + expect(await screen.findByDisplayValue('v10.9.0 - Something Newer')).toBeInTheDocument() + }) + + // On phones the two low-emphasis actions live behind the kebab as bottom-sheet rows — + // the same actions treatment as the rest of the mobile surface. + it('puts GitHub and permanent dismiss behind the kebab sheet on mobile', async () => { + layoutState.isMobile = true + const user = userEvent.setup() + renderWithProviders() + // the house pick-one pattern: a trigger, not a text input — no keyboard to summon + const trigger = await screen.findByRole('button', { name: /switch release/i }) + expect(trigger).toHaveTextContent('v10.9.0 - Something Newer') + expect(screen.queryByDisplayValue('v10.9.0 - Something Newer')).not.toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'More options' })) + // the desktop footer's copy is only display:none'd by a media query jsdom can't + // evaluate — scope to the sheet's drawer paper + const github = await screen.findByRole('link', { name: /view release notes on github/i }) + expect(github).toHaveAttribute('href', 'https://github.com/CyberDrain/CIPP/releases/tag/v10.9.0') + const sheet = within(github.closest('.MuiDrawer-paper')) + + await user.click(sheet.getByText("Don't show again")) + await flushEffects() + expect(window.localStorage.getItem(PERMANENT_HIDE_KEY)).toBe('true') + }) + + it('switches release from the mobile sheet', async () => { + layoutState.isMobile = true + const user = userEvent.setup() + renderWithProviders() + + await user.click(await screen.findByRole('button', { name: /switch release/i })) + const sheet = within((await screen.findByText('Release')).closest('.MuiDrawer-paper')) + await user.click(sheet.getByText('v10.8.2 - Hotfix')) + + expect(await screen.findByText('Notes for the hotfix that is actually running')).toBeInTheDocument() + expect(screen.getByRole('button', { name: /switch release/i })).toHaveTextContent( + 'v10.8.2 - Hotfix' + ) }) it('falls back to the .0 notes when the running version has no release of its own', async () => { @@ -113,7 +159,7 @@ describe('ReleaseNotesDialog', () => { renderWithProviders() - expect(await screen.findByText('Release notes for v10.9.0 - Something Newer')).toBeInTheDocument() + expect(await screen.findByDisplayValue('v10.9.0 - Something Newer')).toBeInTheDocument() }) it('honours a permanent dismissal', async () => { diff --git a/frontend/tests/layouts/TabbedLayout.test.jsx b/frontend/tests/layouts/TabbedLayout.test.jsx index f993739370..f913a511e5 100644 --- a/frontend/tests/layouts/TabbedLayout.test.jsx +++ b/frontend/tests/layouts/TabbedLayout.test.jsx @@ -97,6 +97,29 @@ describe("TabbedLayout", () => { tabOptions.forEach((tab) => expect(sheet.getByText(tab.label)).toBeInTheDocument()); }); + // pages/index.js re-exports the dashboard, so it renders at "/" while every tab path is + // /dashboardv2/... — no match meant the trigger fell back to "Views" and the sheet had no + // check. An aliased route belongs to the tab whose page it re-exports: the first one. + it("treats an aliased route as the first tab instead of showing no selection", async () => { + layoutState.isMobile = true; + routerState.pathname = "/"; + const user = userEvent.setup(); + renderWithProviders( + +
    page content
    +
    + ); + + expect(picker()).toHaveAccessibleName("Overview switch view"); + + const sheet = await openPicker(user); + expect(sheet.getByText("Overview").closest('[role="button"]')).toHaveClass("Mui-selected"); + + // and tapping the aliased tab is still a no-op, not a navigation loop + await user.click(sheet.getByText("Overview")); + expect(routerState.push).not.toHaveBeenCalled(); + }); + it("navigates when a tab row is tapped, and does nothing for the current tab", async () => { layoutState.isMobile = true; const user = userEvent.setup(); From 76ebbb26b72b83045a425896f697f4d41efd977a Mon Sep 17 00:00:00 2001 From: Bobby <31723128+kris6673@users.noreply.github.com> Date: Sat, 15 Aug 2026 16:49:21 +0200 Subject: [PATCH 063/226] feat(standards): add EnableTeamsConsumerInbound to Teams external access standards Add the missing AllowTeamsConsumerInbound / EnableTeamsConsumerInbound toggle to both TeamsExternalAccessPolicy and TeamsFederationConfiguration standards so MSPs can control whether unmanaged Teams users may initiate contact independently of the parent consumer-access switch. Closes #102 --- .../TeamsExternalAccessPolicy.json | 16 ++++++++++++-- backend/Config/standards.json | 20 +++++++++++++++++ .../Public/Get-CippTestDataFieldManifest.ps1 | 4 ++-- ...-CIPPStandardTeamsExternalAccessPolicy.ps1 | 22 ++++++++++++------- ...PPStandardTeamsFederationConfiguration.ps1 | 20 +++++++++++------ frontend/src/data/standards.json | 20 +++++++++++++++++ 6 files changed, 83 insertions(+), 19 deletions(-) diff --git a/backend/Config/BaselineStandards/Teams Standards/TeamsExternalAccessPolicy.json b/backend/Config/BaselineStandards/Teams Standards/TeamsExternalAccessPolicy.json index 6ef835626a..9c3c92fab1 100644 --- a/backend/Config/BaselineStandards/Teams Standards/TeamsExternalAccessPolicy.json +++ b/backend/Config/BaselineStandards/Teams Standards/TeamsExternalAccessPolicy.json @@ -37,11 +37,22 @@ "type": "switch", "label": "Allow communication with unmanaged Teams accounts", "default": false + }, + "EnableTeamsConsumerInbound": { + "type": "switch", + "label": "Allow unmanaged Teams users to initiate contact", + "default": false, + "condition": { + "field": "EnableTeamsConsumerAccess", + "compareType": "is", + "compareValue": true + } } }, "expected": { "EnableFederationAccess": "%EnableFederationAccess%", - "EnableTeamsConsumerAccess": "%EnableTeamsConsumerAccess%" + "EnableTeamsConsumerAccess": "%EnableTeamsConsumerAccess%", + "EnableTeamsConsumerInbound": "%EnableTeamsConsumerInbound%" }, "read": { "cacheType": "CsExternalAccessPolicy" @@ -53,7 +64,8 @@ "cmdlet": "Set-CsExternalAccessPolicy", "params": { "EnableFederationAccess": "%EnableFederationAccess%", - "EnableTeamsConsumerAccess": "%EnableTeamsConsumerAccess%" + "EnableTeamsConsumerAccess": "%EnableTeamsConsumerAccess%", + "EnableTeamsConsumerInbound": "%EnableTeamsConsumerInbound%" } } ] diff --git a/backend/Config/standards.json b/backend/Config/standards.json index 62a8932f62..b44e78ab29 100644 --- a/backend/Config/standards.json +++ b/backend/Config/standards.json @@ -6021,6 +6021,16 @@ "type": "switch", "name": "standards.TeamsExternalAccessPolicy.EnableTeamsConsumerAccess", "label": "Allow communication with unmanaged Teams accounts" + }, + { + "type": "switch", + "name": "standards.TeamsExternalAccessPolicy.EnableTeamsConsumerInbound", + "label": "Allow unmanaged Teams users to initiate contact", + "condition": { + "field": "standards.TeamsExternalAccessPolicy.EnableTeamsConsumerAccess", + "compareType": "is", + "compareValue": true + } } ], "label": "External Access Settings for Microsoft Teams", @@ -6045,6 +6055,16 @@ "name": "standards.TeamsFederationConfiguration.AllowTeamsConsumer", "label": "Allow users to communicate with consumer Teams accounts" }, + { + "type": "switch", + "name": "standards.TeamsFederationConfiguration.AllowTeamsConsumerInbound", + "label": "Allow unmanaged Teams users to initiate contact", + "condition": { + "field": "standards.TeamsFederationConfiguration.AllowTeamsConsumer", + "compareType": "is", + "compareValue": true + } + }, { "type": "autoComplete", "required": true, diff --git a/backend/Modules/CIPPCore/Public/Get-CippTestDataFieldManifest.ps1 b/backend/Modules/CIPPCore/Public/Get-CippTestDataFieldManifest.ps1 index a2e97e2f61..b0598ac101 100644 --- a/backend/Modules/CIPPCore/Public/Get-CippTestDataFieldManifest.ps1 +++ b/backend/Modules/CIPPCore/Public/Get-CippTestDataFieldManifest.ps1 @@ -84,12 +84,12 @@ function Get-CippTestDataFieldManifest { 'ConditionalAccessPolicies' = @('id', 'displayName', 'state', 'conditions', 'grantControls', 'sessionControls', 'createdDateTime', 'modifiedDateTime') 'CopilotReadinessActivity' = @('userPrincipalName', 'usesOutlookEmail', 'usesTeamsMeetings', 'usesTeamsChat', 'usesOfficeDocs', 'onQualifiedUpdateChannel', 'hasCopilotLicenseAssigned') 'CrossTenantAccessPolicy' = @('id', 'b2bCollaborationOutbound', 'b2bDirectConnectOutbound', 'tenantRestrictions') - 'CsExternalAccessPolicy' = @('EnableFederationAccess', 'EnableTeamsConsumerAccess') + 'CsExternalAccessPolicy' = @('EnableFederationAccess', 'EnableTeamsConsumerAccess', 'EnableTeamsConsumerInbound') 'CsTeamsAppPermissionPolicy' = @('Identity', 'GlobalCatalogAppsType', 'DefaultCatalogAppsType') 'CsTeamsClientConfiguration' = @('AllowDropbox', 'AllowBox', 'AllowGoogleDrive', 'AllowShareFile', 'AllowEgnyte', 'AllowEmailIntoChannel') 'CsTeamsMeetingPolicy' = @('AllowAnonymousUsersToJoinMeeting', 'AllowAnonymousUsersToStartMeeting', 'AutoAdmittedUsers', 'AllowPSTNUsersToBypassLobby', 'MeetingChatEnabledType', 'DesignatedPresenterRoleMode', 'AllowExternalParticipantGiveRequestControl', 'AllowExternalNonTrustedMeetingChat', 'AllowCloudRecording') 'CsTeamsMessagingPolicy' = @('UseB2BInvitesToAddExternalUsers', 'AllowSecurityEndUserReporting') - 'CsTenantFederationConfiguration' = @('AllowFederatedUsers', 'AllowedDomains', 'AllowTeamsConsumer') + 'CsTenantFederationConfiguration' = @('AllowFederatedUsers', 'AllowedDomains', 'AllowTeamsConsumer', 'AllowTeamsConsumerInbound') 'DefaultAppManagementPolicy' = @('isEnabled', 'applicationRestrictions', 'servicePrincipalRestrictions') 'DeviceRegistrationPolicy' = @('azureADJoin', 'userDeviceQuota', 'localAdminPassword', 'multiFactorAuthConfiguration') 'DeviceSettings' = @('secureByDefault') diff --git a/backend/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTeamsExternalAccessPolicy.ps1 b/backend/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTeamsExternalAccessPolicy.ps1 index 8eb632ba19..37bd93c22a 100644 --- a/backend/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTeamsExternalAccessPolicy.ps1 +++ b/backend/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTeamsExternalAccessPolicy.ps1 @@ -18,6 +18,7 @@ function Invoke-CIPPStandardTeamsExternalAccessPolicy { ADDEDCOMPONENT {"type":"switch","name":"standards.TeamsExternalAccessPolicy.EnableFederationAccess","label":"Allow communication from trusted organizations"} {"type":"switch","name":"standards.TeamsExternalAccessPolicy.EnableTeamsConsumerAccess","label":"Allow communication with unmanaged Teams accounts"} + {"type":"switch","name":"standards.TeamsExternalAccessPolicy.EnableTeamsConsumerInbound","label":"Allow unmanaged Teams users to initiate contact","condition":{"field":"standards.TeamsExternalAccessPolicy.EnableTeamsConsumerAccess","compareType":"is","compareValue":true}} IMPACT Medium Impact ADDEDDATE @@ -55,18 +56,21 @@ function Invoke-CIPPStandardTeamsExternalAccessPolicy { $EnableFederationAccess = $Settings.EnableFederationAccess ?? $false $EnableTeamsConsumerAccess = $Settings.EnableTeamsConsumerAccess ?? $false + $EnableTeamsConsumerInbound = $Settings.EnableTeamsConsumerInbound ?? $false $StateIsCorrect = ($CurrentState.EnableFederationAccess -eq $EnableFederationAccess) -and - ($CurrentState.EnableTeamsConsumerAccess -eq $EnableTeamsConsumerAccess) + ($CurrentState.EnableTeamsConsumerAccess -eq $EnableTeamsConsumerAccess) -and + ($CurrentState.EnableTeamsConsumerInbound -eq $EnableTeamsConsumerInbound) if ($Settings.remediate -eq $true) { if ($StateIsCorrect -eq $true) { Write-LogMessage -API 'Standards' -tenant $Tenant -message 'External Access Policy already set.' -sev Info } else { $cmdParams = @{ - Identity = 'Global' - EnableFederationAccess = $EnableFederationAccess - EnableTeamsConsumerAccess = $EnableTeamsConsumerAccess + Identity = 'Global' + EnableFederationAccess = $EnableFederationAccess + EnableTeamsConsumerAccess = $EnableTeamsConsumerAccess + EnableTeamsConsumerInbound = $EnableTeamsConsumerInbound } try { @@ -92,12 +96,14 @@ function Invoke-CIPPStandardTeamsExternalAccessPolicy { Add-CIPPBPAField -FieldName 'TeamsExternalAccessPolicy' -FieldValue $StateIsCorrect -StoreAs bool -Tenant $Tenant $CurrentValue = @{ - EnableFederationAccess = $CurrentState.EnableFederationAccess - EnableTeamsConsumerAccess = $CurrentState.EnableTeamsConsumerAccess + EnableFederationAccess = $CurrentState.EnableFederationAccess + EnableTeamsConsumerAccess = $CurrentState.EnableTeamsConsumerAccess + EnableTeamsConsumerInbound = $CurrentState.EnableTeamsConsumerInbound } $ExpectedValue = @{ - EnableFederationAccess = $EnableFederationAccess - EnableTeamsConsumerAccess = $EnableTeamsConsumerAccess + EnableFederationAccess = $EnableFederationAccess + EnableTeamsConsumerAccess = $EnableTeamsConsumerAccess + EnableTeamsConsumerInbound = $EnableTeamsConsumerInbound } Set-CIPPStandardsCompareField -FieldName 'standards.TeamsExternalAccessPolicy' -CurrentValue $CurrentValue -ExpectedValue $ExpectedValue -Tenant $Tenant } diff --git a/backend/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTeamsFederationConfiguration.ps1 b/backend/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTeamsFederationConfiguration.ps1 index 10639e02d8..c4e62f781d 100644 --- a/backend/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTeamsFederationConfiguration.ps1 +++ b/backend/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTeamsFederationConfiguration.ps1 @@ -17,6 +17,7 @@ function Invoke-CIPPStandardTeamsFederationConfiguration { Configures how the organization federates with external organizations for Teams communication, controlling whether employees can communicate with specific external domains or all external organizations. This setting enables secure inter-organizational collaboration while maintaining control over external communications. ADDEDCOMPONENT {"type":"switch","name":"standards.TeamsFederationConfiguration.AllowTeamsConsumer","label":"Allow users to communicate with other organizations"} + {"type":"switch","name":"standards.TeamsFederationConfiguration.AllowTeamsConsumerInbound","label":"Allow unmanaged Teams users to initiate contact","condition":{"field":"standards.TeamsFederationConfiguration.AllowTeamsConsumer","compareType":"is","compareValue":true}} {"type":"autoComplete","required":true,"multiple":false,"creatable":false,"name":"standards.TeamsFederationConfiguration.DomainControl","label":"Communication Mode","options":[{"label":"Allow all external domains","value":"AllowAllExternal"},{"label":"Block all external domains","value":"BlockAllExternal"},{"label":"Allow specific external domains","value":"AllowSpecificExternal"},{"label":"Block specific external domains","value":"BlockSpecificExternal"}]} {"type":"textField","name":"standards.TeamsFederationConfiguration.DomainList","label":"Domains, Comma separated","required":false,"condition":{"field":"standards.TeamsFederationConfiguration.DomainControl.value","compareType":"isOneOf","compareValue":["AllowSpecificExternal","BlockSpecificExternal"]}} IMPACT @@ -60,6 +61,7 @@ function Invoke-CIPPStandardTeamsFederationConfiguration { $DomainControl = $Settings.DomainControl.value ?? $Settings.DomainControl # An untoggled switch is absent from the settings; default it to $false so we never send null to the ConfigApi $AllowTeamsConsumer = $Settings.AllowTeamsConsumer ?? $false + $AllowTeamsConsumerInbound = $Settings.AllowTeamsConsumerInbound ?? $false $AllowedDomainsAsAList = @() $BlockedDomains = @() switch ($DomainControl) { @@ -140,6 +142,7 @@ function Invoke-CIPPStandardTeamsFederationConfiguration { $ExpectedBlockedDomains = $BlockedDomains ?? @() $StateIsCorrect = ($CurrentState.AllowTeamsConsumer -eq $AllowTeamsConsumer) -and + ($CurrentState.AllowTeamsConsumerInbound -eq $AllowTeamsConsumerInbound) -and ($CurrentState.AllowFederatedUsers -eq $AllowFederatedUsers) -and $AllowedDomainsMatches -and $BlockedDomainsMatches @@ -150,8 +153,9 @@ function Invoke-CIPPStandardTeamsFederationConfiguration { } else { $cmdParams = @{ Identity = 'Global' - AllowTeamsConsumer = $AllowTeamsConsumer - AllowFederatedUsers = $AllowFederatedUsers + AllowTeamsConsumer = $AllowTeamsConsumer + AllowTeamsConsumerInbound = $AllowTeamsConsumerInbound + AllowFederatedUsers = $AllowFederatedUsers AllowedDomains = $AllowedDomainsPayload BlockedDomains = @($BlockedDomains) } @@ -210,15 +214,17 @@ function Invoke-CIPPStandardTeamsFederationConfiguration { } $CurrentValue = @{ - AllowTeamsConsumer = $CurrentState.AllowTeamsConsumer - AllowFederatedUsers = $CurrentState.AllowFederatedUsers + AllowTeamsConsumer = $CurrentState.AllowTeamsConsumer + AllowTeamsConsumerInbound = $CurrentState.AllowTeamsConsumerInbound + AllowFederatedUsers = $CurrentState.AllowFederatedUsers AllowedDomains = $CurrentAllowedDomainsForReport BlockedDomains = $CurrentBlockedDomainsForReport } $ExpectedValue = @{ - AllowTeamsConsumer = $AllowTeamsConsumer - AllowFederatedUsers = $AllowFederatedUsers - AllowedDomains = $ExpectedAllowedDomainsForReport + AllowTeamsConsumer = $AllowTeamsConsumer + AllowTeamsConsumerInbound = $AllowTeamsConsumerInbound + AllowFederatedUsers = $AllowFederatedUsers + AllowedDomains = $ExpectedAllowedDomainsForReport BlockedDomains = $ExpectedBlockedDomainsForReport } Set-CIPPStandardsCompareField -FieldName 'standards.TeamsFederationConfiguration' -CurrentValue $CurrentValue -ExpectedValue $ExpectedValue -Tenant $Tenant diff --git a/frontend/src/data/standards.json b/frontend/src/data/standards.json index ac06341b8b..e2e74e6f0e 100644 --- a/frontend/src/data/standards.json +++ b/frontend/src/data/standards.json @@ -6021,6 +6021,16 @@ "type": "switch", "name": "standards.TeamsExternalAccessPolicy.EnableTeamsConsumerAccess", "label": "Allow communication with unmanaged Teams accounts" + }, + { + "type": "switch", + "name": "standards.TeamsExternalAccessPolicy.EnableTeamsConsumerInbound", + "label": "Allow unmanaged Teams users to initiate contact", + "condition": { + "field": "standards.TeamsExternalAccessPolicy.EnableTeamsConsumerAccess", + "compareType": "is", + "compareValue": true + } } ], "label": "External Access Settings for Microsoft Teams", @@ -6045,6 +6055,16 @@ "name": "standards.TeamsFederationConfiguration.AllowTeamsConsumer", "label": "Allow users to communicate with consumer Teams accounts" }, + { + "type": "switch", + "name": "standards.TeamsFederationConfiguration.AllowTeamsConsumerInbound", + "label": "Allow unmanaged Teams users to initiate contact", + "condition": { + "field": "standards.TeamsFederationConfiguration.AllowTeamsConsumer", + "compareType": "is", + "compareValue": true + } + }, { "type": "autoComplete", "required": true, From 11d59d0205c0522c914b2968cea13c0517f65231 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Sat, 15 Aug 2026 11:10:08 -0400 Subject: [PATCH 064/226] fix(sankey): use painted palette for dark mode detection When currentTheme is "browser", the app resolves dark/light from the OS preference. Reading the setting for "dark" returned false while the page painted dark, causing link ribbons to multiply-blend over a dark card and render as invisible black. Switch to useTheme().palette.mode so the check reflects the actual painted palette. Also align the Bookmarks header row with the breadcrumb rail on desktop by adding an alignWithRail prop and matching padding/pt values. --- .../components/CippComponents/CippSankey.jsx | 10 +- frontend/src/layouts/side-nav-bookmarks.js | 9 +- frontend/src/layouts/side-nav.js | 8 +- .../CippComponents/CippSankey.test.jsx | 144 +++++------------- 4 files changed, 60 insertions(+), 111 deletions(-) diff --git a/frontend/src/components/CippComponents/CippSankey.jsx b/frontend/src/components/CippComponents/CippSankey.jsx index 035d0d9952..44d46736a7 100644 --- a/frontend/src/components/CippComponents/CippSankey.jsx +++ b/frontend/src/components/CippComponents/CippSankey.jsx @@ -1,7 +1,6 @@ import { useMemo } from "react"; import { ResponsiveSankey } from "@nivo/sankey"; -import { Box, ButtonBase, Typography } from "@mui/material"; -import { useSettings } from "../../hooks/use-settings"; +import { Box, ButtonBase, Typography, useTheme } from "@mui/material"; import { useIsMobileLayout } from "../../hooks/use-breakpoint"; // A node's weight: what flows in, or out if nothing flows in (the leftmost column). @@ -19,8 +18,11 @@ const nodeTotals = (data) => { }; export const CippSankey = ({ data, onNodeClick, onLinkClick }) => { - const settings = useSettings(); - const isDark = settings.currentTheme?.value === "dark"; + // The painted palette, not the theme *setting*: when the setting is "browser" the app + // resolves dark/light from the OS preference, so checking the setting for "dark" said + // light while the page was dark — and a "multiply" blend over a dark card composites the + // link ribbons to black. + const isDark = useTheme().palette.mode === "dark"; // A sankey is three columns of nodes plus their labels. At desktop widths the labels sit // horizontally inside an 18px-thick node and still read. On a ~350px card they cannot: a // node carrying a handful of users is a couple of pixels tall, and its label — rotated or diff --git a/frontend/src/layouts/side-nav-bookmarks.js b/frontend/src/layouts/side-nav-bookmarks.js index 0ae0ec7abd..08bba90d2b 100644 --- a/frontend/src/layouts/side-nav-bookmarks.js +++ b/frontend/src/layouts/side-nav-bookmarks.js @@ -16,10 +16,15 @@ import ChevronDownIcon from "@heroicons/react/24/outline/ChevronDownIcon"; import { useSettings } from "../hooks/use-settings"; import { useUserBookmarks } from "../hooks/use-user-bookmarks"; -export const SideNavBookmarks = ({ collapse = false }) => { +// alignWithRail: the pinned side nav sits beside the content area's breadcrumb rail, and the +// two header rows share a divider line across the seam — the desktop nav passes this so the +// Bookmarks row matches the rail's 28px row instead of the 48px nav-item rhythm. The mobile +// drawer has no rail beside it and keeps the roomier row. +export const SideNavBookmarks = ({ collapse = false, alignWithRail = false }) => { const settings = useSettings(); const compactNav = settings.compactNav ?? false; const navItemPy = compactNav ? "6px" : "12px"; + const headerPy = alignWithRail ? "2px" : navItemPy; const emptyStatePy = compactNav ? "4px" : "8px"; const { bookmarks, setBookmarks } = useUserBookmarks(); const [open, setOpen] = useState(settings.bookmarksOpen ?? false); @@ -190,7 +195,7 @@ export const SideNavBookmarks = ({ collapse = false }) => { fontWeight: 500, justifyContent: "flex-start", px: "6px", - py: navItemPy, + py: headerPy, textAlign: "left", whiteSpace: "nowrap", width: "100%", diff --git a/frontend/src/layouts/side-nav.js b/frontend/src/layouts/side-nav.js index cd8cd5ce83..5181643c64 100644 --- a/frontend/src/layouts/side-nav.js +++ b/frontend/src/layouts/side-nav.js @@ -227,6 +227,9 @@ export const SideNav = (props) => { flexDirection: 'column', height: '100%', p: 2, + // The breadcrumb rail across the seam starts 10px under the top nav; starting + // the Bookmarks header at the same offset lets the two rows share a line. + pt: '10px', }} > { {/* Bookmarks section above Dashboard */} {showSidebarBookmarks && ( <> - - + + {/* mt matches the rail row's mb: 1, so the dividers meet across the seam */} + )} {/* Render all menu items */} diff --git a/frontend/tests/components/CippComponents/CippSankey.test.jsx b/frontend/tests/components/CippComponents/CippSankey.test.jsx index a9721bfb26..85735229d8 100644 --- a/frontend/tests/components/CippComponents/CippSankey.test.jsx +++ b/frontend/tests/components/CippComponents/CippSankey.test.jsx @@ -1,123 +1,61 @@ import React from "react"; -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { render, screen, within } from "@testing-library/react"; +import { describe, it, expect, vi } from "vitest"; +import { renderWithProviders, settingsWith } from "../../test-utils"; +import { createTheme } from "../../../src/theme"; -// A sankey is three node columns plus labels. Desktop draws labels horizontally inside an -// 18px node; at ~350px they overrun the node and collide with the links, which is what -// "messed up on mobile" looks like. Assert the narrow-screen geometry instead of pixels. -const layoutState = vi.hoisted(() => ({ isMobile: false })); -vi.mock("../../../src/hooks/use-breakpoint", () => ({ - useIsMobileLayout: () => layoutState.isMobile, - useIsTabletLayout: () => false, - useTableViewMode: () => "table", -})); - -vi.mock("../../../src/hooks/use-settings", () => ({ - useSettings: () => ({ currentTheme: { value: "light" } }), -})); - -const sankeyProps = vi.hoisted(() => ({ last: null })); +// jsdom gives nivo's responsive wrapper a 0×0 parent, so nothing paints — capture the +// props instead and assert on the dark/light decisions they encode. +const captured = vi.hoisted(() => ({ props: null })); vi.mock("@nivo/sankey", () => ({ ResponsiveSankey: (props) => { - sankeyProps.last = props; - return
    ; + captured.props = props; + return null; }, })); +vi.mock("../../../src/hooks/use-breakpoint", async (importOriginal) => ({ + ...(await importOriginal()), + useIsMobileLayout: () => false, +})); + import { CippSankey } from "../../../src/components/CippComponents/CippSankey"; const data = { - nodes: [{ id: "A", nodeColor: "red" }, { id: "B", nodeColor: "blue" }], - links: [{ source: "A", target: "B", value: 1 }], -}; - -// The shape that broke on a phone: one node carrying nearly everything and three carrying a -// handful each, so the small ones are a couple of pixels tall and their labels are not. -const lopsided = { nodes: [ - { id: "users", label: "Users", nodeColor: "orange" }, - { id: "mfa", label: "Multi factor", nodeColor: "blue" }, - { id: "single", label: "Single factor", nodeColor: "red" }, - { id: "phish", label: "Phishing-resistant", nodeColor: "green" }, - ], - links: [ - { source: "users", target: "mfa", value: 471 }, - { source: "users", target: "single", value: 3 }, - { source: "users", target: "phish", value: 2 }, + { id: "Users", nodeColor: "#f97316" }, + { id: "MFA", nodeColor: "#22c55e" }, ], + links: [{ source: "Users", target: "MFA", value: 5 }], }; -describe("CippSankey", () => { - beforeEach(() => { - layoutState.isMobile = false; - sankeyProps.last = null; - }); - - it("keeps horizontal inside labels and blended gradient links on desktop", () => { - render(); - expect(sankeyProps.last.labelOrientation).toBe("horizontal"); - expect(sankeyProps.last.nodeThickness).toBe(18); - expect(sankeyProps.last.enableLinkGradient).toBe(true); - expect(sankeyProps.last.linkBlendMode).toBe("multiply"); - }); - - // Bare node bars with no ribbons between them: mix-blend-mode on SVG is unreliable in - // mobile WebKit and can composite gradient-filled links away entirely. - it("draws links without blend modes or gradients on narrow screens", () => { - layoutState.isMobile = true; - render(); - - expect(sankeyProps.last.linkBlendMode).toBe("normal"); - expect(sankeyProps.last.enableLinkGradient).toBe(false); - expect(sankeyProps.last.linkOpacity).toBeGreaterThan(0.5); - expect(sankeyProps.last.linkContract).toBe(0); - }); - - it("rotates labels and thins the nodes on narrow screens", () => { - layoutState.isMobile = true; - render(); - - expect(sankeyProps.last.labelOrientation).toBe("vertical"); - expect(sankeyProps.last.nodeThickness).toBeLessThan(18); - expect(sankeyProps.last.nodeSpacing).toBeLessThan(24); - expect(sankeyProps.last.labelPadding).toBeLessThan(16); - // margins shrink so the chart itself keeps the width it has - expect(sankeyProps.last.margin.left).toBeLessThan(10); - expect(sankeyProps.last.theme.labels.text.fontSize).toBeLessThan(12); - }); - - // A node worth 2 of 476 users is a couple of pixels tall; its label, rotated or not, is - // longer than the node it belongs to, so the small ones stack into an unreadable smear. - // Below md the chart stops drawing labels and the legend names the nodes instead. - it("moves node names out of the chart and into a legend on narrow screens", () => { - layoutState.isMobile = true; - render(); - - expect(sankeyProps.last.enableLabels).toBe(false); - - const legend = screen.getByRole("list"); - const rows = within(legend).getAllByRole("listitem"); - expect(rows).toHaveLength(4); - expect(legend).toHaveTextContent("Phishing-resistant"); - // weight comes from the links, not the nodes: incoming, or outgoing for the first column - expect(within(legend).getByText("476")).toBeInTheDocument(); - expect(within(legend).getByText("471")).toBeInTheDocument(); - }); +const darkTheme = createTheme({ + colorPreset: "orange", + direction: "ltr", + paletteMode: "dark", + contrast: "high", +}); - it("keeps the chart labelled and adds no legend on desktop", () => { - render(); - expect(sankeyProps.last.enableLabels).toBe(true); - expect(screen.queryByRole("list")).not.toBeInTheDocument(); +describe("CippSankey theming", () => { + // The app resolves currentTheme "browser" to the OS preference when building the MUI + // theme, so the *setting* can say "browser" while the page paints dark. Deciding + // darkness from the setting made the chart multiply its ribbons over a dark card — + // composited to black, i.e. an invisible chart until the user toggled the theme. + it("follows the painted palette, not the theme setting", () => { + renderWithProviders(, { + theme: darkTheme, + settings: settingsWith({ currentTheme: { value: "browser", label: "Browser default" } }), + }); + + expect(captured.props.linkBlendMode).toBe("lighten"); + expect(captured.props.labelTextColor).toBe("#ffffff"); }); - it("makes each legend row a tap target that selects its node", async () => { - const onNodeClick = vi.fn(); - layoutState.isMobile = true; - const { default: userEvent } = await import("@testing-library/user-event"); - const user = userEvent.setup(); - render(); + it("keeps multiply-over-white on an actually light page", () => { + renderWithProviders(, { + settings: settingsWith({ currentTheme: { value: "browser", label: "Browser default" } }), + }); - await user.click(screen.getByText("Single factor")); - expect(onNodeClick).toHaveBeenCalledWith(expect.objectContaining({ id: "single" })); + expect(captured.props.linkBlendMode).toBe("multiply"); + expect(captured.props.labelTextColor).toBe("#000000"); }); }); From d8340749bf42591f313355b06b51c4e6ca0cf91b Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Sat, 15 Aug 2026 19:31:57 +0200 Subject: [PATCH 065/226] standards --- .../Defender Standards/AntiSpamSafeList.json | 60 +++ .../EmptyFilterIPAllowList.json | 54 ++ .../Defender Standards/TeamsZAP.json | 54 ++ .../Exchange Standards/DeployMailContact.json | 88 ++++ .../Exchange Standards/DisableViva.json | 36 ++ .../Exchange Standards/SpoofWarn.json | 77 +++ .../Global Standards/AddDMARCToMOERA.json | 44 ++ .../Global Standards/AnonReportDisable.json | 38 ++ .../Global Standards/EnablePronouns.json | 35 ++ .../FormsPhishingProtection.json | 47 ++ .../IntuneComplianceSettings.json | 72 +++ .../IntuneWindowsDiagnostic.json | 57 ++ .../MDMEnrollmentDuringRegistration.json | 52 ++ .../DisableUserSiteCreate.json | 50 ++ .../TenantDefaultTimezone.json | 494 ++++++++++++++++++ 15 files changed, 1258 insertions(+) create mode 100644 backend/Config/BaselineStandards/Defender Standards/AntiSpamSafeList.json create mode 100644 backend/Config/BaselineStandards/Defender Standards/EmptyFilterIPAllowList.json create mode 100644 backend/Config/BaselineStandards/Defender Standards/TeamsZAP.json create mode 100644 backend/Config/BaselineStandards/Exchange Standards/DeployMailContact.json create mode 100644 backend/Config/BaselineStandards/Exchange Standards/DisableViva.json create mode 100644 backend/Config/BaselineStandards/Exchange Standards/SpoofWarn.json create mode 100644 backend/Config/BaselineStandards/Global Standards/AddDMARCToMOERA.json create mode 100644 backend/Config/BaselineStandards/Global Standards/AnonReportDisable.json create mode 100644 backend/Config/BaselineStandards/Global Standards/EnablePronouns.json create mode 100644 backend/Config/BaselineStandards/Global Standards/FormsPhishingProtection.json create mode 100644 backend/Config/BaselineStandards/Intune Standards/IntuneComplianceSettings.json create mode 100644 backend/Config/BaselineStandards/Intune Standards/IntuneWindowsDiagnostic.json create mode 100644 backend/Config/BaselineStandards/Intune Standards/MDMEnrollmentDuringRegistration.json create mode 100644 backend/Config/BaselineStandards/SharePoint Standards/DisableUserSiteCreate.json create mode 100644 backend/Config/BaselineStandards/SharePoint Standards/TenantDefaultTimezone.json diff --git a/backend/Config/BaselineStandards/Defender Standards/AntiSpamSafeList.json b/backend/Config/BaselineStandards/Defender Standards/AntiSpamSafeList.json new file mode 100644 index 0000000000..2acef633ab --- /dev/null +++ b/backend/Config/BaselineStandards/Defender Standards/AntiSpamSafeList.json @@ -0,0 +1,60 @@ +{ + "name": "AntiSpamSafeList", + "label": "Set Anti-Spam Connection Filter Safe List", + "cat": "Defender Standards", + "tag": [ + "CIS M365 7.0.0 (2.1.13)" + ], + "impact": "Medium Impact", + "helpText": "Sets the anti-spam connection filter policy option 'safe list' in Defender.", + "executiveText": "Enables Microsoft's pre-approved list of trusted email servers to improve email delivery from legitimate sources while maintaining spam protection. This reduces false positives where legitimate emails might be blocked while still protecting against spam and malicious emails.", + "docsDescription": "Sets [Microsoft's built-in 'safe list'](https://learn.microsoft.com/en-us/powershell/module/exchange/set-hostedconnectionfilterpolicy?view=exchange-ps#-enablesafelist) in the anti-spam connection filter policy, rather than setting a custom safe/block list of IPs.", + "impactColour": "info", + "addedDate": "2025-02-15", + "powershellEquivalent": "Set-HostedConnectionFilterPolicy \"Default\" -EnableSafeList $true", + "appliesToTest": [ + "CIS_2_1_13" + ], + "recommendedBy": [], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ], + "secureScoreImpact": 0, + "compare": "subset", + "variables": { + "enableSafeList": { + "type": "switch", + "label": "Enable Safe List", + "default": false, + "recommended": false + } + }, + "expected": { + "EnableSafeList": "%enableSafeList%" + }, + "read": { + "cacheType": "ExoHostedConnectionFilterPolicy", + "filter": [ + { + "property": "Identity", + "value": "Default" + } + ] + }, + "remediate": { + "executor": "ExoRequest", + "cmdlets": [ + { + "cmdlet": "Set-HostedConnectionFilterPolicy", + "params": { + "Identity": "Default", + "EnableSafeList": "%enableSafeList%" + } + } + ] + } +} diff --git a/backend/Config/BaselineStandards/Defender Standards/EmptyFilterIPAllowList.json b/backend/Config/BaselineStandards/Defender Standards/EmptyFilterIPAllowList.json new file mode 100644 index 0000000000..ce6bdd83eb --- /dev/null +++ b/backend/Config/BaselineStandards/Defender Standards/EmptyFilterIPAllowList.json @@ -0,0 +1,54 @@ +{ + "name": "EmptyFilterIPAllowList", + "label": "Ensure connection filter IP allow list is empty", + "cat": "Defender Standards", + "tag": [ + "CIS M365 7.0.0 (2.1.12)" + ], + "impact": "Medium Impact", + "helpText": "Ensures the connection filter IP allow list is not used. IPs on this list bypass spam, spoof, and authentication checks.", + "executiveText": "Ensures the Exchange Online connection filter IP allow list is empty, preventing any IP addresses from bypassing spam filtering, spoofing checks, and sender authentication. Keeping this list empty ensures all inbound email undergoes full security scanning, reducing the risk of phishing and malware delivery through trusted-but-compromised sources.", + "docsDescription": "IPs on the connection filter allow list bypass spam, spoof, and authentication checks. CIS recommends keeping this list empty to ensure all inbound email is properly scanned. This standard checks that the IPAllowList on the Default hosted connection filter policy is empty and can remediate by clearing it.", + "impactColour": "warning", + "addedDate": "2026-05-06", + "powershellEquivalent": "Set-HostedConnectionFilterPolicy -Identity Default -IPAllowList @()", + "appliesToTest": [ + "CIS_2_1_12" + ], + "recommendedBy": [ + "CIS" + ], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ], + "secureScoreImpact": 0, + "compare": "subset", + "expected": { + "IPAllowList": [] + }, + "read": { + "cacheType": "ExoHostedConnectionFilterPolicy", + "filter": [ + { + "property": "Identity", + "value": "Default" + } + ] + }, + "remediate": { + "executor": "ExoRequest", + "cmdlets": [ + { + "cmdlet": "Set-HostedConnectionFilterPolicy", + "params": { + "Identity": "Default", + "IPAllowList": [] + } + } + ] + } +} diff --git a/backend/Config/BaselineStandards/Defender Standards/TeamsZAP.json b/backend/Config/BaselineStandards/Defender Standards/TeamsZAP.json new file mode 100644 index 0000000000..32d80145e5 --- /dev/null +++ b/backend/Config/BaselineStandards/Defender Standards/TeamsZAP.json @@ -0,0 +1,54 @@ +{ + "name": "TeamsZAP", + "label": "Ensure Zero-hour auto purge for Microsoft Teams is on", + "cat": "Defender Standards", + "tag": [ + "CIS M365 7.0.0 (2.4.4)" + ], + "impact": "Low Impact", + "helpText": "Ensures Zero-hour auto purge (ZAP) is enabled for Microsoft Teams, automatically removing malicious messages after delivery.", + "executiveText": "Enables Zero-hour auto purge for Microsoft Teams to automatically detect and remove malicious messages after delivery. This provides an additional layer of protection against phishing and malware that may bypass initial scanning, ensuring threats are neutralised even after they reach users.", + "docsDescription": "Zero-hour auto purge (ZAP) for Microsoft Teams retroactively detects and neutralises malicious messages that have already been delivered in Teams chats. Enabling ZAP ensures that phishing, malware, and high confidence phishing messages are automatically purged even after initial delivery, aligning with CIS M365 7.0.0 benchmark control 2.4.4.", + "impactColour": "info", + "addedDate": "2026-05-06", + "powershellEquivalent": "Set-TeamsProtectionPolicy -Identity 'Teams Protection Policy' -ZapEnabled $true", + "appliesToTest": [ + "CIS_2_4_4" + ], + "recommendedBy": [ + "CIS" + ], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ], + "secureScoreImpact": 0, + "compare": "subset", + "expected": { + "ZapEnabled": true + }, + "read": { + "cacheType": "ExoTeamsProtectionPolicy", + "filter": [ + { + "property": "Identity", + "value": "Teams Protection Policy" + } + ] + }, + "remediate": { + "executor": "ExoRequest", + "cmdlets": [ + { + "cmdlet": "Set-TeamsProtectionPolicy", + "params": { + "Identity": "Teams Protection Policy", + "ZapEnabled": true + } + } + ] + } +} diff --git a/backend/Config/BaselineStandards/Exchange Standards/DeployMailContact.json b/backend/Config/BaselineStandards/Exchange Standards/DeployMailContact.json new file mode 100644 index 0000000000..272860fcf5 --- /dev/null +++ b/backend/Config/BaselineStandards/Exchange Standards/DeployMailContact.json @@ -0,0 +1,88 @@ +{ + "name": "DeployMailContact", + "label": "Deploy Mail Contact", + "cat": "Exchange Standards", + "tag": [], + "impact": "Low Impact", + "helpText": "Creates a new mail contact in Exchange Online across all selected tenants. The contact will be visible in the Global Address List.", + "executiveText": "Automatically creates external email contacts in the organization's address book, enabling seamless communication with external partners and vendors. This standardizes contact management across all company locations and improves collaboration efficiency.", + "docsDescription": "This standard creates a new mail contact in Exchange Online. Mail contacts are useful for adding external email addresses to your organization's address book. They can be used for distribution lists, shared mailboxes, and other collaboration scenarios.", + "impactColour": "info", + "addedDate": "2024-03-19", + "powershellEquivalent": "New-MailContact", + "recommendedBy": [ + "CIPP" + ], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ], + "secureScoreImpact": 0, + "compare": "subset", + "variables": { + "ExternalEmailAddress": { + "type": "textField", + "label": "External Email Address", + "required": true, + "default": "" + }, + "DisplayName": { + "type": "textField", + "label": "Display Name", + "required": true, + "default": "" + }, + "FirstName": { + "type": "textField", + "label": "First Name", + "default": "" + }, + "LastName": { + "type": "textField", + "label": "Last Name", + "default": "" + } + }, + "expected": { + "DisplayName": "%DisplayName%", + "ExternalEmailAddress": "%ExternalEmailAddress%", + "FirstName": "%FirstName%", + "LastName": "%LastName%" + }, + "read": { + "cacheType": "ExoMailContacts", + "filter": [ + { + "property": "ExternalEmailAddress", + "value": "%ExternalEmailAddress%" + } + ] + }, + "remediate": { + "executor": "ExoRequest", + "cmdlets": [ + { + "cmdlet": "New-MailContact", + "params": { + "Name": "%DisplayName%", + "ExternalEmailAddress": "%ExternalEmailAddress%", + "FirstName": "%FirstName%", + "LastName": "%LastName%" + }, + "continueOnError": true + }, + { + "cmdlet": "Set-Contact", + "params": { + "Identity": "%ExternalEmailAddress%", + "DisplayName": "%DisplayName%", + "FirstName": "%FirstName%", + "LastName": "%LastName%" + } + } + ] + } +} diff --git a/backend/Config/BaselineStandards/Exchange Standards/DisableViva.json b/backend/Config/BaselineStandards/Exchange Standards/DisableViva.json new file mode 100644 index 0000000000..c7f96176ba --- /dev/null +++ b/backend/Config/BaselineStandards/Exchange Standards/DisableViva.json @@ -0,0 +1,36 @@ +{ + "name": "DisableViva", + "label": "Disable daily Insight/Viva reports", + "cat": "Exchange Standards", + "tag": [], + "impact": "Low Impact", + "helpText": "Disables the daily viva reports for all users. This standard requires the CIPP-SAM application to have the Company Administrator (Global Admin) role in the tenant. Enable this using CIPP > Advanced > Super Admin > SAM App Roles. Activate the roles with a CPV refresh.", + "executiveText": "Disables daily Microsoft Viva Insights reports that are automatically sent to employees, reducing email volume and allowing organizations to control when and how productivity insights are shared. This can help prevent information overload while maintaining the ability to access insights when needed.", + "docsDescription": "", + "impactColour": "info", + "addedDate": "2022-05-25", + "powershellEquivalent": "Set-UserBriefingConfig", + "recommendedBy": [], + "requiredCapabilities": [], + "secureScoreImpact": 0, + "compare": "subset", + "variables": {}, + "expected": { + "isEnabledInOrganization": false + }, + "read": { + "cacheType": "PeopleInsights" + }, + "remediate": { + "executor": "GraphRequest", + "requests": [ + { + "method": "PATCH", + "uri": "organization/%tenantid%/settings/peopleInsights", + "body": { + "isEnabledInOrganization": false + } + } + ] + } +} diff --git a/backend/Config/BaselineStandards/Exchange Standards/SpoofWarn.json b/backend/Config/BaselineStandards/Exchange Standards/SpoofWarn.json new file mode 100644 index 0000000000..d259955cfa --- /dev/null +++ b/backend/Config/BaselineStandards/Exchange Standards/SpoofWarn.json @@ -0,0 +1,77 @@ +{ + "name": "SpoofWarn", + "label": "Enable or disable 'external' warning in Outlook", + "cat": "Exchange Standards", + "tag": [ + "CIS M365 7.0.0 (6.2.3)" + ], + "impact": "Low Impact", + "helpText": "Adds or removes indicators to e-mail messages received from external senders in Outlook. Works on all Outlook clients/OWA", + "executiveText": "Displays visual warnings in Outlook when emails come from external senders, helping employees identify potentially suspicious messages and reducing the risk of phishing attacks. This security feature makes it easier for staff to distinguish between internal and external communications.", + "docsDescription": "Adds or removes indicators to e-mail messages received from external senders in Outlook. You can read more about this feature on [Microsoft's Exchange Team Blog.](https://techcommunity.microsoft.com/t5/exchange-team-blog/native-external-sender-callouts-on-email-in-outlook/ba-p/2250098)", + "impactColour": "info", + "addedDate": "2021-11-16", + "powershellEquivalent": "Set-ExternalInOutlook –Enabled $true or $false", + "appliesToTest": [ + "CISAMSEXO71", + "CIS_6_2_3", + "ORCA111", + "ORCA240" + ], + "recommendedBy": [ + "CIS", + "CIPP" + ], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ], + "secureScoreImpact": 0, + "compare": "subset", + "variables": { + "externalWarningEnabled": { + "type": "switch", + "label": "Show the 'external' sender warning in Outlook", + "default": true, + "recommended": true + }, + "allowListAdd": { + "type": "autoComplete", + "multiple": true, + "creatable": true, + "required": false, + "label": "Enter allowed senders(domain.com, *.domain.com or test@domain.com)", + "default": [] + } + }, + "expected": { + "Enabled": "%externalWarningEnabled%" + }, + "read": { + "cacheType": "ExoExternalInOutlook" + }, + "remediate": { + "executor": "ExoRequest", + "cmdlets": [ + { + "cmdlet": "Set-ExternalInOutlook", + "params": { + "Enabled": "%externalWarningEnabled%" + } + }, + { + "cmdlet": "Set-ExternalInOutlook", + "params": { + "AllowList": { + "@odata.type": "#Exchange.GenericHashTable", + "Add": "%allowListAdd%" + } + }, + "continueOnError": true + } + ] + } +} diff --git a/backend/Config/BaselineStandards/Global Standards/AddDMARCToMOERA.json b/backend/Config/BaselineStandards/Global Standards/AddDMARCToMOERA.json new file mode 100644 index 0000000000..039f34d41c --- /dev/null +++ b/backend/Config/BaselineStandards/Global Standards/AddDMARCToMOERA.json @@ -0,0 +1,44 @@ +{ + "name": "AddDMARCToMOERA", + "label": "Enables DMARC on MOERA (onmicrosoft.com) domains", + "cat": "Global Standards", + "tag": [ + "CIS M365 7.0.0 (2.1.10)", + "Security", + "PhishingProtection", + "SMB1001 (2.12)" + ], + "impact": "Low Impact", + "helpText": "** Remediation is not available ** Note: requires 'Domain Name Administrator' GDAP role. This should be enabled even if the MOERA (onmicrosoft.com) domains is not used for sending. Enabling this prevents email spoofing. The default value is 'v=DMARC1; p=reject;' recommended because the domain is only used within M365 and reporting is not needed. Omitting pct tag default to 100%", + "executiveText": "Implements advanced email security for Microsoft's default domain names (onmicrosoft.com) to prevent criminals from impersonating your organization. This blocks fraudulent emails that could damage your company's reputation and protects partners and customers from phishing attacks using your domain names.", + "docsDescription": "** Remediation is not available ** Note: requires 'Domain Name Administrator' GDAP role. Adds a DMARC record to MOERA (onmicrosoft.com) domains. This should be enabled even if the MOERA (onmicrosoft.com) domains is not used for sending. Enabling this prevents email spoofing. The default record is 'v=DMARC1; p=reject;' recommended because the domain is only used within M365 and reporting is not needed. Omitting pct tag default to 100%", + "impactColour": "info", + "addedDate": "2025-06-16", + "powershellEquivalent": "Portal only", + "appliesToTest": [ + "CIS_2_1_10", + "SMB1001_2_12" + ], + "recommendedBy": [ + "CIS", + "Microsoft" + ], + "requiredCapabilities": [], + "secureScoreImpact": 0, + "compare": "subset", + "variables": { + "RecordValue": { + "type": "textField", + "label": "DMARC record value", + "default": "v=DMARC1; p=reject;", + "recommended": "v=DMARC1; p=reject;" + } + }, + "expected": { + "hasDmarc": true, + "record": "%RecordValue%" + }, + "read": { + "cacheType": "MoeraDmarc" + } +} diff --git a/backend/Config/BaselineStandards/Global Standards/AnonReportDisable.json b/backend/Config/BaselineStandards/Global Standards/AnonReportDisable.json new file mode 100644 index 0000000000..b5c9d3fff7 --- /dev/null +++ b/backend/Config/BaselineStandards/Global Standards/AnonReportDisable.json @@ -0,0 +1,38 @@ +{ + "name": "AnonReportDisable", + "label": "Enable Usernames instead of pseudo anonymised names in reports", + "cat": "Global Standards", + "tag": [], + "impact": "Low Impact", + "helpText": "Shows usernames instead of pseudo anonymised names in reports. This standard is required for reporting to work correctly.", + "executiveText": "Configures Microsoft 365 reports to display actual usernames instead of anonymized identifiers, enabling IT administrators to effectively troubleshoot issues and generate meaningful usage reports. This improves operational efficiency and system management capabilities.", + "docsDescription": "Microsoft announced some APIs and reports no longer return names, to comply with compliance and legal requirements in specific countries. This proves an issue for a lot of MSPs because those reports are often helpful for engineers. This standard applies a setting that shows usernames in those API calls / reports.", + "impactColour": "info", + "addedDate": "2021-11-16", + "powershellEquivalent": "Update-MgBetaAdminReportSetting -BodyParameter @{displayConcealedNames = $true}", + "recommendedBy": [ + "CIPP" + ], + "requiredCapabilities": [], + "secureScoreImpact": 0, + "compare": "subset", + "variables": {}, + "expected": { + "displayConcealedNames": false + }, + "read": { + "cacheType": "AdminReportSettings" + }, + "remediate": { + "executor": "GraphRequest", + "requests": [ + { + "method": "PATCH", + "uri": "admin/reportSettings", + "body": { + "displayConcealedNames": false + } + } + ] + } +} diff --git a/backend/Config/BaselineStandards/Global Standards/EnablePronouns.json b/backend/Config/BaselineStandards/Global Standards/EnablePronouns.json new file mode 100644 index 0000000000..603c9cd581 --- /dev/null +++ b/backend/Config/BaselineStandards/Global Standards/EnablePronouns.json @@ -0,0 +1,35 @@ +{ + "name": "EnablePronouns", + "label": "Enable Pronouns", + "cat": "Global Standards", + "tag": [], + "impact": "Low Impact", + "helpText": "Enables the Pronouns feature for the tenant. This allows users to set their pronouns in their profile.", + "executiveText": "Allows employees to display their preferred pronouns in their Microsoft 365 profiles, supporting inclusive workplace practices and helping colleagues communicate respectfully. This feature enhances diversity and inclusion initiatives while fostering a more welcoming work environment.", + "impactColour": "info", + "addedDate": "2024-06-05", + "powershellEquivalent": "Update-MgBetaAdminPeoplePronoun -IsEnabledInOrganization:$true", + "recommendedBy": [], + "requiredCapabilities": [], + "secureScoreImpact": 0, + "compare": "subset", + "variables": {}, + "expected": { + "isEnabledInOrganization": true + }, + "read": { + "cacheType": "Pronouns" + }, + "remediate": { + "executor": "GraphRequest", + "requests": [ + { + "method": "PATCH", + "uri": "admin/people/pronouns", + "body": { + "isEnabledInOrganization": true + } + } + ] + } +} diff --git a/backend/Config/BaselineStandards/Global Standards/FormsPhishingProtection.json b/backend/Config/BaselineStandards/Global Standards/FormsPhishingProtection.json new file mode 100644 index 0000000000..ddc211eeb4 --- /dev/null +++ b/backend/Config/BaselineStandards/Global Standards/FormsPhishingProtection.json @@ -0,0 +1,47 @@ +{ + "name": "FormsPhishingProtection", + "label": "Enable internal phishing protection for Forms", + "cat": "Global Standards", + "tag": [ + "CIS M365 7.0.0 (1.3.5)", + "Security", + "PhishingProtection" + ], + "impact": "Low Impact", + "helpText": "Enables internal phishing protection for Microsoft Forms to help prevent malicious forms from being created and shared within the organization. This feature scans forms created by internal users for potential phishing content and suspicious patterns.", + "executiveText": "Automatically scans Microsoft Forms created by employees for malicious content and phishing attempts, preventing the creation and distribution of harmful forms within the organization. This protects against both internal threats and compromised accounts that might be used to distribute malicious content.", + "docsDescription": "Enables internal phishing protection for Microsoft Forms by setting the isInOrgFormsPhishingScanEnabled property to true. This security feature helps protect organizations from internal phishing attacks through Microsoft Forms by automatically scanning forms created by internal users for potential malicious content, suspicious links, and phishing patterns. When enabled, Forms will analyze form content and block or flag potentially dangerous forms before they can be shared within the organization.", + "impactColour": "info", + "addedDate": "2025-06-06", + "powershellEquivalent": "Graph API", + "appliesToTest": [ + "CIS_1_3_5" + ], + "recommendedBy": [ + "CIS", + "CIPP" + ], + "requiredCapabilities": [], + "secureScoreImpact": 0, + "compare": "subset", + "variables": {}, + "expected": { + "isInOrgFormsPhishingScanEnabled": true + }, + "read": { + "cacheType": "FormsSettings" + }, + "remediate": { + "executor": "GraphRequest", + "requests": [ + { + "method": "PATCH", + "asApp": false, + "uri": "admin/forms/settings", + "body": { + "isInOrgFormsPhishingScanEnabled": true + } + } + ] + } +} diff --git a/backend/Config/BaselineStandards/Intune Standards/IntuneComplianceSettings.json b/backend/Config/BaselineStandards/Intune Standards/IntuneComplianceSettings.json new file mode 100644 index 0000000000..231b53452f --- /dev/null +++ b/backend/Config/BaselineStandards/Intune Standards/IntuneComplianceSettings.json @@ -0,0 +1,72 @@ +{ + "name": "IntuneComplianceSettings", + "label": "Set Intune Compliance Settings", + "cat": "Intune Standards", + "tag": [ + "CIS M365 7.0.0 (4.1)" + ], + "impact": "Low Impact", + "helpText": "Sets the mark devices with no compliance policy assigned as compliance/non compliant and Compliance status validity period.", + "executiveText": "Configures how the system treats devices that don't have specific compliance policies and sets how often devices must check in to maintain their compliance status. This ensures proper security oversight of all corporate devices and maintains current compliance information.", + "impactColour": "info", + "addedDate": "2024-11-12", + "powershellEquivalent": "Graph API", + "appliesToTest": [ + "CIS_4_1" + ], + "recommendedBy": [], + "requiredCapabilities": [ + "INTUNE_A", + "MDM_Services", + "EMS", + "SCCM", + "MICROSOFTINTUNEPLAN1" + ], + "secureScoreImpact": 0, + "compare": "subset", + "variables": { + "secureByDefault": { + "type": "autoComplete", + "label": "Mark devices with no compliance policy as", + "required": true, + "recommended": true, + "options": [ + { + "label": "Compliant", + "value": false + }, + { + "label": "Non-Compliant", + "value": true + } + ] + }, + "deviceComplianceCheckinThresholdDays": { + "type": "number", + "label": "Compliance status validity period (days)", + "default": 120 + } + }, + "expected": { + "secureByDefault": "%secureByDefault%", + "deviceComplianceCheckinThresholdDays": "%deviceComplianceCheckinThresholdDays%" + }, + "read": { + "cacheType": "IntuneDeviceManagementSettings" + }, + "remediate": { + "executor": "GraphRequest", + "requests": [ + { + "method": "PATCH", + "uri": "deviceManagement", + "body": { + "settings": { + "secureByDefault": "%secureByDefault%", + "deviceComplianceCheckinThresholdDays": "%deviceComplianceCheckinThresholdDays%" + } + } + } + ] + } +} diff --git a/backend/Config/BaselineStandards/Intune Standards/IntuneWindowsDiagnostic.json b/backend/Config/BaselineStandards/Intune Standards/IntuneWindowsDiagnostic.json new file mode 100644 index 0000000000..f73395f9b8 --- /dev/null +++ b/backend/Config/BaselineStandards/Intune Standards/IntuneWindowsDiagnostic.json @@ -0,0 +1,57 @@ +{ + "name": "IntuneWindowsDiagnostic", + "label": "Set Intune Windows diagnostic data settings", + "cat": "Intune Standards", + "tag": [], + "impact": "Low Impact", + "helpText": "**Some features require Windows E3 or equivalent licenses** Configures Windows diagnostic data settings for Intune. Enables features like Windows update reports, device readiness reports, and driver update reports. More information can be found in [Microsoft's documentation.](https://go.microsoft.com/fwlink/?linkid=2204384)", + "executiveText": "Enables access to Windows Update reporting and compatibility analysis features in Intune by allowing the use of Windows diagnostic data. This unlocks important capabilities like device readiness reports for feature updates, driver update reports, and proactive alerts for update failures, helping IT teams plan and monitor Windows updates more effectively across the organization.", + "docsDescription": "Enables Windows diagnostic data in processor configuration for your Intune tenant. This setting is required for several Intune features including Windows feature update device readiness reports, compatibility risk reports, driver update reports, and update policy alerts. When enabled, your organization becomes the controller of Windows diagnostic data collected from managed devices, allowing Intune to use this data for reporting and update management features. More information can be found in [Microsoft's documentation.](https://go.microsoft.com/fwlink/?linkid=2204384)", + "impactColour": "info", + "addedDate": "2026-01-27", + "powershellEquivalent": "Graph API", + "recommendedBy": [], + "requiredCapabilities": [ + "INTUNE_A", + "MDM_Services", + "EMS", + "SCCM", + "MICROSOFTINTUNEPLAN1" + ], + "secureScoreImpact": 0, + "compare": "subset", + "variables": { + "areDataProcessorServiceForWindowsFeaturesEnabled": { + "type": "switch", + "label": "Enable Windows data", + "default": false + }, + "hasValidWindowsLicense": { + "type": "switch", + "label": "Confirm ownership of the required Windows E3 or equivalent licenses (Enables Windows update app and driver compatibility reports)", + "default": false + } + }, + "expected": { + "areDataProcessorServiceForWindowsFeaturesEnabled": "%areDataProcessorServiceForWindowsFeaturesEnabled%", + "hasValidWindowsLicense": "%hasValidWindowsLicense%" + }, + "read": { + "cacheType": "IntuneDataProcessorOnboarding" + }, + "remediate": { + "executor": "GraphRequest", + "requests": [ + { + "method": "PATCH", + "uri": "deviceManagement/dataProcessorServiceForWindowsFeaturesOnboarding", + "body": { + "value": { + "areDataProcessorServiceForWindowsFeaturesEnabled": "%areDataProcessorServiceForWindowsFeaturesEnabled%", + "hasValidWindowsLicense": "%hasValidWindowsLicense%" + } + } + } + ] + } +} diff --git a/backend/Config/BaselineStandards/Intune Standards/MDMEnrollmentDuringRegistration.json b/backend/Config/BaselineStandards/Intune Standards/MDMEnrollmentDuringRegistration.json new file mode 100644 index 0000000000..e0efcddefb --- /dev/null +++ b/backend/Config/BaselineStandards/Intune Standards/MDMEnrollmentDuringRegistration.json @@ -0,0 +1,52 @@ +{ + "name": "MDMEnrollmentDuringRegistration", + "label": "Configure MDM enrollment when adding work or school account", + "cat": "Intune Standards", + "tag": [], + "impact": "Medium Impact", + "helpText": "Controls the \"Allow my organization to manage my device\" prompt when adding a work or school account on Windows. This setting determines whether automatic MDM enrollment occurs during account registration.", + "executiveText": "Controls automatic device management enrollment during work account setup. When disabled, users can add work accounts to their Windows devices without the prompt asking to allow organizational device management, preventing unintended MDM enrollments on personal or BYOD devices.", + "docsDescription": "Controls whether Windows shows the \"Allow my organization to manage my device\" prompt when users add a work or school account. When set to disabled, this setting prevents automatic MDM enrollment during the account registration flow, separating account registration from device enrollment. This is useful for environments where you want to allow users to add work accounts without triggering MDM enrollment.", + "impactColour": "warning", + "addedDate": "2025-12-15", + "powershellEquivalent": "Graph API", + "recommendedBy": [], + "requiredCapabilities": [ + "INTUNE_A", + "MDM_Services", + "EMS", + "SCCM", + "MICROSOFTINTUNEPLAN1" + ], + "secureScoreImpact": 0, + "compare": "subset", + "variables": { + "disableEnrollment": { + "type": "switch", + "label": "Disable MDM enrollment during registration", + "default": false + } + }, + "expected": { + "isMdmEnrollmentDuringRegistrationDisabled": "%disableEnrollment%" + }, + "read": { + "cacheType": "MobileDeviceManagementPolicies", + "defaults": { + "isMdmEnrollmentDuringRegistrationDisabled": false + } + }, + "remediate": { + "executor": "GraphRequest", + "requests": [ + { + "method": "PATCH", + "asApp": false, + "uri": "policies/mobileDeviceManagementPolicies/0000000a-0000-0000-c000-000000000000", + "body": { + "isMdmEnrollmentDuringRegistrationDisabled": "%disableEnrollment%" + } + } + ] + } +} diff --git a/backend/Config/BaselineStandards/SharePoint Standards/DisableUserSiteCreate.json b/backend/Config/BaselineStandards/SharePoint Standards/DisableUserSiteCreate.json new file mode 100644 index 0000000000..ee47b1a703 --- /dev/null +++ b/backend/Config/BaselineStandards/SharePoint Standards/DisableUserSiteCreate.json @@ -0,0 +1,50 @@ +{ + "name": "DisableUserSiteCreate", + "label": "Disable site creation by standard users", + "cat": "SharePoint Standards", + "tag": [ + "SMB1001 (2.8)" + ], + "impact": "High Impact", + "helpText": "Disables users from creating new SharePoint sites", + "executiveText": "Restricts the creation of new SharePoint sites to authorized administrators, preventing uncontrolled proliferation of collaboration spaces and ensuring proper governance. This maintains organized information architecture while requiring approval for new collaborative environments.", + "docsDescription": "Disables standard users from creating SharePoint sites, also disables the ability to fully create teams", + "impactColour": "danger", + "addedDate": "2022-06-15", + "powershellEquivalent": "Update-MgAdminSharePointSetting", + "appliesToTest": [ + "SMB1001_2_8" + ], + "recommendedBy": [], + "requiredCapabilities": [ + "SHAREPOINTWAC", + "SHAREPOINTSTANDARD", + "SHAREPOINTENTERPRISE", + "SHAREPOINTENTERPRISE_EDU", + "ONEDRIVE_BASIC", + "ONEDRIVE_ENTERPRISE" + ], + "secureScoreImpact": 0, + "compare": "subset", + "variables": {}, + "expected": { + "isSiteCreationEnabled": false, + "isSiteCreationUIEnabled": false + }, + "read": { + "cacheType": "SharePointAdminSettings" + }, + "remediate": { + "executor": "GraphRequest", + "requests": [ + { + "method": "PATCH", + "uri": "admin/sharepoint/settings", + "body": { + "isSiteCreationEnabled": false, + "isSiteCreationUIEnabled": false + } + } + ] + } +} diff --git a/backend/Config/BaselineStandards/SharePoint Standards/TenantDefaultTimezone.json b/backend/Config/BaselineStandards/SharePoint Standards/TenantDefaultTimezone.json new file mode 100644 index 0000000000..9a5d1353f5 --- /dev/null +++ b/backend/Config/BaselineStandards/SharePoint Standards/TenantDefaultTimezone.json @@ -0,0 +1,494 @@ +{ + "name": "TenantDefaultTimezone", + "label": "Set Default Timezone for Tenant", + "cat": "SharePoint Standards", + "tag": [], + "impact": "Low Impact", + "helpText": "Sets the default timezone for the tenant. This will be used for all new users and sites.", + "executiveText": "Standardizes the timezone setting across all SharePoint sites and new user accounts, ensuring consistent scheduling and time-based operations throughout the organization. This improves collaboration efficiency and reduces confusion in global or multi-timezone organizations.", + "impactColour": "info", + "addedDate": "2024-04-20", + "powershellEquivalent": "Update-MgBetaAdminSharePointSetting", + "recommendedBy": [], + "requiredCapabilities": [ + "SHAREPOINTWAC", + "SHAREPOINTSTANDARD", + "SHAREPOINTENTERPRISE", + "SHAREPOINTENTERPRISE_EDU", + "ONEDRIVE_BASIC", + "ONEDRIVE_ENTERPRISE" + ], + "secureScoreImpact": 0, + "compare": "subset", + "variables": { + "Timezone": { + "type": "autoComplete", + "label": "Timezone", + "required": true, + "options": [ + { + "label": "(UTC-12:00) International Date Line West", + "value": "(UTC-12:00) International Date Line West" + }, + { + "label": "(UTC-11:00) Coordinated Universal Time-11", + "value": "(UTC-11:00) Coordinated Universal Time-11" + }, + { + "label": "(UTC-10:00) Hawaii", + "value": "(UTC-10:00) Hawaii" + }, + { + "label": "(UTC-09:00) Alaska", + "value": "(UTC-09:00) Alaska" + }, + { + "label": "(UTC-08:00) Baja California", + "value": "(UTC-08:00) Baja California" + }, + { + "label": "(UTC-08:00) Pacific Time (US and Canada)", + "value": "(UTC-08:00) Pacific Time (US and Canada)" + }, + { + "label": "(UTC-07:00) Arizona", + "value": "(UTC-07:00) Arizona" + }, + { + "label": "(UTC-07:00) Chihuahua, La Paz, Mazatlan", + "value": "(UTC-07:00) Chihuahua, La Paz, Mazatlan" + }, + { + "label": "(UTC-07:00) Mountain Time (US and Canada)", + "value": "(UTC-07:00) Mountain Time (US and Canada)" + }, + { + "label": "(UTC-06:00) Central America", + "value": "(UTC-06:00) Central America" + }, + { + "label": "(UTC-06:00) Central Time (US and Canada)", + "value": "(UTC-06:00) Central Time (US and Canada)" + }, + { + "label": "(UTC-06:00) Guadalajara, Mexico City, Monterrey", + "value": "(UTC-06:00) Guadalajara, Mexico City, Monterrey" + }, + { + "label": "(UTC-06:00) Saskatchewan", + "value": "(UTC-06:00) Saskatchewan" + }, + { + "label": "(UTC-05:00) Bogota, Lima, Quito", + "value": "(UTC-05:00) Bogota, Lima, Quito" + }, + { + "label": "(UTC-05:00) Eastern Time (US and Canada)", + "value": "(UTC-05:00) Eastern Time (US and Canada)" + }, + { + "label": "(UTC-05:00) Indiana (East)", + "value": "(UTC-05:00) Indiana (East)" + }, + { + "label": "(UTC-04:30) Caracas", + "value": "(UTC-04:30) Caracas" + }, + { + "label": "(UTC-04:00) Asuncion", + "value": "(UTC-04:00) Asuncion" + }, + { + "label": "(UTC-04:00) Atlantic Time (Canada)", + "value": "(UTC-04:00) Atlantic Time (Canada)" + }, + { + "label": "(UTC-04:00) Cuiaba", + "value": "(UTC-04:00) Cuiaba" + }, + { + "label": "(UTC-04:00) Georgetown, La Paz, Manaus, San Juan", + "value": "(UTC-04:00) Georgetown, La Paz, Manaus, San Juan" + }, + { + "label": "(UTC-04:00) Santiago", + "value": "(UTC-04:00) Santiago" + }, + { + "label": "(UTC-03:30) Newfoundland", + "value": "(UTC-03:30) Newfoundland" + }, + { + "label": "(UTC-03:00) Brasilia", + "value": "(UTC-03:00) Brasilia" + }, + { + "label": "(UTC-03:00) Buenos Aires", + "value": "(UTC-03:00) Buenos Aires" + }, + { + "label": "(UTC-03:00) Cayenne, Fortaleza", + "value": "(UTC-03:00) Cayenne, Fortaleza" + }, + { + "label": "(UTC-03:00) Greenland", + "value": "(UTC-03:00) Greenland" + }, + { + "label": "(UTC-03:00) Montevideo", + "value": "(UTC-03:00) Montevideo" + }, + { + "label": "(UTC-03:00) Salvador", + "value": "(UTC-03:00) Salvador" + }, + { + "label": "(UTC-02:00) Coordinated Universal Time-02", + "value": "(UTC-02:00) Coordinated Universal Time-02" + }, + { + "label": "(UTC-02:00) Mid-Atlantic", + "value": "(UTC-02:00) Mid-Atlantic" + }, + { + "label": "(UTC-01:00) Azores", + "value": "(UTC-01:00) Azores" + }, + { + "label": "(UTC-01:00) Cabo Verde", + "value": "(UTC-01:00) Cabo Verde" + }, + { + "label": "(UTC) Casablanca", + "value": "(UTC) Casablanca" + }, + { + "label": "(UTC) Coordinated Universal Time", + "value": "(UTC) Coordinated Universal Time" + }, + { + "label": "(UTC) Dublin, Edinburgh, Lisbon, London", + "value": "(UTC) Dublin, Edinburgh, Lisbon, London" + }, + { + "label": "(UTC) Monrovia, Reykjavik", + "value": "(UTC) Monrovia, Reykjavik" + }, + { + "label": "(UTC+01:00) Amsterdam, Berlin, Bern, Rome, Stockholm, Vienna", + "value": "(UTC+01:00) Amsterdam, Berlin, Bern, Rome, Stockholm, Vienna" + }, + { + "label": "(UTC+01:00) Belgrade, Bratislava, Budapest, Ljubljana, Prague", + "value": "(UTC+01:00) Belgrade, Bratislava, Budapest, Ljubljana, Prague" + }, + { + "label": "(UTC+01:00) Brussels, Copenhagen, Madrid, Paris", + "value": "(UTC+01:00) Brussels, Copenhagen, Madrid, Paris" + }, + { + "label": "(UTC+01:00) Sarajevo, Skopje, Warsaw, Zagreb", + "value": "(UTC+01:00) Sarajevo, Skopje, Warsaw, Zagreb" + }, + { + "label": "(UTC+01:00) West Central Africa", + "value": "(UTC+01:00) West Central Africa" + }, + { + "label": "(UTC+01:00) Windhoek", + "value": "(UTC+01:00) Windhoek" + }, + { + "label": "(UTC+02:00) Amman", + "value": "(UTC+02:00) Amman" + }, + { + "label": "(UTC+02:00) Athens, Bucharest", + "value": "(UTC+02:00) Athens, Bucharest" + }, + { + "label": "(UTC+02:00) Beirut", + "value": "(UTC+02:00) Beirut" + }, + { + "label": "(UTC+02:00) Cairo", + "value": "(UTC+02:00) Cairo" + }, + { + "label": "(UTC+02:00) Damascus", + "value": "(UTC+02:00) Damascus" + }, + { + "label": "(UTC+02:00) Harare, Pretoria", + "value": "(UTC+02:00) Harare, Pretoria" + }, + { + "label": "(UTC+02:00) Helsinki, Kyiv, Riga, Sofia, Tallinn, Vilnius", + "value": "(UTC+02:00) Helsinki, Kyiv, Riga, Sofia, Tallinn, Vilnius" + }, + { + "label": "(UTC+02:00) Jerusalem", + "value": "(UTC+02:00) Jerusalem" + }, + { + "label": "(UTC+02:00) Minsk (old)", + "value": "(UTC+02:00) Minsk (old)" + }, + { + "label": "(UTC+02:00) E. Europe", + "value": "(UTC+02:00) E. Europe" + }, + { + "label": "(UTC+02:00) Kaliningrad", + "value": "(UTC+02:00) Kaliningrad" + }, + { + "label": "(UTC+03:00) Baghdad", + "value": "(UTC+03:00) Baghdad" + }, + { + "label": "(UTC+03:00) Istanbul", + "value": "(UTC+03:00) Istanbul" + }, + { + "label": "(UTC+03:00) Kuwait, Riyadh", + "value": "(UTC+03:00) Kuwait, Riyadh" + }, + { + "label": "(UTC+03:00) Minsk", + "value": "(UTC+03:00) Minsk" + }, + { + "label": "(UTC+03:00) Moscow, St. Petersburg, Volgograd", + "value": "(UTC+03:00) Moscow, St. Petersburg, Volgograd" + }, + { + "label": "(UTC+03:00) Nairobi", + "value": "(UTC+03:00) Nairobi" + }, + { + "label": "(UTC+03:30) Tehran", + "value": "(UTC+03:30) Tehran" + }, + { + "label": "(UTC+04:00) Abu Dhabi, Muscat", + "value": "(UTC+04:00) Abu Dhabi, Muscat" + }, + { + "label": "(UTC+04:00) Astrakhan, Ulyanovsk", + "value": "(UTC+04:00) Astrakhan, Ulyanovsk" + }, + { + "label": "(UTC+04:00) Baku", + "value": "(UTC+04:00) Baku" + }, + { + "label": "(UTC+04:00) Izhevsk, Samara", + "value": "(UTC+04:00) Izhevsk, Samara" + }, + { + "label": "(UTC+04:00) Port Louis", + "value": "(UTC+04:00) Port Louis" + }, + { + "label": "(UTC+04:00) Tbilisi", + "value": "(UTC+04:00) Tbilisi" + }, + { + "label": "(UTC+04:00) Yerevan", + "value": "(UTC+04:00) Yerevan" + }, + { + "label": "(UTC+04:30) Kabul", + "value": "(UTC+04:30) Kabul" + }, + { + "label": "(UTC+05:00) Ekaterinburg", + "value": "(UTC+05:00) Ekaterinburg" + }, + { + "label": "(UTC+05:00) Islamabad, Karachi", + "value": "(UTC+05:00) Islamabad, Karachi" + }, + { + "label": "(UTC+05:00) Tashkent", + "value": "(UTC+05:00) Tashkent" + }, + { + "label": "(UTC+05:30) Chennai, Kolkata, Mumbai, New Delhi", + "value": "(UTC+05:30) Chennai, Kolkata, Mumbai, New Delhi" + }, + { + "label": "(UTC+05:30) Sri Jayawardenepura", + "value": "(UTC+05:30) Sri Jayawardenepura" + }, + { + "label": "(UTC+05:45) Kathmandu", + "value": "(UTC+05:45) Kathmandu" + }, + { + "label": "(UTC+06:00) Astana", + "value": "(UTC+06:00) Astana" + }, + { + "label": "(UTC+06:00) Dhaka", + "value": "(UTC+06:00) Dhaka" + }, + { + "label": "(UTC+06:00) Omsk", + "value": "(UTC+06:00) Omsk" + }, + { + "label": "(UTC+06:30) Yangon (Rangoon)", + "value": "(UTC+06:30) Yangon (Rangoon)" + }, + { + "label": "(UTC+07:00) Bangkok, Hanoi, Jakarta", + "value": "(UTC+07:00) Bangkok, Hanoi, Jakarta" + }, + { + "label": "(UTC+07:00) Barnaul, Gorno-Altaysk", + "value": "(UTC+07:00) Barnaul, Gorno-Altaysk" + }, + { + "label": "(UTC+07:00) Krasnoyarsk", + "value": "(UTC+07:00) Krasnoyarsk" + }, + { + "label": "(UTC+07:00) Novosibirsk", + "value": "(UTC+07:00) Novosibirsk" + }, + { + "label": "(UTC+07:00) Tomsk", + "value": "(UTC+07:00) Tomsk" + }, + { + "label": "(UTC+08:00) Beijing, Chongqing, Hong Kong, Urumqi", + "value": "(UTC+08:00) Beijing, Chongqing, Hong Kong, Urumqi" + }, + { + "label": "(UTC+08:00) Irkutsk", + "value": "(UTC+08:00) Irkutsk" + }, + { + "label": "(UTC+08:00) Kuala Lumpur, Singapore", + "value": "(UTC+08:00) Kuala Lumpur, Singapore" + }, + { + "label": "(UTC+08:00) Perth", + "value": "(UTC+08:00) Perth" + }, + { + "label": "(UTC+08:00) Taipei", + "value": "(UTC+08:00) Taipei" + }, + { + "label": "(UTC+08:00) Ulaanbaatar", + "value": "(UTC+08:00) Ulaanbaatar" + }, + { + "label": "(UTC+09:00) Osaka, Sapporo, Tokyo", + "value": "(UTC+09:00) Osaka, Sapporo, Tokyo" + }, + { + "label": "(UTC+09:00) Seoul", + "value": "(UTC+09:00) Seoul" + }, + { + "label": "(UTC+09:00) Yakutsk", + "value": "(UTC+09:00) Yakutsk" + }, + { + "label": "(UTC+09:30) Adelaide", + "value": "(UTC+09:30) Adelaide" + }, + { + "label": "(UTC+09:30) Darwin", + "value": "(UTC+09:30) Darwin" + }, + { + "label": "(UTC+10:00) Brisbane", + "value": "(UTC+10:00) Brisbane" + }, + { + "label": "(UTC+10:00) Canberra, Melbourne, Sydney", + "value": "(UTC+10:00) Canberra, Melbourne, Sydney" + }, + { + "label": "(UTC+10:00) Guam, Port Moresby", + "value": "(UTC+10:00) Guam, Port Moresby" + }, + { + "label": "(UTC+10:00) Hobart", + "value": "(UTC+10:00) Hobart" + }, + { + "label": "(UTC+10:00) Magadan", + "value": "(UTC+10:00) Magadan" + }, + { + "label": "(UTC+10:00) Vladivostok", + "value": "(UTC+10:00) Vladivostok" + }, + { + "label": "(UTC+11:00) Chokurdakh", + "value": "(UTC+11:00) Chokurdakh" + }, + { + "label": "(UTC+11:00) Sakhalin", + "value": "(UTC+11:00) Sakhalin" + }, + { + "label": "(UTC+11:00) Solomon Is., New Caledonia", + "value": "(UTC+11:00) Solomon Is., New Caledonia" + }, + { + "label": "(UTC+12:00) Anadyr, Petropavlovsk-Kamchatsky", + "value": "(UTC+12:00) Anadyr, Petropavlovsk-Kamchatsky" + }, + { + "label": "(UTC+12:00) Auckland, Wellington", + "value": "(UTC+12:00) Auckland, Wellington" + }, + { + "label": "(UTC+12:00) Coordinated Universal Time+12", + "value": "(UTC+12:00) Coordinated Universal Time+12" + }, + { + "label": "(UTC+12:00) Fiji", + "value": "(UTC+12:00) Fiji" + }, + { + "label": "(UTC+12:00) Petropavlovsk-Kamchatsky - Old", + "value": "(UTC+12:00) Petropavlovsk-Kamchatsky - Old" + }, + { + "label": "(UTC+13:00) Nuku'alofa", + "value": "(UTC+13:00) Nuku'alofa" + }, + { + "label": "(UTC+13:00) Samoa", + "value": "(UTC+13:00) Samoa" + } + ] + } + }, + "expected": { + "tenantDefaultTimezone": "%Timezone%" + }, + "read": { + "cacheType": "SharePointAdminSettings" + }, + "remediate": { + "executor": "GraphRequest", + "requests": [ + { + "method": "PATCH", + "uri": "admin/sharepoint/settings", + "body": { + "tenantDefaultTimezone": "%Timezone%" + } + } + ] + } +} From ebbc9dbe9416a9a6c1ee019c64a8a74ec8e360d5 Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Sat, 15 Aug 2026 20:13:29 +0200 Subject: [PATCH 066/226] more conversions --- .../CopilotSettings.json | 188 ++++++++++++++++++ .../QuarantineRequestAlert.json | 75 +++++++ .../SharePointMassDeletionAlert.json | 87 ++++++++ .../Global Standards/Branding.json | 125 ++++++++++++ .../EnableNamePronunciation.json | 37 ++++ .../intuneBrandingProfile.json | 157 +++++++++++++++ .../SharePoint Standards/SPFileRequests.json | 68 +++++++ .../Invoke-CIPPBaselineExoRequest.ps1 | 11 +- .../Invoke-CIPPBaselineGraphRequest.ps1 | 9 +- .../Public/Compare-CIPPIntuneObject.ps1 | 3 +- .../Set-CIPPDBCacheCopilotPolicySettings.ps1 | 47 +++-- 11 files changed, 780 insertions(+), 27 deletions(-) create mode 100644 backend/Config/BaselineStandards/Copilot (M365) Standards/CopilotSettings.json create mode 100644 backend/Config/BaselineStandards/Defender Standards/QuarantineRequestAlert.json create mode 100644 backend/Config/BaselineStandards/Defender Standards/SharePointMassDeletionAlert.json create mode 100644 backend/Config/BaselineStandards/Global Standards/Branding.json create mode 100644 backend/Config/BaselineStandards/Global Standards/EnableNamePronunciation.json create mode 100644 backend/Config/BaselineStandards/Intune Standards/intuneBrandingProfile.json create mode 100644 backend/Config/BaselineStandards/SharePoint Standards/SPFileRequests.json diff --git a/backend/Config/BaselineStandards/Copilot (M365) Standards/CopilotSettings.json b/backend/Config/BaselineStandards/Copilot (M365) Standards/CopilotSettings.json new file mode 100644 index 0000000000..43e2a9d63a --- /dev/null +++ b/backend/Config/BaselineStandards/Copilot (M365) Standards/CopilotSettings.json @@ -0,0 +1,188 @@ +{ + "name": "CopilotSettings", + "label": "Configure Microsoft 365 Copilot policy settings", + "cat": "Copilot (M365) Standards", + "tag": [], + "impact": "Medium Impact", + "helpText": "Configures Microsoft 365 Copilot tenant policy settings: Copilot Chat pinning, blocking Copilot access to open content, Designer image generation, web search, and admin-center Copilot. Each setting can be left unconfigured, enabled, or disabled. These settings are managed through the Copilot policy service (Cloud Policy / Intune) and are applied at the tenant level.", + "executiveText": "Provides centralized governance of Microsoft 365 Copilot capabilities across the organization. Administrators can control whether Copilot Chat is pinned for users, whether Copilot can access open files, and whether features such as image generation and web search are available, helping balance employee productivity with data governance and compliance requirements.", + "docsDescription": "Manages Microsoft 365 Copilot admin policy settings via the `/copilot/admin/policySettings` Microsoft Graph API (beta). Each of the five supported settings can be independently set or left unmanaged using the \"Do not configure\" option - an unconfigured setting is neither graded nor written. NOTE: this API currently requires delegated authentication and supports only tenant-level policies; settings scoped to group-level policies return an error and are skipped. Values are strings whose meaning is per-setting, not uniform: web search is three-state (\"0\" enabled everywhere, \"1\" disabled everywhere, \"2\" disabled in Copilot Work mode only) and Designer image generation is inverted (\"1\" disables it, \"0\" enables it). Graph treats these as opaque strings and validates nothing, so do not assume 1=on/0=off for a setting you have not verified against a Copilot-licensed tenant.", + "impactColour": "warning", + "addedDate": "2026-06-09", + "powershellEquivalent": "Graph API: PATCH /beta/copilot/admin/policySettings/{id}", + "recommendedBy": [], + "requiredCapabilities": [], + "secureScoreImpact": 0, + "compare": "subset", + "variables": { + "copilotChatPinning": { + "type": "autoComplete", + "multiple": false, + "creatable": false, + "label": "Pin Microsoft 365 Copilot Chat", + "omitWhenBlank": true, + "options": [ + { + "label": "Do not configure", + "value": "" + }, + { + "label": "Enabled", + "value": "1" + }, + { + "label": "Disabled", + "value": "0" + } + ], + "default": "" + }, + "blockAccessToOpenFiles": { + "type": "autoComplete", + "multiple": false, + "creatable": false, + "label": "Copilot Access to Open Content", + "omitWhenBlank": true, + "options": [ + { + "label": "Do not configure", + "value": "" + }, + { + "label": "Block open content", + "value": "1" + }, + { + "label": "Allow open content", + "value": "0" + } + ], + "default": "" + }, + "imageGeneration": { + "type": "autoComplete", + "multiple": false, + "creatable": false, + "label": "Designer Image Generation", + "omitWhenBlank": true, + "options": [ + { + "label": "Do not configure", + "value": "" + }, + { + "label": "Disabled", + "value": "1" + }, + { + "label": "Enabled", + "value": "0" + } + ], + "default": "" + }, + "allowWebSearch": { + "type": "autoComplete", + "multiple": false, + "creatable": false, + "label": "Web Search in Copilot", + "omitWhenBlank": true, + "options": [ + { + "label": "Do not configure", + "value": "" + }, + { + "label": "Enabled in Microsoft 365 Copilot and Microsoft 365 Copilot Chat", + "value": "0" + }, + { + "label": "Disabled in Microsoft 365 Copilot and Microsoft 365 Copilot Chat", + "value": "1" + }, + { + "label": "Disabled in Microsoft 365 Copilot Work mode, Enabled in Microsoft 365 Copilot Chat", + "value": "2" + } + ], + "default": "" + }, + "allowInAdminCenters": { + "type": "autoComplete", + "multiple": false, + "creatable": false, + "label": "Admin Copilot in Microsoft 365 Admin Center", + "omitWhenBlank": true, + "options": [ + { + "label": "Do not configure", + "value": "" + }, + { + "label": "Enabled", + "value": "1" + }, + { + "label": "Disabled", + "value": "0" + } + ], + "default": "" + } + }, + "expected": { + "copilotChatPinning": "%copilotChatPinning%", + "blockAccessToOpenFiles": "%blockAccessToOpenFiles%", + "imageGeneration": "%imageGeneration%", + "allowWebSearch": "%allowWebSearch%", + "allowInAdminCenters": "%allowInAdminCenters%" + }, + "read": { + "cacheType": "CopilotPolicySettings" + }, + "remediate": { + "executor": "GraphRequest", + "requests": [ + { + "method": "PATCH", + "uri": "copilot/admin/policySettings/microsoft.copilot.copilotchatpinning", + "asApp": false, + "body": { + "value": "%copilotChatPinning%" + } + }, + { + "method": "PATCH", + "uri": "copilot/admin/policySettings/microsoft.copilot.blockaccesstoopenfiles", + "asApp": false, + "body": { + "value": "%blockAccessToOpenFiles%" + } + }, + { + "method": "PATCH", + "uri": "copilot/admin/policySettings/microsoft.copilot.imagegeneration", + "asApp": false, + "body": { + "value": "%imageGeneration%" + } + }, + { + "method": "PATCH", + "uri": "copilot/admin/policySettings/microsoft.copilot.allowwebsearch", + "asApp": false, + "body": { + "value": "%allowWebSearch%" + } + }, + { + "method": "PATCH", + "uri": "copilot/admin/policySettings/microsoft.copilot.allowinadmincenters", + "asApp": false, + "body": { + "value": "%allowInAdminCenters%" + } + } + ] + } +} diff --git a/backend/Config/BaselineStandards/Defender Standards/QuarantineRequestAlert.json b/backend/Config/BaselineStandards/Defender Standards/QuarantineRequestAlert.json new file mode 100644 index 0000000000..193114fb75 --- /dev/null +++ b/backend/Config/BaselineStandards/Defender Standards/QuarantineRequestAlert.json @@ -0,0 +1,75 @@ +{ + "name": "QuarantineRequestAlert", + "label": "Quarantine Release Request Alert", + "cat": "Defender Standards", + "tag": [], + "impact": "Low Impact", + "helpText": "Sets a e-mail address to alert when a User requests to release a quarantined message.", + "executiveText": "Notifies IT administrators when employees request to release emails that were quarantined for security reasons, enabling oversight of potentially dangerous messages. This helps ensure that legitimate emails are released while maintaining security controls over suspicious content.", + "docsDescription": "Sets a e-mail address to alert when a User requests to release a quarantined message. This is useful for monitoring and ensuring that the correct messages are released.", + "impactColour": "info", + "addedDate": "2024-07-15", + "powershellEquivalent": "New-ProtectionAlert and Set-ProtectionAlert", + "recommendedBy": [], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ], + "secureScoreImpact": 0, + "compare": "subset", + "variables": { + "NotifyUser": { + "type": "textField", + "label": "E-mail to receive the alert", + "required": true + } + }, + "expected": { + "NotifyUser": [ + "%NotifyUser%" + ] + }, + "read": { + "cacheType": "ExoProtectionAlert", + "filter": [ + { + "property": "Name", + "value": "CIPP User requested to release a quarantined message" + } + ] + }, + "remediate": { + "executor": "ExoRequest", + "cmdlets": [ + { + "cmdlet": "New-ProtectionAlert", + "compliance": true, + "continueOnError": true, + "params": { + "Name": "CIPP User requested to release a quarantined message", + "ThreatType": "Activity", + "Category": "ThreatManagement", + "Operation": "QuarantineRequestReleaseMessage", + "Severity": "Informational", + "AggregationType": "None", + "NotifyUser": "%NotifyUser%" + } + }, + { + "cmdlet": "Set-ProtectionAlert", + "compliance": true, + "params": { + "Identity": "CIPP User requested to release a quarantined message", + "Category": "ThreatManagement", + "Operation": "QuarantineRequestReleaseMessage", + "Severity": "Informational", + "AggregationType": "None", + "NotifyUser": "%NotifyUser%" + } + } + ] + } +} diff --git a/backend/Config/BaselineStandards/Defender Standards/SharePointMassDeletionAlert.json b/backend/Config/BaselineStandards/Defender Standards/SharePointMassDeletionAlert.json new file mode 100644 index 0000000000..d2fe941eb9 --- /dev/null +++ b/backend/Config/BaselineStandards/Defender Standards/SharePointMassDeletionAlert.json @@ -0,0 +1,87 @@ +{ + "name": "SharePointMassDeletionAlert", + "label": "SharePoint Mass Deletion Alert", + "cat": "Defender Standards", + "tag": [], + "impact": "Low Impact", + "helpText": "Sets a e-mail address to alert when a User deletes more than 20 SharePoint files within 60 minutes. NB: Requires a Office 365 E5 subscription, Office 365 E3 with Threat Intelligence or Office 365 EquivioAnalytics add-on.", + "executiveText": "Alerts administrators when employees delete large numbers of SharePoint files in a short time period, helping detect potential data destruction attacks, ransomware, or accidental mass deletions. This early warning system enables rapid response to protect critical business documents and data.", + "docsDescription": "Sets a e-mail address to alert when a User deletes more than 20 SharePoint files within 60 minutes. This is useful for monitoring and ensuring that the correct SharePoint files are deleted. NB: Requires a Office 365 E5 subscription, Office 365 E3 with Threat Intelligence or Office 365 EquivioAnalytics add-on.", + "impactColour": "info", + "addedDate": "2025-04-07", + "powershellEquivalent": "New-ProtectionAlert and Set-ProtectionAlert", + "recommendedBy": [], + "requiredCapabilities": [ + "RMS_S_PREMIUM2" + ], + "secureScoreImpact": 0, + "compare": "subset", + "variables": { + "Threshold": { + "type": "number", + "label": "Max files to delete within the time frame", + "default": 20 + }, + "TimeWindow": { + "type": "number", + "label": "Time frame in minutes", + "default": 60 + }, + "NotifyUser": { + "type": "autoComplete", + "multiple": true, + "creatable": true, + "required": true, + "label": "E-mail to receive the alert" + } + }, + "expected": { + "Threshold": "%Threshold%", + "TimeWindow": "%TimeWindow%", + "NotifyUser": "%NotifyUser%" + }, + "read": { + "cacheType": "ExoProtectionAlert", + "filter": [ + { + "property": "Name", + "value": "CIPP SharePoint mass deletion of files by a user" + } + ] + }, + "remediate": { + "executor": "ExoRequest", + "cmdlets": [ + { + "cmdlet": "New-ProtectionAlert", + "compliance": true, + "continueOnError": true, + "params": { + "Name": "CIPP SharePoint mass deletion of files by a user", + "ThreatType": "Activity", + "Category": "DataGovernance", + "Operation": "FileDeleted", + "Severity": "High", + "AggregationType": "1", + "Threshold": "%Threshold%", + "TimeWindow": "%TimeWindow%", + "NotifyUser": "%NotifyUser%" + } + }, + { + "cmdlet": "Set-ProtectionAlert", + "compliance": true, + "params": { + "Identity": "CIPP SharePoint mass deletion of files by a user", + "Category": "DataGovernance", + "Operation": "FileDeleted", + "Severity": "High", + "AggregationType": "1", + "Threshold": "%Threshold%", + "TimeWindow": "%TimeWindow%", + "NotifyUser": "%NotifyUser%" + } + } + ] + } +} diff --git a/backend/Config/BaselineStandards/Global Standards/Branding.json b/backend/Config/BaselineStandards/Global Standards/Branding.json new file mode 100644 index 0000000000..1129a7ca25 --- /dev/null +++ b/backend/Config/BaselineStandards/Global Standards/Branding.json @@ -0,0 +1,125 @@ +{ + "name": "Branding", + "label": "Set branding for the tenant", + "cat": "Global Standards", + "tag": [], + "impact": "Low Impact", + "helpText": "Sets the branding for the tenant. This includes the login page, and the Office 365 portal.", + "executiveText": "Customizes Microsoft 365 login pages and portals with company branding, including logos, colors, and messaging. This creates a consistent corporate identity experience for employees and reinforces brand recognition while maintaining professional appearance across all Microsoft services.", + "docsDescription": "Sets the branding for the tenant. This includes the login page, and the Office 365 portal.", + "impactColour": "info", + "addedDate": "2024-05-13", + "powershellEquivalent": "Portal only", + "recommendedBy": [], + "requiredCapabilities": [ + "AAD_PREMIUM", + "AAD_PREMIUM_P2", + "OFFICE_BUSINESS" + ], + "secureScoreImpact": 0, + "compare": "subset", + "variables": { + "signInPageText": { + "type": "textField", + "label": "Sign-in page text", + "omitWhenBlank": true, + "default": "" + }, + "usernameHintText": { + "type": "textField", + "label": "Username hint Text", + "omitWhenBlank": true, + "default": "" + }, + "hideAccountResetCredentials": { + "type": "switch", + "label": "Hide self-service password reset", + "default": false + }, + "layoutTemplateType": { + "type": "autoComplete", + "multiple": false, + "label": "Visual Template", + "options": [ + { + "label": "Full-screen background", + "value": "default" + }, + { + "label": "Partial-screen background", + "value": "verticalSplit" + } + ], + "default": "default" + }, + "isHeaderShown": { + "type": "switch", + "label": "Show header", + "default": false + }, + "isFooterShown": { + "type": "switch", + "label": "Show footer", + "default": false + } + }, + "expected": { + "signInPageText": "%signInPageText%", + "usernameHintText": "%usernameHintText%", + "loginPageTextVisibilitySettings": { + "hideAccountResetCredentials": "%hideAccountResetCredentials%" + }, + "loginPageLayoutConfiguration": { + "layoutTemplateType": "%layoutTemplateType%", + "isHeaderShown": "%isHeaderShown%", + "isFooterShown": "%isFooterShown%" + } + }, + "read": { + "cacheType": "OrganizationBranding", + "filter": [ + { + "property": "id", + "value": "0" + } + ] + }, + "remediate": { + "executor": "GraphRequest", + "requests": [ + { + "method": "POST", + "uri": "organization/%tenantid%/branding/localizations", + "continueOnError": true, + "body": { + "signInPageText": "%signInPageText%", + "usernameHintText": "%usernameHintText%", + "loginPageTextVisibilitySettings": { + "hideAccountResetCredentials": "%hideAccountResetCredentials%" + }, + "loginPageLayoutConfiguration": { + "layoutTemplateType": "%layoutTemplateType%", + "isHeaderShown": "%isHeaderShown%", + "isFooterShown": "%isFooterShown%" + } + } + }, + { + "method": "PATCH", + "uri": "organization/%tenantid%/branding/localizations/0", + "body": { + "signInPageText": "%signInPageText%", + "usernameHintText": "%usernameHintText%", + "loginPageTextVisibilitySettings": { + "hideAccountResetCredentials": "%hideAccountResetCredentials%" + }, + "loginPageLayoutConfiguration": { + "layoutTemplateType": "%layoutTemplateType%", + "isHeaderShown": "%isHeaderShown%", + "isFooterShown": "%isFooterShown%" + } + } + } + ] + } +} diff --git a/backend/Config/BaselineStandards/Global Standards/EnableNamePronunciation.json b/backend/Config/BaselineStandards/Global Standards/EnableNamePronunciation.json new file mode 100644 index 0000000000..0ea10cc81f --- /dev/null +++ b/backend/Config/BaselineStandards/Global Standards/EnableNamePronunciation.json @@ -0,0 +1,37 @@ +{ + "name": "EnableNamePronunciation", + "label": "Enable Name Pronunciation", + "cat": "Global Standards", + "tag": [], + "impact": "Low Impact", + "helpText": "Enables the Name Pronunciation feature for the tenant. This allows users to set their name pronunciation in their profile.", + "executiveText": "Enables employees to add pronunciation guides for their names in Microsoft 365 profiles, improving communication and respect in diverse workplaces. This feature helps colleagues pronounce names correctly, enhancing professional relationships and inclusive culture.", + "docsDescription": "Enables the Name Pronunciation feature for the tenant. This allows users to set their name pronunciation in their profile.", + "impactColour": "info", + "addedDate": "2025-06-06", + "recommendedBy": [ + "CIPP" + ], + "requiredCapabilities": [], + "secureScoreImpact": 0, + "compare": "subset", + "variables": {}, + "expected": { + "isEnabledInOrganization": true + }, + "read": { + "cacheType": "NamePronunciation" + }, + "remediate": { + "executor": "GraphRequest", + "requests": [ + { + "method": "PATCH", + "uri": "admin/people/namePronunciation", + "body": { + "isEnabledInOrganization": true + } + } + ] + } +} diff --git a/backend/Config/BaselineStandards/Intune Standards/intuneBrandingProfile.json b/backend/Config/BaselineStandards/Intune Standards/intuneBrandingProfile.json new file mode 100644 index 0000000000..ebfe909156 --- /dev/null +++ b/backend/Config/BaselineStandards/Intune Standards/intuneBrandingProfile.json @@ -0,0 +1,157 @@ +{ + "name": "intuneBrandingProfile", + "label": "Set Intune Company Portal branding profile", + "cat": "Intune Standards", + "tag": [], + "impact": "Low Impact", + "helpText": "Sets the branding profile for the Intune Company Portal app. This is a tenant wide setting and overrules any settings set on the app level. Fields left blank are neither graded nor written - the tenant keeps whatever it has.", + "executiveText": "Customizes the Intune Company Portal app with company branding, contact information, and support details, providing employees with a consistent corporate experience when managing their devices. This improves user experience and ensures employees know how to get IT support when needed.", + "docsDescription": "Sets the branding profile for the Intune Company Portal app. This is a tenant wide setting and overrules any settings set on the app level.", + "impactColour": "info", + "addedDate": "2024-06-20", + "powershellEquivalent": "Graph API", + "recommendedBy": [], + "requiredCapabilities": [ + "INTUNE_A", + "MDM_Services", + "EMS", + "SCCM", + "MICROSOFTINTUNEPLAN1" + ], + "secureScoreImpact": 0, + "compare": "subset", + "variables": { + "displayName": { + "type": "textField", + "label": "Organization name", + "omitWhenBlank": true, + "default": "" + }, + "showLogo": { + "type": "autoComplete", + "multiple": false, + "label": "Show logo", + "omitWhenBlank": true, + "options": [ + { + "label": "Keep the tenant's current value", + "value": "" + }, + { + "label": "Show logo", + "value": true + }, + { + "label": "Hide logo", + "value": false + } + ], + "default": "" + }, + "showDisplayNameNextToLogo": { + "type": "autoComplete", + "multiple": false, + "label": "Show organization name next to logo", + "omitWhenBlank": true, + "options": [ + { + "label": "Keep the tenant's current value", + "value": "" + }, + { + "label": "Show organization name", + "value": true + }, + { + "label": "Hide organization name", + "value": false + } + ], + "default": "" + }, + "contactITName": { + "type": "textField", + "label": "Contact IT name", + "omitWhenBlank": true, + "default": "" + }, + "contactITPhoneNumber": { + "type": "textField", + "label": "Contact IT phone number", + "omitWhenBlank": true, + "default": "" + }, + "contactITEmailAddress": { + "type": "textField", + "label": "Contact IT email address", + "omitWhenBlank": true, + "default": "" + }, + "contactITNotes": { + "type": "textField", + "label": "Contact IT notes", + "omitWhenBlank": true, + "default": "" + }, + "onlineSupportSiteName": { + "type": "textField", + "label": "Online support site name", + "omitWhenBlank": true, + "default": "" + }, + "onlineSupportSiteUrl": { + "type": "textField", + "label": "Online support site URL", + "omitWhenBlank": true, + "default": "" + }, + "privacyUrl": { + "type": "textField", + "label": "Privacy statement URL", + "omitWhenBlank": true, + "default": "" + } + }, + "expected": { + "displayName": "%displayName%", + "showLogo": "%showLogo%", + "showDisplayNameNextToLogo": "%showDisplayNameNextToLogo%", + "contactITName": "%contactITName%", + "contactITPhoneNumber": "%contactITPhoneNumber%", + "contactITEmailAddress": "%contactITEmailAddress%", + "contactITNotes": "%contactITNotes%", + "onlineSupportSiteName": "%onlineSupportSiteName%", + "onlineSupportSiteUrl": "%onlineSupportSiteUrl%", + "privacyUrl": "%privacyUrl%" + }, + "read": { + "cacheType": "IntuneBrandingProfile", + "filter": [ + { + "property": "id", + "value": "c3a59481-1bf2-46ce-94b3-66eec07a8d60" + } + ] + }, + "remediate": { + "executor": "GraphRequest", + "requests": [ + { + "method": "PATCH", + "uri": "deviceManagement/intuneBrandingProfiles/c3a59481-1bf2-46ce-94b3-66eec07a8d60", + "body": { + "displayName": "%displayName%", + "showLogo": "%showLogo%", + "showDisplayNameNextToLogo": "%showDisplayNameNextToLogo%", + "contactITName": "%contactITName%", + "contactITPhoneNumber": "%contactITPhoneNumber%", + "contactITEmailAddress": "%contactITEmailAddress%", + "contactITNotes": "%contactITNotes%", + "onlineSupportSiteName": "%onlineSupportSiteName%", + "onlineSupportSiteUrl": "%onlineSupportSiteUrl%", + "privacyUrl": "%privacyUrl%" + } + } + ] + } +} diff --git a/backend/Config/BaselineStandards/SharePoint Standards/SPFileRequests.json b/backend/Config/BaselineStandards/SharePoint Standards/SPFileRequests.json new file mode 100644 index 0000000000..bb42f444ff --- /dev/null +++ b/backend/Config/BaselineStandards/SharePoint Standards/SPFileRequests.json @@ -0,0 +1,68 @@ +{ + "name": "SPFileRequests", + "label": "Set SharePoint and OneDrive File Requests", + "cat": "SharePoint Standards", + "tag": [], + "impact": "Medium Impact", + "helpText": "Enables or disables File Requests for SharePoint and OneDrive, allowing users to create secure upload-only links. Optionally sets the maximum number of days for the link to remain active before expiring. Leave Link Expiration blank to leave the tenant's current expiration untouched. Requires the tenant sharing level to be set to 'External Users and Guests (Anyone)'.", + "executiveText": "Enables secure file upload functionality that allows external users to submit files directly to company folders without seeing other submissions or folder contents. This provides a professional and secure way to collect documents from clients, vendors, and partners while maintaining data privacy and security.", + "docsDescription": "File Requests allow users to create secure upload-only share links where uploads are hidden from other people using the link. This creates a secure and private way for people to upload files to a folder. This feature is not enabled by default on new tenants and requires PowerShell configuration. This standard enables or disables this feature and optionally configures link expiration settings for both SharePoint and OneDrive.", + "impactColour": "warning", + "addedDate": "2025-07-30", + "powershellEquivalent": "Set-SPOTenant -CoreRequestFilesLinkEnabled $true -OneDriveRequestFilesLinkEnabled $true -CoreRequestFilesLinkExpirationInDays 30 -OneDriveRequestFilesLinkExpirationInDays 30", + "recommendedBy": [ + "CIPP" + ], + "requiredCapabilities": [ + "SHAREPOINTWAC", + "SHAREPOINTSTANDARD", + "SHAREPOINTENTERPRISE", + "SHAREPOINTENTERPRISE_EDU", + "SHAREPOINTENTERPRISE_GOV", + "ONEDRIVE_BASIC", + "ONEDRIVE_ENTERPRISE" + ], + "secureScoreImpact": 0, + "compare": "subset", + "variables": { + "state": { + "type": "switch", + "label": "Enable File Requests", + "default": false + }, + "expirationDays": { + "type": "number", + "label": "Link Expiration 1-730 Days (Optional)", + "omitWhenBlank": true, + "default": "", + "validators": { + "min": { + "value": 1, + "message": "Minimum value is 1" + }, + "max": { + "value": 730, + "message": "Maximum value is 730" + } + } + } + }, + "expected": { + "CoreRequestFilesLinkEnabled": "%state%", + "OneDriveRequestFilesLinkEnabled": "%state%", + "CoreRequestFilesLinkExpirationInDays": "%expirationDays%", + "OneDriveRequestFilesLinkExpirationInDays": "%expirationDays%" + }, + "read": { + "cacheType": "SPOTenant" + }, + "remediate": { + "executor": "SPOTenant", + "properties": { + "CoreRequestFilesLinkEnabled": "%state%", + "OneDriveRequestFilesLinkEnabled": "%state%", + "CoreRequestFilesLinkExpirationInDays": "%expirationDays%", + "OneDriveRequestFilesLinkExpirationInDays": "%expirationDays%" + } + } +} diff --git a/backend/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineExoRequest.ps1 b/backend/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineExoRequest.ps1 index fc8fabe453..0a3be6c536 100644 --- a/backend/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineExoRequest.ps1 +++ b/backend/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineExoRequest.ps1 @@ -5,9 +5,12 @@ function Invoke-CIPPBaselineExoRequest { .DESCRIPTION One script for the whole request type - the ordered array supports remediations that need several cmdlets (pre-steps first). Each entry is { cmdlet, params, - continueOnError }; continueOnError marks idempotent pre-steps such as - Enable-OrganizationCustomization, which fails when it already ran. The spec arrives - fully rendered (%var% + tenant tokens resolved). + continueOnError, compliance }; continueOnError marks idempotent pre-steps such as + Enable-OrganizationCustomization, which fails when it already ran. compliance routes + the step through the Security & Compliance endpoint instead of Exchange Online - + the *-ProtectionAlert, *-DlpCompliance* and *-Retention* cmdlet families only exist + there, and calling them without it fails with an unrecognised-cmdlet error. The spec + arrives fully rendered (%var% + tenant tokens resolved). .FUNCTIONALITY Internal #> @@ -24,7 +27,7 @@ function Invoke-CIPPBaselineExoRequest { $CmdParams[$Property.Name] = $Property.Value } try { - $null = New-ExoRequest -tenantid $TenantFilter -cmdlet $Step.cmdlet -cmdParams $CmdParams -useSystemMailbox $true + $null = New-ExoRequest -tenantid $TenantFilter -cmdlet $Step.cmdlet -cmdParams $CmdParams -useSystemMailbox $true -Compliance:([bool]($Step.compliance ?? $false)) } catch { if ($Step.continueOnError -eq $true) { Write-Information "Baselines: $($Step.cmdlet) on $TenantFilter continued past: $($_.Exception.Message)" diff --git a/backend/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineGraphRequest.ps1 b/backend/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineGraphRequest.ps1 index 4520963382..511718e4b5 100644 --- a/backend/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineGraphRequest.ps1 +++ b/backend/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineGraphRequest.ps1 @@ -21,11 +21,16 @@ function Invoke-CIPPBaselineGraphRequest { foreach ($Step in @($Remediate.requests)) { if (-not $Step) { continue } + $Method = $Step.method ?? 'PATCH' + if ($Method -eq 'PATCH' -and @(($Step.body ?? [PSCustomObject]@{}).PSObject.Properties).Count -eq 0) { + Write-Information "Baselines: PATCH $($Step.uri) on $TenantFilter skipped - nothing configured to write." + continue + } try { - $null = New-GraphPostRequest -tenantid $TenantFilter -uri "https://graph.microsoft.com/beta/$($Step.uri)" -type ($Step.method ?? 'PATCH') -body (ConvertTo-Json -Compress -Depth 100 -InputObject $Step.body) -AsApp ([bool]($Step.asApp ?? $true)) + $null = New-GraphPostRequest -tenantid $TenantFilter -uri "https://graph.microsoft.com/beta/$($Step.uri)" -type $Method -body (ConvertTo-Json -Compress -Depth 100 -InputObject $Step.body) -AsApp ([bool]($Step.asApp ?? $true)) } catch { if ($Step.continueOnError -eq $true) { - Write-Information "Baselines: $($Step.method) $($Step.uri) on $TenantFilter continued past: $($_.Exception.Message)" + Write-Information "Baselines: $Method $($Step.uri) on $TenantFilter continued past: $($_.Exception.Message)" } else { throw } } } diff --git a/backend/Modules/CIPPCore/Public/Compare-CIPPIntuneObject.ps1 b/backend/Modules/CIPPCore/Public/Compare-CIPPIntuneObject.ps1 index 33c61b4575..506ac11124 100644 --- a/backend/Modules/CIPPCore/Public/Compare-CIPPIntuneObject.ps1 +++ b/backend/Modules/CIPPCore/Public/Compare-CIPPIntuneObject.ps1 @@ -85,7 +85,8 @@ function Compare-CIPPIntuneObject { 'includeDevices', 'excludeDevices', 'includeGuestOrExternalUserTypes', - 'excludeGuestOrExternalUserTypes' + 'excludeGuestOrExternalUserTypes', + 'NotifyUser' ) foreach ($pattern in $unorderedSetPatterns) { diff --git a/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheCopilotPolicySettings.ps1 b/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheCopilotPolicySettings.ps1 index d1ded16501..0bdfae63d3 100644 --- a/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheCopilotPolicySettings.ps1 +++ b/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheCopilotPolicySettings.ps1 @@ -5,9 +5,9 @@ function Set-CIPPDBCacheCopilotPolicySettings { .DESCRIPTION Caches the five supported Copilot policy settings (Copilot Chat pinning, block access to open - files, image generation, web search and admin center Copilot) as one row per setting with - id, value and policyId. - + files, image generation, web search and admin center Copilot) as ONE row whose properties are + the CIPP setting keys, so a single declarative read can compare all five at once. The Graph + policy ids each value came from are kept under policyIds .PARAMETER TenantFilter The tenant to cache Copilot policy settings for @@ -24,32 +24,39 @@ function Set-CIPPDBCacheCopilotPolicySettings { try { Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching Copilot policy settings' -sev Debug - $SettingIds = @( - 'microsoft.copilot.copilotchatpinning' - 'microsoft.copilot.blockaccesstoopenfiles' - 'microsoft.copilot.imagegeneration' - 'microsoft.copilot.allowwebsearch' - 'microsoft.copilot.allowinadmincenters' - ) + # Keyed by the CIPP setting name the standard compares on, valued by the Graph + # policySettings id the value is read from. + $SettingMap = [ordered]@{ + copilotChatPinning = 'microsoft.copilot.copilotchatpinning' + blockAccessToOpenFiles = 'microsoft.copilot.blockaccesstoopenfiles' + imageGeneration = 'microsoft.copilot.imagegeneration' + allowWebSearch = 'microsoft.copilot.allowwebsearch' + allowInAdminCenters = 'microsoft.copilot.allowinadmincenters' + } # The Copilot policySettings API currently requires delegated auth (no -AsApp). The entity # carries a scalar 'value' property that is data rather than a collection envelope, so # -SkipValueExtraction returns the entity intact. - $PolicySettings = foreach ($SettingId in $SettingIds) { + $Values = [ordered]@{} + $PolicyIds = [ordered]@{} + foreach ($Key in $SettingMap.Keys) { try { - $Current = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/copilot/admin/policySettings/$SettingId" -tenantid $TenantFilter -SkipValueExtraction - [PSCustomObject]@{ - id = $SettingId - value = $Current.value - policyId = $Current.policyId - } + $Current = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/copilot/admin/policySettings/$($SettingMap[$Key])" -tenantid $TenantFilter -SkipValueExtraction + # Graph returns these as opaque strings; keep them as strings so the compare + # never turns "0" into a number and stops matching the configured value. + $Values[$Key] = if ($null -eq $Current.value) { $null } else { [string]$Current.value } + $PolicyIds[$Key] = $Current.policyId } catch { - Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Failed to get Copilot policy setting '$SettingId': $($_.Exception.Message)" -sev Warning + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Failed to get Copilot policy setting '$($SettingMap[$Key])': $($_.Exception.Message)" -sev Warning + $Values[$Key] = $null + $PolicyIds[$Key] = $null } } + $Values['policyIds'] = [PSCustomObject]$PolicyIds - Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'CopilotPolicySettings' -Data @($PolicySettings) -AddCount - $PolicySettings = $null + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'CopilotPolicySettings' -Data @([PSCustomObject]$Values) -AddCount + $Values = $null + $PolicyIds = $null Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached Copilot policy settings successfully' -sev Debug From e08d678c3fbfb4ab759eac8f19f7805371a722cf Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Sat, 15 Aug 2026 20:43:10 +0200 Subject: [PATCH 067/226] Remove custom, change it to prepare+executor. --- .../DisableBasicAuthSMTP.json | 7 +- .../ActivityBasedTimeout.json | 7 +- ...-CIPPBaselineActivityBasedTimeoutState.ps1 | 38 ++++ ...-CIPPBaselineDisableBasicAuthSMTPState.ps1 | 57 ++++++ ...nvoke-CIPPBaselineActivityBasedTimeout.ps1 | 180 +++-------------- .../Invoke-CIPPBaselineCATemplate.ps1 | 4 +- ...nvoke-CIPPBaselineDisableBasicAuthSMTP.ps1 | 190 +++--------------- .../Invoke-CIPPBaselineExoRequest.ps1 | 4 +- .../Invoke-CIPPBaselineGraphRequest.ps1 | 4 +- .../Invoke-CIPPBaselineIntuneTemplate.ps1 | 4 +- .../Invoke-CIPPBaselineSPOTenant.ps1 | 4 +- .../Baselines/Invoke-CIPPBaselineStandard.ps1 | 40 ++-- .../Invoke-CIPPBaselineTeamsRequest.ps1 | 4 +- 13 files changed, 208 insertions(+), 335 deletions(-) create mode 100644 backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineActivityBasedTimeoutState.ps1 create mode 100644 backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineDisableBasicAuthSMTPState.ps1 diff --git a/backend/Config/BaselineStandards/Exchange Standards/DisableBasicAuthSMTP.json b/backend/Config/BaselineStandards/Exchange Standards/DisableBasicAuthSMTP.json index 12431d4b22..667beaf6e0 100644 --- a/backend/Config/BaselineStandards/Exchange Standards/DisableBasicAuthSMTP.json +++ b/backend/Config/BaselineStandards/Exchange Standards/DisableBasicAuthSMTP.json @@ -46,6 +46,9 @@ "read": { "cacheType": "ExoTransportConfig" }, - "custom": true, - "customFunction": "Invoke-CIPPBaselineDisableBasicAuthSMTP" + "prepare": "Get-CIPPBaselineDisableBasicAuthSMTPState", + "remediate": { + "executor": "DisableBasicAuthSMTP", + "disabled": "%disabled%" + } } diff --git a/backend/Config/BaselineStandards/Global Standards/ActivityBasedTimeout.json b/backend/Config/BaselineStandards/Global Standards/ActivityBasedTimeout.json index ae7e383b59..aef903da30 100644 --- a/backend/Config/BaselineStandards/Global Standards/ActivityBasedTimeout.json +++ b/backend/Config/BaselineStandards/Global Standards/ActivityBasedTimeout.json @@ -61,6 +61,9 @@ "read": { "cacheType": "ActivityBasedTimeoutPolicy" }, - "custom": true, - "customFunction": "Invoke-CIPPBaselineActivityBasedTimeout" + "prepare": "Get-CIPPBaselineActivityBasedTimeoutState", + "remediate": { + "executor": "ActivityBasedTimeout", + "timeout": "%timeout%" + } } diff --git a/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineActivityBasedTimeoutState.ps1 b/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineActivityBasedTimeoutState.ps1 new file mode 100644 index 0000000000..b866922ba2 --- /dev/null +++ b/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineActivityBasedTimeoutState.ps1 @@ -0,0 +1,38 @@ +function Get-CIPPBaselineActivityBasedTimeoutState { + <# + .SYNOPSIS + Prepare hook for ActivityBasedTimeout: normalizes the cached policy into a + comparable { timeout } object. + .DESCRIPTION + The governed value sits in a JSON string INSIDE the policy JSON + (definition[0] -> {"ActivityBasedTimeoutPolicy":{...}}), which the declarative read + spec cannot express - the only reason this standard needs a hook at all. The + portal/Graph schema nests the timeout under ApplicationPolicies (ApplicationId + 'default'); policies written by an early engine build put WebSessionIdleTimeout + directly on the root, so both are read or those tenants report permanent drift. + + Expected is NOT returned: the definition's declarative expected ({ timeout: + "%timeout%" }) renders correctly from the variable, and the engine owns it. + A null Current is the honest 'not collected' signal - the engine triggers the + collector, retries once, and parks the row at No Data if it is still missing. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Item, + $TenantFilter + ) + + $Policy = @(New-CIPPDbRequest -TenantFilter $TenantFilter -Type 'ActivityBasedTimeoutPolicy' | Where-Object { $_ }) | Select-Object -First 1 + if ($null -eq $Policy) { return @{ Current = $null } } + + $CurrentTimeout = $(try { + $AbtDefinition = (@($Policy.definition)[0] | ConvertFrom-Json).ActivityBasedTimeoutPolicy + $DefaultApplicationPolicy = @($AbtDefinition.ApplicationPolicies) | Where-Object { $_.ApplicationId -eq 'default' } | Select-Object -First 1 + if (-not $DefaultApplicationPolicy) { $DefaultApplicationPolicy = @($AbtDefinition.ApplicationPolicies) | Select-Object -First 1 } + $DefaultApplicationPolicy.WebSessionIdleTimeout ?? $AbtDefinition.WebSessionIdleTimeout + } catch { $null }) + + @{ Current = [PSCustomObject]@{ timeout = "$CurrentTimeout" } } +} diff --git a/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineDisableBasicAuthSMTPState.ps1 b/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineDisableBasicAuthSMTPState.ps1 new file mode 100644 index 0000000000..805a8053e8 --- /dev/null +++ b/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineDisableBasicAuthSMTPState.ps1 @@ -0,0 +1,57 @@ +function Get-CIPPBaselineDisableBasicAuthSMTPState { + <# + .SYNOPSIS + Prepare hook for DisableBasicAuthSMTP: joins the tenant-wide transport flag with + the per-user CAS mailbox overrides into one comparable object. + .DESCRIPTION + Two dimensions, which is why this standard needs a hook: the TransportConfig + SmtpClientAuthenticationDisabled flag AND the per-user overrides + (SmtpClientAuthenticationDisabled -eq $false = SMTP AUTH explicitly enabled for + that user, alive regardless of the tenant switch). They live in two cache types, + and a declarative read selects from one. + + Expected IS returned, because its SHAPE is conditional: the override list is only + graded when the point is disabling SMTP AUTH. An operator who deliberately sets + the flag to enabled has not asked for per-user enablements to be stripped, so that + key is dropped from both sides rather than compared against an empty list. + + A null Current is the honest 'not collected' signal for the transport config - the + engine triggers the collector, retries once, then parks at No Data. The override + cache is this hook's own business: an empty read there is ambiguous (never + collected vs genuinely none), so it always re-collects once - the collector's + ClearOnEmpty makes the collected-empty state authoritative and the recollect cheap. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Item, + $TenantFilter + ) + + $TransportConfig = @(New-CIPPDbRequest -TenantFilter $TenantFilter -Type 'ExoTransportConfig' | Where-Object { $_ }) | Select-Object -First 1 + if ($null -eq $TransportConfig) { return @{ Current = $null } } + + $ExpectedDisabled = "$($Item.Variables.disabled)" -in @('True', 'true', '1') + + $Overrides = @(New-CIPPDbRequest -TenantFilter $TenantFilter -Type 'ExoCASMailboxSmtpAuth' | Where-Object { $_ }) + if ($Overrides.Count -eq 0) { + $Collector = Get-Command -Name 'Set-CIPPDBCacheExoCASMailboxSmtpAuth' -ErrorAction SilentlyContinue + if ($Collector) { + try { $null = & $Collector -TenantFilter $TenantFilter } catch { + Write-Information "Baselines: SMTP AUTH override cache collection on $TenantFilter failed: $($_.Exception.Message)" + } + $Overrides = @(New-CIPPDbRequest -TenantFilter $TenantFilter -Type 'ExoCASMailboxSmtpAuth' | Where-Object { $_ }) + } + } + $EnabledUsers = @($Overrides | ForEach-Object { "$($_.PrimarySmtpAddress ?? $_.Identity)" } | Where-Object { $_ } | Sort-Object) + + $Expected = [PSCustomObject]@{ SmtpClientAuthenticationDisabled = $ExpectedDisabled } + $Current = [PSCustomObject]@{ SmtpClientAuthenticationDisabled = [bool]$TransportConfig.SmtpClientAuthenticationDisabled } + if ($ExpectedDisabled) { + $Expected | Add-Member -NotePropertyName 'UsersWithSmtpAuthEnabled' -NotePropertyValue @() + $Current | Add-Member -NotePropertyName 'UsersWithSmtpAuthEnabled' -NotePropertyValue $EnabledUsers + } + + @{ Expected = $Expected; Current = $Current } +} diff --git a/backend/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineActivityBasedTimeout.ps1 b/backend/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineActivityBasedTimeout.ps1 index 245efb0a6e..88b90ad193 100644 --- a/backend/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineActivityBasedTimeout.ps1 +++ b/backend/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineActivityBasedTimeout.ps1 @@ -1,162 +1,48 @@ function Invoke-CIPPBaselineActivityBasedTimeout { <# .SYNOPSIS - Custom baseline standard: Activity Based Timeout. + ActivityBasedTimeout executor: writes the org-default idle session timeout policy. .DESCRIPTION - The ABT policy stores its configuration JSON-encoded INSIDE the policy JSON - (definition[0] -> {"ActivityBasedTimeoutPolicy":{"WebSessionIdleTimeout":...}}), which - the declarative read spec cannot express - the reason this standard is custom. Reads - the ActivityBasedTimeoutPolicy cache (triggering the collector once on a miss; a miss - after that writes NOTHING so the row stays 'No Data'), compares the web session idle - timeout, and remediates by PATCHing the existing policy or POSTing a new - organization-default one. Persists through the shared writer like every engine result. + Needs its own executor because the write is an upsert against a policy whose id is + per tenant: PATCH the existing organization-default policy, POST a new one when + none exists. The body nests the timeout the way the portal and Graph schema do - + ApplicationPolicies[] with the org-wide entry keyed ApplicationId 'default'. + + Create-vs-update is decided on a LIVE read, not the cached row: a cache that + predates a policy created by an earlier run would make every run POST and collide + with the existing org default. The spec arrives fully rendered. .FUNCTIONALITY Internal #> [CmdletBinding()] param( - $Item, - [ValidateSet('run', 'compare', 'oneoff')]$Mode = 'run', - $TriggeredBy = 'schedule', - [switch]$Force, - $RunId + $Remediate, + $TenantFilter, + # The read result. Unused here - the write reads live state instead. + $Current ) - if (-not $RunId) { $RunId = [string](New-Guid).Guid } - $TenantFilter = $Item.TenantFilter - $Now = [int64]([datetimeoffset]::UtcNow.ToUnixTimeSeconds()) - $ExpectedTimeout = "$($Item.Variables.timeout)" + $Timeout = "$($Remediate.timeout)" + if ([string]::IsNullOrWhiteSpace($Timeout)) { throw 'ActivityBasedTimeout: no timeout configured to write.' } - $Result = [PSCustomObject]@{ - Item = $Item - Mode = $Mode - TriggeredBy = $TriggeredBy - ExpectedValue = [PSCustomObject]@{ timeout = $ExpectedTimeout } - CurrentValue = $null - Compliant = $false - PendingVerification = $false - LicenseAvailable = $true - Status = $null - Remediated = $false - Outcome = 'Error' - Diff = $null - Inheritance = @($Item.Tiers) - AlertEvent = $null - CacheType = 'ActivityBasedTimeoutPolicy' - } - - try { - $ResolvedTable = Get-CippTable -tablename 'BaselineAlignment' - $SafeTenant = ConvertTo-CIPPODataFilterValue -Value $TenantFilter - $SafeStandard = ConvertTo-CIPPODataFilterValue -Value $Item.Standard - $Prior = Get-CIPPAzDataTableEntity @ResolvedTable -Filter "PartitionKey eq '$SafeTenant' and StandardName eq '$SafeStandard'" | Select-Object -First 1 - $PriorStatus = $Prior.Status - $Result.Status = $PriorStatus - - $Policy = @(New-CIPPDbRequest -TenantFilter $TenantFilter -Type 'ActivityBasedTimeoutPolicy' | Where-Object { $_ }) | Select-Object -First 1 - if ($null -eq $Policy) { - $Collector = Get-Command -Name 'Set-CIPPDBCacheActivityBasedTimeoutPolicy' -ErrorAction SilentlyContinue - if ($Collector) { - try { - $null = & $Collector -TenantFilter $TenantFilter - $Policy = @(New-CIPPDbRequest -TenantFilter $TenantFilter -Type 'ActivityBasedTimeoutPolicy' | Where-Object { $_ }) | Select-Object -First 1 - } catch { - Write-Information "Baselines: ABT cache collection on $TenantFilter failed: $($_.Exception.Message)" - } - } - } - # Fail open: a missing cache never returns early - an enforced standard still - # applies its expected state (POSTing a new org-default policy when none exists). - # The governed value sits in a JSON string inside the policy's definition array; - # the portal/Graph schema nests it under ApplicationPolicies (ApplicationId - # 'default'). Policies written by an early engine build put WebSessionIdleTimeout - # directly on the root - read both so those do not report permanent drift. - $CurrentTimeout = $(try { - $AbtDefinition = (@($Policy.definition)[0] | ConvertFrom-Json).ActivityBasedTimeoutPolicy - $DefaultApplicationPolicy = @($AbtDefinition.ApplicationPolicies) | Where-Object { $_.ApplicationId -eq 'default' } | Select-Object -First 1 - if (-not $DefaultApplicationPolicy) { $DefaultApplicationPolicy = @($AbtDefinition.ApplicationPolicies) | Select-Object -First 1 } - $DefaultApplicationPolicy.WebSessionIdleTimeout ?? $AbtDefinition.WebSessionIdleTimeout - } catch { $null }) - if ($null -ne $Policy) { - $Result.CurrentValue = [PSCustomObject]@{ timeout = $CurrentTimeout } - } - $Compliant = ($null -ne $Policy) -and ($CurrentTimeout -eq $ExpectedTimeout) - if (-not $Compliant) { - $Result.Diff = @([PSCustomObject]@{ Property = 'timeout'; ExpectedValue = $ExpectedTimeout; ReceivedValue = $CurrentTimeout }) - } - - $Expires = if ("$($Prior.DeviationExpires)" -match '^\d+$') { [int64]$Prior.DeviationExpires } else { 0 } - $AcceptActive = $PriorStatus -eq 'Accepted' -and ($Expires -eq 0 -or $Now -lt $Expires) - if (-not $Compliant -and $null -ne $Policy -and $AcceptActive) { - $Result.Outcome = 'Drift' - $Result.Status = 'Accepted' - Set-CIPPBaselineResult -Result $Result -Prior $Prior -RunId $RunId - return $Result - } - - $DeniedRemediate = $PriorStatus -eq 'Denied - Remediate Pending' - # An active Accept blocks remediation - including the fail-open path. - $RemediationAllowed = (($Mode -eq 'oneoff') -or ($Mode -eq 'run' -and ($Item.RemediateEnabled -or $DeniedRemediate))) -and -not $AcceptActive - $WriteNeeded = (-not $Compliant) -or $Force.IsPresent - - if ($Mode -ne 'compare' -and $RemediationAllowed -and $WriteNeeded) { - # The documented definition schema: ApplicationPolicies[] with the org-wide - # entry keyed ApplicationId 'default' - the shape the portal writes and reads. - $PolicyDefinition = ConvertTo-Json -Compress -Depth 10 -InputObject ([PSCustomObject]@{ - ActivityBasedTimeoutPolicy = [PSCustomObject]@{ - Version = 1 - ApplicationPolicies = @([PSCustomObject]@{ ApplicationId = 'default'; WebSessionIdleTimeout = $ExpectedTimeout }) - } - }) - $Body = ConvertTo-Json -Compress -Depth 10 -InputObject ([PSCustomObject]@{ - definition = @($PolicyDefinition) - isOrganizationDefault = $true - displayName = 'DefaultTimeoutPolicy' - }) - try { - if ($Policy.id) { - $null = New-GraphPostRequest -tenantid $TenantFilter -uri "https://graph.microsoft.com/beta/policies/activityBasedTimeoutPolicies/$($Policy.id)" -type PATCH -body $Body -AsApp $true - } else { - $null = New-GraphPostRequest -tenantid $TenantFilter -uri 'https://graph.microsoft.com/beta/policies/activityBasedTimeoutPolicies' -type POST -body $Body -AsApp $true - } - } catch { - Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "Failed to change `"Enable Activity based Timeout`" to $ExpectedTimeout`: $($_.Exception.Message) - Run $RunId" -Sev 'Error' - $Result.Outcome = 'Error' - Set-CIPPBaselineResult -Result $Result -Prior $Prior -RunId $RunId - return $Result + $PolicyDefinition = ConvertTo-Json -Compress -Depth 10 -InputObject ([PSCustomObject]@{ + ActivityBasedTimeoutPolicy = [PSCustomObject]@{ + Version = 1 + ApplicationPolicies = @([PSCustomObject]@{ ApplicationId = 'default'; WebSessionIdleTimeout = $Timeout }) } - Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "Successfully changed `"Enable Activity based Timeout`" to $ExpectedTimeout - Run $RunId" -Sev 'Info' - $Result.CurrentValue = $Result.ExpectedValue - $Result.Compliant = $true - $Result.PendingVerification = $true - $Result.Remediated = $true - $Result.Outcome = 'Remediated' - $Result.Status = 'Compliant' - if ($Item.AlertOnRemediate) { $Result.AlertEvent = 'Remediated' } - } elseif ($Compliant) { - $Result.Compliant = $true - $Result.Outcome = 'Compliant' - $Result.Status = 'Compliant' - } elseif ($null -eq $Policy) { - # No cache and remediation does not apply: nothing to honestly report, so - # nothing is written - the row stays 'No Data' and retries next run. - Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "$($Item.Standard): no ActivityBasedTimeoutPolicy data in CIPPDb after collection and remediation does not apply - skipped, nothing written." -Sev 'Info' - $Result.Outcome = 'Skipped-NoCache' - $Result.Status = $PriorStatus ?? 'No Data' - return $Result - } else { - $Result.Outcome = 'Drift' - $Result.Status = if ("$PriorStatus".StartsWith('Denied')) { $PriorStatus } else { 'Drift' } - if ($Result.Status -eq 'Drift' -and $PriorStatus -ne 'Drift' -and $Item.AlertEnabled) { $Result.AlertEvent = 'Drift' } - } - - Set-CIPPBaselineResult -Result $Result -Prior $Prior -RunId $RunId - if ($Result.AlertEvent) { Send-CIPPBaselineAlert -Result $Result } - return $Result - } catch { - Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "Activity Based Timeout baseline failed on ${TenantFilter}: $($_.Exception.Message)" -Sev 'Error' - $Result.Outcome = 'Error' - try { Set-CIPPBaselineResult -Result $Result -Prior $Prior -RunId $RunId } catch { Write-Information "Set-CIPPBaselineResult failed: $($_.Exception.Message)" } - return $Result + }) + $Body = ConvertTo-Json -Compress -Depth 10 -InputObject ([PSCustomObject]@{ + definition = @($PolicyDefinition) + isOrganizationDefault = $true + displayName = 'DefaultTimeoutPolicy' + }) + + $Existing = @(New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/policies/activityBasedTimeoutPolicies' -tenantid $TenantFilter -AsApp $true | + Where-Object { $_.isOrganizationDefault -eq $true }) | Select-Object -First 1 + + if ($Existing.id) { + $null = New-GraphPostRequest -tenantid $TenantFilter -uri "https://graph.microsoft.com/beta/policies/activityBasedTimeoutPolicies/$($Existing.id)" -type PATCH -body $Body -AsApp $true + } else { + $null = New-GraphPostRequest -tenantid $TenantFilter -uri 'https://graph.microsoft.com/beta/policies/activityBasedTimeoutPolicies' -type POST -body $Body -AsApp $true } } diff --git a/backend/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineCATemplate.ps1 b/backend/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineCATemplate.ps1 index c9e2c68765..c369e476cb 100644 --- a/backend/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineCATemplate.ps1 +++ b/backend/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineCATemplate.ps1 @@ -16,7 +16,9 @@ function Invoke-CIPPBaselineCATemplate { [CmdletBinding()] param( $Remediate, - $TenantFilter + $TenantFilter, + # The read result. Unused here; every executor takes the same arguments. + $Current ) $TemplateRef = "$($Remediate.caTemplate)" diff --git a/backend/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineDisableBasicAuthSMTP.ps1 b/backend/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineDisableBasicAuthSMTP.ps1 index 9565548b97..148500774e 100644 --- a/backend/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineDisableBasicAuthSMTP.ps1 +++ b/backend/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineDisableBasicAuthSMTP.ps1 @@ -1,181 +1,45 @@ function Invoke-CIPPBaselineDisableBasicAuthSMTP { <# .SYNOPSIS - Custom baseline standard: Disable SMTP Basic Authentication. + DisableBasicAuthSMTP executor: sets the tenant transport flag and clears per-user + SMTP AUTH overrides. .DESCRIPTION - Two dimensions, which is why this standard is custom: the tenant-wide - TransportConfig SmtpClientAuthenticationDisabled flag AND the per-user CAS mailbox - overrides (SmtpClientAuthenticationDisabled -eq $false = SMTP AUTH explicitly - enabled for that user, alive regardless of the tenant switch). Compliant = flag - matches the expectation and, when disabling, no user overrides remain. Remediation - sets the transport flag and clears each override back to $null (inherit), exactly - like the classic standard, then re-collects the override cache so the next run - reads the cleared state. Persists through the shared writer like every engine - result. + Needs its own executor because the second half of the write is a SWEEP: one + Set-CASMailbox per user who has SMTP AUTH explicitly enabled. The offender list is + not a constant - it comes from -Current, the object the prepare hook already + computed, so the write targets exactly what the compare graded. + + Overrides are cleared back to inherit ($null), never $true, so a later tenant-level + policy change applies to those users again. Only relevant when disabling: an + operator who set the flag to enabled has not asked for enablements to be stripped, + and the prepare hook does not grade them either. .FUNCTIONALITY Internal #> [CmdletBinding()] param( - $Item, - [ValidateSet('run', 'compare', 'oneoff')]$Mode = 'run', - $TriggeredBy = 'schedule', - [switch]$Force, - $RunId + $Remediate, + $TenantFilter, + $Current ) - if (-not $RunId) { $RunId = [string](New-Guid).Guid } - $TenantFilter = $Item.TenantFilter - $Now = [int64]([datetimeoffset]::UtcNow.ToUnixTimeSeconds()) - $ExpectedDisabled = "$($Item.Variables.disabled)" -in @('True', 'true', '1') + $Disabled = [bool]($Remediate.disabled -eq $true) + $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Set-TransportConfig' -cmdParams @{ SmtpClientAuthenticationDisabled = $Disabled } + if (-not $Disabled) { return } - $Result = [PSCustomObject]@{ - Item = $Item - Mode = $Mode - TriggeredBy = $TriggeredBy - ExpectedValue = [PSCustomObject]@{ SmtpClientAuthenticationDisabled = $ExpectedDisabled; UsersWithSmtpAuthEnabled = @() } - CurrentValue = $null - Compliant = $false - PendingVerification = $false - LicenseAvailable = $true - Status = $null - Remediated = $false - Outcome = 'Error' - Diff = $null - Inheritance = @($Item.Tiers) - AlertEvent = $null - CacheType = 'ExoTransportConfig' + $EnabledUsers = @($Current.UsersWithSmtpAuthEnabled | Where-Object { $_ }) + foreach ($User in $EnabledUsers) { + $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Set-CASMailbox' -cmdParams @{ Identity = $User; SmtpClientAuthenticationDisabled = $null } } - try { - $ResolvedTable = Get-CippTable -tablename 'BaselineAlignment' - $SafeTenant = ConvertTo-CIPPODataFilterValue -Value $TenantFilter - $SafeStandard = ConvertTo-CIPPODataFilterValue -Value $Item.Standard - $Prior = Get-CIPPAzDataTableEntity @ResolvedTable -Filter "PartitionKey eq '$SafeTenant' and StandardName eq '$SafeStandard'" | Select-Object -First 1 - $PriorStatus = $Prior.Status - $Result.Status = $PriorStatus - - $TransportConfig = @(New-CIPPDbRequest -TenantFilter $TenantFilter -Type 'ExoTransportConfig' | Where-Object { $_ }) | Select-Object -First 1 - if ($null -eq $TransportConfig) { - $Collector = Get-Command -Name 'Set-CIPPDBCacheExoTransportConfig' -ErrorAction SilentlyContinue - if ($Collector) { - try { - $null = & $Collector -TenantFilter $TenantFilter - $TransportConfig = @(New-CIPPDbRequest -TenantFilter $TenantFilter -Type 'ExoTransportConfig' | Where-Object { $_ }) | Select-Object -First 1 - } catch { - Write-Information "Baselines: TransportConfig cache collection on $TenantFilter failed: $($_.Exception.Message)" - } - } - } - if ($null -eq $TransportConfig) { - # No cache and no way to grade honestly: nothing is written - the row stays - # 'No Data' and retries next run. - Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "$($Item.Standard): no ExoTransportConfig data in CIPPDb after collection - skipped, nothing written." -Sev 'Info' - $Result.Outcome = 'Skipped-NoCache' - $Result.Status = $PriorStatus ?? 'No Data' - return $Result - } - - # The override set: an empty read is ambiguous (never collected vs genuinely - # none), so an empty read always re-collects once - the collector's ClearOnEmpty - # makes the collected-empty state authoritative and the recollect cheap. - $CollectOverrides = { - $Collector = Get-Command -Name 'Set-CIPPDBCacheExoCASMailboxSmtpAuth' -ErrorAction SilentlyContinue - if ($Collector) { - try { $null = & $Collector -TenantFilter $TenantFilter } catch { - Write-Information "Baselines: SMTP AUTH override cache collection on $TenantFilter failed: $($_.Exception.Message)" - } - } - } - $Overrides = @(New-CIPPDbRequest -TenantFilter $TenantFilter -Type 'ExoCASMailboxSmtpAuth' | Where-Object { $_ }) - if ($Overrides.Count -eq 0) { - & $CollectOverrides - $Overrides = @(New-CIPPDbRequest -TenantFilter $TenantFilter -Type 'ExoCASMailboxSmtpAuth' | Where-Object { $_ }) - } - $EnabledUsers = @($Overrides | ForEach-Object { "$($_.PrimarySmtpAddress ?? $_.Identity)" } | Where-Object { $_ } | Sort-Object) - - $CurrentDisabled = [bool]$TransportConfig.SmtpClientAuthenticationDisabled - $Result.CurrentValue = [PSCustomObject]@{ - SmtpClientAuthenticationDisabled = $CurrentDisabled - UsersWithSmtpAuthEnabled = $EnabledUsers - } - - $FlagCompliant = $CurrentDisabled -eq $ExpectedDisabled - # Per-user overrides only matter when the point is disabling SMTP AUTH. - $UsersCompliant = (-not $ExpectedDisabled) -or ($EnabledUsers.Count -eq 0) - $Compliant = $FlagCompliant -and $UsersCompliant - if (-not $Compliant) { - $Diff = [System.Collections.Generic.List[object]]::new() - if (-not $FlagCompliant) { - $Diff.Add([PSCustomObject]@{ Property = 'SmtpClientAuthenticationDisabled'; ExpectedValue = $ExpectedDisabled; ReceivedValue = $CurrentDisabled }) - } - if (-not $UsersCompliant) { - $Diff.Add([PSCustomObject]@{ Property = 'UsersWithSmtpAuthEnabled'; ExpectedValue = @(); ReceivedValue = $EnabledUsers }) - } - $Result.Diff = @($Diff) - } - - $Expires = if ("$($Prior.DeviationExpires)" -match '^\d+$') { [int64]$Prior.DeviationExpires } else { 0 } - $AcceptActive = $PriorStatus -eq 'Accepted' -and ($Expires -eq 0 -or $Now -lt $Expires) - if (-not $Compliant -and $AcceptActive) { - $Result.Outcome = 'Drift' - $Result.Status = 'Accepted' - Set-CIPPBaselineResult -Result $Result -Prior $Prior -RunId $RunId - return $Result - } - - $DeniedRemediate = $PriorStatus -eq 'Denied - Remediate Pending' - $RemediationAllowed = (($Mode -eq 'oneoff') -or ($Mode -eq 'run' -and ($Item.RemediateEnabled -or $DeniedRemediate))) -and -not $AcceptActive - $WriteNeeded = (-not $Compliant) -or $Force.IsPresent - - if ($Mode -ne 'compare' -and $RemediationAllowed -and $WriteNeeded) { - try { - if (-not $FlagCompliant -or $Force.IsPresent) { - $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Set-TransportConfig' -cmdParams @{ SmtpClientAuthenticationDisabled = $ExpectedDisabled } - } - # Clear each explicit enablement back to inherit ($null) - never $true, so - # a later tenant-level policy change applies to these users again. - if ($ExpectedDisabled) { - foreach ($User in $EnabledUsers) { - $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Set-CASMailbox' -cmdParams @{ Identity = $User; SmtpClientAuthenticationDisabled = $null } - } - } - } catch { - Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "Failed to change `"Disable SMTP Basic Authentication`": $($_.Exception.Message) - Run $RunId" -Sev 'Error' - $Result.Outcome = 'Error' - Set-CIPPBaselineResult -Result $Result -Prior $Prior -RunId $RunId - return $Result + if ($EnabledUsers.Count -gt 0) { + # Refresh the override cache now: the cleared users must not read back as drift on + # the next run (ClearOnEmpty makes the emptied state stick). + $Collector = Get-Command -Name 'Set-CIPPDBCacheExoCASMailboxSmtpAuth' -ErrorAction SilentlyContinue + if ($Collector) { + try { $null = & $Collector -TenantFilter $TenantFilter } catch { + Write-Information "Baselines: SMTP AUTH override cache refresh on $TenantFilter failed: $($_.Exception.Message)" } - if ($EnabledUsers.Count -gt 0) { - # Refresh the override cache now: the cleared users must not read back as - # drift on the next run (ClearOnEmpty makes the emptied state stick). - & $CollectOverrides - } - Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "Successfully changed `"Disable SMTP Basic Authentication`" ($($EnabledUsers.Count) user override$(if ($EnabledUsers.Count -eq 1) { '' } else { 's' }) cleared) - Run $RunId" -Sev 'Info' - $Result.CurrentValue = $Result.ExpectedValue - $Result.Compliant = $true - $Result.PendingVerification = $true - $Result.Remediated = $true - $Result.Outcome = 'Remediated' - $Result.Status = 'Compliant' - if ($Item.AlertOnRemediate) { $Result.AlertEvent = 'Remediated' } - } elseif ($Compliant) { - $Result.Compliant = $true - $Result.Outcome = 'Compliant' - $Result.Status = 'Compliant' - } else { - $Result.Outcome = 'Drift' - $Result.Status = if ("$PriorStatus".StartsWith('Denied')) { $PriorStatus } else { 'Drift' } - if ($Result.Status -eq 'Drift' -and $PriorStatus -ne 'Drift' -and $Item.AlertEnabled) { $Result.AlertEvent = 'Drift' } } - - Set-CIPPBaselineResult -Result $Result -Prior $Prior -RunId $RunId - if ($Result.AlertEvent) { Send-CIPPBaselineAlert -Result $Result } - return $Result - } catch { - Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "Disable SMTP Basic Authentication baseline failed on ${TenantFilter}: $($_.Exception.Message)" -Sev 'Error' - $Result.Outcome = 'Error' - try { Set-CIPPBaselineResult -Result $Result -Prior $Prior -RunId $RunId } catch { Write-Information "Set-CIPPBaselineResult failed: $($_.Exception.Message)" } - return $Result } } diff --git a/backend/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineExoRequest.ps1 b/backend/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineExoRequest.ps1 index 0a3be6c536..e0440757d6 100644 --- a/backend/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineExoRequest.ps1 +++ b/backend/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineExoRequest.ps1 @@ -17,7 +17,9 @@ function Invoke-CIPPBaselineExoRequest { [CmdletBinding()] param( $Remediate, - $TenantFilter + $TenantFilter, + # The read result. Unused here; every executor takes the same arguments. + $Current ) foreach ($Step in @($Remediate.cmdlets)) { diff --git a/backend/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineGraphRequest.ps1 b/backend/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineGraphRequest.ps1 index 511718e4b5..f96bf8a515 100644 --- a/backend/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineGraphRequest.ps1 +++ b/backend/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineGraphRequest.ps1 @@ -16,7 +16,9 @@ function Invoke-CIPPBaselineGraphRequest { [CmdletBinding()] param( $Remediate, - $TenantFilter + $TenantFilter, + # The read result. Unused here; every executor takes the same arguments. + $Current ) foreach ($Step in @($Remediate.requests)) { diff --git a/backend/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineIntuneTemplate.ps1 b/backend/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineIntuneTemplate.ps1 index 55778be3ad..dda0b8a06e 100644 --- a/backend/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineIntuneTemplate.ps1 +++ b/backend/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineIntuneTemplate.ps1 @@ -16,7 +16,9 @@ function Invoke-CIPPBaselineIntuneTemplate { [CmdletBinding()] param( $Remediate, - $TenantFilter + $TenantFilter, + # The read result. Unused here; every executor takes the same arguments. + $Current ) $TemplateRef = "$($Remediate.intuneTemplate)" diff --git a/backend/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineSPOTenant.ps1 b/backend/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineSPOTenant.ps1 index 569008a1fb..27256f44c2 100644 --- a/backend/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineSPOTenant.ps1 +++ b/backend/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineSPOTenant.ps1 @@ -19,7 +19,9 @@ function Invoke-CIPPBaselineSPOTenant { [CmdletBinding()] param( $Remediate, - $TenantFilter + $TenantFilter, + # The read result. Unused here; every executor takes the same arguments. + $Current ) # CSOM property writes accept Boolean/String/Int32 only. diff --git a/backend/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineStandard.ps1 b/backend/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineStandard.ps1 index 8eae19c3c5..b13b6941a5 100644 --- a/backend/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineStandard.ps1 +++ b/backend/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineStandard.ps1 @@ -116,12 +116,12 @@ function Invoke-CIPPBaselineStandard { Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "Started `"$Label`" ($Mode) - Run $RunId" -Sev 'Info' - # 1a. Custom standards own their whole flow in their per-standard script. - if ($Definition.custom -eq $true) { - $CustomFunction = Get-Command -Name $Definition.customFunction -ErrorAction SilentlyContinue - if (-not $CustomFunction) { throw "Custom function $($Definition.customFunction) is not available." } - return (& $Definition.customFunction -Item $Item -Mode $Mode -TriggeredBy $TriggeredBy -Force:$Force -RunId $RunId) - } + # There is ONE flow. A standard whose read or write cannot be expressed + # declaratively replaces THAT PART ONLY - a prepare hook for a bespoke read, a + # named executor for a bespoke write - and the engine still owns compare, hard + # gaps, accepted paths, triage, conflict, deletion and persistence. Nothing + # short-circuits past this point: a standard that owned its whole flow was a fork + # of the logic below, and forks silently stop tracking it. # Prior resolved row: the deviation lifecycle and manual completion live on it. $ResolvedTable = Get-CippTable -tablename 'BaselineAlignment' @@ -342,8 +342,10 @@ function Invoke-CIPPBaselineStandard { } $Value } - # Pre-check gate, for TEMPLATE standards only (prepare hook = CA/Intune): their - # verdicts drive policy deploys and their domains change under external hands. + # Pre-check gate, for TEMPLATE standards only - keyed on read.requiredCaches, + # which only they declare (a prepare hook alone no longer implies it, now that + # prepare is the general escape hatch for any bespoke read): their verdicts drive + # policy deploys and their domains change under external hands. # Wait-CIPPBaselineCacheReady verifies the cache is complete (all required # family caches collected), recent (3h), and consistent with live state (CA # live-count probe) - and enforces SINGLE-FLIGHT collection: one job per @@ -352,7 +354,9 @@ function Invoke-CIPPBaselineStandard { # Everything else tolerates normal CIPPDb cadence staleness. $JustRefreshed = $false $CacheCollector = Get-Command -Name "Set-CIPPDBCache$($Definition.read.cacheType)" -ErrorAction SilentlyContinue - if ($CacheCollector -and $Definition.prepare) { + # The Where-Object is load-bearing: @($null).Count is 1, so an unfiltered @() test + # is true for every definition that simply omits the property. + if ($CacheCollector -and @($Definition.read.requiredCaches | Where-Object { $_ }).Count -gt 0) { $JustRefreshed = Wait-CIPPBaselineCacheReady -TenantFilter $TenantFilter -Definition $Definition -RunId $RunId } @@ -624,13 +628,19 @@ function Invoke-CIPPBaselineStandard { $ExpectedJson = ConvertTo-Json -Compress -Depth 100 -InputObject $Expected try { $Rendered = & $Render $Definition.remediate $Item.Variables + # Every executor takes the same three arguments. -Current is the read + # result (declarative or prepared): a sweep writes to the offender set the + # read computed, and an object-scoped write needs the id it found. Most + # executors ignore it. switch ($Definition.remediate.executor) { - 'ExoRequest' { Invoke-CIPPBaselineExoRequest -Remediate $Rendered -TenantFilter $TenantFilter } - 'GraphRequest' { Invoke-CIPPBaselineGraphRequest -Remediate $Rendered -TenantFilter $TenantFilter } - 'TeamsRequest' { Invoke-CIPPBaselineTeamsRequest -Remediate $Rendered -TenantFilter $TenantFilter } - 'SPOTenant' { Invoke-CIPPBaselineSPOTenant -Remediate $Rendered -TenantFilter $TenantFilter } - 'CATemplate' { Invoke-CIPPBaselineCATemplate -Remediate $Rendered -TenantFilter $TenantFilter } - 'IntuneTemplate' { Invoke-CIPPBaselineIntuneTemplate -Remediate $Rendered -TenantFilter $TenantFilter } + 'ExoRequest' { Invoke-CIPPBaselineExoRequest -Remediate $Rendered -TenantFilter $TenantFilter -Current $Current } + 'GraphRequest' { Invoke-CIPPBaselineGraphRequest -Remediate $Rendered -TenantFilter $TenantFilter -Current $Current } + 'TeamsRequest' { Invoke-CIPPBaselineTeamsRequest -Remediate $Rendered -TenantFilter $TenantFilter -Current $Current } + 'SPOTenant' { Invoke-CIPPBaselineSPOTenant -Remediate $Rendered -TenantFilter $TenantFilter -Current $Current } + 'CATemplate' { Invoke-CIPPBaselineCATemplate -Remediate $Rendered -TenantFilter $TenantFilter -Current $Current } + 'IntuneTemplate' { Invoke-CIPPBaselineIntuneTemplate -Remediate $Rendered -TenantFilter $TenantFilter -Current $Current } + 'ActivityBasedTimeout' { Invoke-CIPPBaselineActivityBasedTimeout -Remediate $Rendered -TenantFilter $TenantFilter -Current $Current } + 'DisableBasicAuthSMTP' { Invoke-CIPPBaselineDisableBasicAuthSMTP -Remediate $Rendered -TenantFilter $TenantFilter -Current $Current } default { throw "Unknown remediate executor '$($Definition.remediate.executor)' on $($Definition.name)." } } } catch { diff --git a/backend/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineTeamsRequest.ps1 b/backend/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineTeamsRequest.ps1 index 4eb6e8f5ff..476a61c417 100644 --- a/backend/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineTeamsRequest.ps1 +++ b/backend/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineTeamsRequest.ps1 @@ -16,7 +16,9 @@ function Invoke-CIPPBaselineTeamsRequest { [CmdletBinding()] param( $Remediate, - $TenantFilter + $TenantFilter, + # The read result. Unused here; every executor takes the same arguments. + $Current ) foreach ($Step in @($Remediate.cmdlets)) { From e957a3598d26ccaaff0d61daef62120f25bf2aec Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Sat, 15 Aug 2026 21:28:16 +0200 Subject: [PATCH 068/226] baselines updates, adding pester tests --- .../intuneDeviceRegLocalAdmins.json | 69 +++++ .../intuneRestrictUserDeviceJoin.json | 62 ++++ .../intuneRestrictUserDeviceRegistration.json | 56 ++++ .../Entra (AAD) Standards/laps.json | 43 +++ .../Exchange Standards/OutBoundSpamAlert.json | 2 +- .../Intune Standards/intuneDeviceReg.json | 53 ++++ .../Intune Standards/intuneRequireMFA.json | 36 +++ ...PBaselineDeviceRegistrationPolicyState.ps1 | 45 +++ ...e-CIPPBaselineDeviceRegistrationPolicy.ps1 | 74 +++++ .../Baselines/Invoke-CIPPBaselineStandard.ps1 | 264 ++++++------------ .../BaselineDefinitions.Catalog.Tests.ps1 | 133 +++++++++ .../Baselines/BaselineExecutors.Tests.ps1 | 189 +++++++++++++ .../Baselines/BaselinePrepareHooks.Tests.ps1 | 166 +++++++++++ 13 files changed, 1016 insertions(+), 176 deletions(-) create mode 100644 backend/Config/BaselineStandards/Entra (AAD) Standards/intuneDeviceRegLocalAdmins.json create mode 100644 backend/Config/BaselineStandards/Entra (AAD) Standards/intuneRestrictUserDeviceJoin.json create mode 100644 backend/Config/BaselineStandards/Entra (AAD) Standards/intuneRestrictUserDeviceRegistration.json create mode 100644 backend/Config/BaselineStandards/Entra (AAD) Standards/laps.json create mode 100644 backend/Config/BaselineStandards/Intune Standards/intuneDeviceReg.json create mode 100644 backend/Config/BaselineStandards/Intune Standards/intuneRequireMFA.json create mode 100644 backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineDeviceRegistrationPolicyState.ps1 create mode 100644 backend/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineDeviceRegistrationPolicy.ps1 create mode 100644 backend/Tests/Baselines/BaselineDefinitions.Catalog.Tests.ps1 create mode 100644 backend/Tests/Baselines/BaselineExecutors.Tests.ps1 create mode 100644 backend/Tests/Baselines/BaselinePrepareHooks.Tests.ps1 diff --git a/backend/Config/BaselineStandards/Entra (AAD) Standards/intuneDeviceRegLocalAdmins.json b/backend/Config/BaselineStandards/Entra (AAD) Standards/intuneDeviceRegLocalAdmins.json new file mode 100644 index 0000000000..88fe41d2eb --- /dev/null +++ b/backend/Config/BaselineStandards/Entra (AAD) Standards/intuneDeviceRegLocalAdmins.json @@ -0,0 +1,69 @@ +{ + "name": "intuneDeviceRegLocalAdmins", + "label": "Configure local administrator rights for users joining devices", + "cat": "Entra (AAD) Standards", + "tag": [ + "CIS M365 7.0.0 (5.1.4.3)", + "CIS M365 7.0.0 (5.1.4.4)", + "SMB1001 (2.2)" + ], + "impact": "Medium Impact", + "helpText": "Controls whether users who register Microsoft Entra joined devices are granted local administrator rights on those devices and if Global Administrators are added as local admins.", + "executiveText": "Controls whether employees who enroll devices automatically receive local administrator access. Disabling registering-user admin rights follows least-privilege principles and reduces security risk from over-privileged endpoints.", + "docsDescription": "Configures the Device Registration Policy local administrator behavior for registering users. When enabled, users who register devices are not granted local administrator rights, you can also configure if Global Administrators are added as local admins.", + "impactColour": "warning", + "addedDate": "2026-02-23", + "powershellEquivalent": "Update-MgBetaPolicyDeviceRegistrationPolicy", + "appliesToTest": [ + "CIS_5_1_4_3", + "CIS_5_1_4_4", + "SMB1001_2_2" + ], + "recommendedBy": [], + "requiredCapabilities": [], + "secureScoreImpact": 0, + "compare": "subset", + "variables": { + "registeringUsers": { + "type": "autoComplete", + "multiple": false, + "label": "Registering users as local administrators", + "required": true, + "options": [ + { + "label": "Disabled (registering users are not local administrators)", + "value": "#microsoft.graph.noDeviceRegistrationMembership" + }, + { + "label": "Enabled (registering users become local administrators)", + "value": "#microsoft.graph.allDeviceRegistrationMembership" + } + ], + "default": "#microsoft.graph.noDeviceRegistrationMembership", + "recommended": "#microsoft.graph.noDeviceRegistrationMembership" + }, + "enableGlobalAdmins": { + "type": "switch", + "label": "Allow Global Administrators to be local administrators", + "default": true, + "recommended": true + } + }, + "expected": { + "localAdminsRegisteringUsers": "%registeringUsers%", + "localAdminsEnableGlobalAdmins": "%enableGlobalAdmins%" + }, + "read": { + "cacheType": "DeviceRegistrationPolicy" + }, + "prepare": "Get-CIPPBaselineDeviceRegistrationPolicyState", + "remediate": { + "executor": "DeviceRegistrationPolicy", + "set": { + "azureADJoin.localAdmins.registeringUsers": { + "@odata.type": "%registeringUsers%" + }, + "azureADJoin.localAdmins.enableGlobalAdmins": "%enableGlobalAdmins%" + } + } +} diff --git a/backend/Config/BaselineStandards/Entra (AAD) Standards/intuneRestrictUserDeviceJoin.json b/backend/Config/BaselineStandards/Entra (AAD) Standards/intuneRestrictUserDeviceJoin.json new file mode 100644 index 0000000000..9543e96ee6 --- /dev/null +++ b/backend/Config/BaselineStandards/Entra (AAD) Standards/intuneRestrictUserDeviceJoin.json @@ -0,0 +1,62 @@ +{ + "name": "intuneRestrictUserDeviceJoin", + "label": "Configure user restriction for Entra device join", + "cat": "Entra (AAD) Standards", + "tag": [ + "CIS M365 7.0.0 (5.1.4.1)", + "SMB1001 (2.8)" + ], + "impact": "High Impact", + "helpText": "Controls whether users can join devices to Entra. Tenants where Entra reports this setting as not admin-configurable are reported but not written to.", + "executiveText": "Controls whether employees can join their devices to the corporate Entra directory. Disabling user device join prevents unauthorized or unmanaged devices from becoming corporate-managed identities, enhancing overall security posture.", + "docsDescription": "Configures whether users can join devices to Entra. When disabled, users are unable to Entra-join devices, which prevents them from creating new Entra-joined (cloud-managed) device identities.", + "impactColour": "warning", + "addedDate": "2026-05-15", + "powershellEquivalent": "Update-MgBetaPolicyDeviceRegistrationPolicy", + "appliesToTest": [ + "CIS_5_1_4_1", + "SMB1001_2_8" + ], + "recommendedBy": [], + "requiredCapabilities": [], + "secureScoreImpact": 0, + "compare": "subset", + "variables": { + "allowedToJoin": { + "type": "autoComplete", + "multiple": false, + "label": "Users allowed to join devices to Entra", + "required": true, + "options": [ + { + "label": "No users (disable users from joining devices)", + "value": "#microsoft.graph.noDeviceRegistrationMembership" + }, + { + "label": "All users", + "value": "#microsoft.graph.allDeviceRegistrationMembership" + } + ], + "default": "#microsoft.graph.noDeviceRegistrationMembership", + "recommended": "#microsoft.graph.noDeviceRegistrationMembership" + } + }, + "expected": { + "allowedToJoin": "%allowedToJoin%" + }, + "read": { + "cacheType": "DeviceRegistrationPolicy" + }, + "prepare": "Get-CIPPBaselineDeviceRegistrationPolicyState", + "remediate": { + "executor": "DeviceRegistrationPolicy", + "requireAdminConfigurable": "azureADJoin", + "set": { + "azureADJoin.allowedToJoin": { + "@odata.type": "%allowedToJoin%", + "users": null, + "groups": null + } + } + } +} diff --git a/backend/Config/BaselineStandards/Entra (AAD) Standards/intuneRestrictUserDeviceRegistration.json b/backend/Config/BaselineStandards/Entra (AAD) Standards/intuneRestrictUserDeviceRegistration.json new file mode 100644 index 0000000000..2ac7f85115 --- /dev/null +++ b/backend/Config/BaselineStandards/Entra (AAD) Standards/intuneRestrictUserDeviceRegistration.json @@ -0,0 +1,56 @@ +{ + "name": "intuneRestrictUserDeviceRegistration", + "label": "Configure user restriction for Entra device registration", + "cat": "Entra (AAD) Standards", + "tag": [], + "impact": "High Impact", + "helpText": "Controls whether users can register devices with Entra. Tenants where Entra reports this setting as not admin-configurable (commonly because Intune is enabled) are reported but not written to.", + "executiveText": "Controls whether employees can register their devices for corporate access. Disabling user device registration prevents unauthorized or unmanaged devices from connecting to company resources, enhancing overall security posture.", + "docsDescription": "Configures whether users can register devices with Entra. When disabled, users are unable to register devices with Entra.", + "impactColour": "warning", + "addedDate": "2026-02-23", + "powershellEquivalent": "Update-MgBetaPolicyDeviceRegistrationPolicy", + "appliesToTest": [], + "recommendedBy": [], + "requiredCapabilities": [], + "secureScoreImpact": 0, + "compare": "subset", + "variables": { + "allowedToRegister": { + "type": "autoComplete", + "multiple": false, + "label": "Users allowed to register devices with Entra", + "required": true, + "options": [ + { + "label": "No users (disable users from registering devices)", + "value": "#microsoft.graph.noDeviceRegistrationMembership" + }, + { + "label": "All users", + "value": "#microsoft.graph.allDeviceRegistrationMembership" + } + ], + "default": "#microsoft.graph.noDeviceRegistrationMembership", + "recommended": "#microsoft.graph.noDeviceRegistrationMembership" + } + }, + "expected": { + "allowedToRegister": "%allowedToRegister%" + }, + "read": { + "cacheType": "DeviceRegistrationPolicy" + }, + "prepare": "Get-CIPPBaselineDeviceRegistrationPolicyState", + "remediate": { + "executor": "DeviceRegistrationPolicy", + "requireAdminConfigurable": "azureADRegistration", + "set": { + "azureADRegistration.allowedToRegister": { + "@odata.type": "%allowedToRegister%", + "users": null, + "groups": null + } + } + } +} diff --git a/backend/Config/BaselineStandards/Entra (AAD) Standards/laps.json b/backend/Config/BaselineStandards/Entra (AAD) Standards/laps.json new file mode 100644 index 0000000000..75b3bfe430 --- /dev/null +++ b/backend/Config/BaselineStandards/Entra (AAD) Standards/laps.json @@ -0,0 +1,43 @@ +{ + "name": "laps", + "label": "Enable LAPS on the tenant", + "cat": "Entra (AAD) Standards", + "tag": [ + "CIS M365 7.0.0 (5.1.4.5)", + "SMB1001 (2.2)" + ], + "impact": "Low Impact", + "helpText": "Enables the tenant to use LAPS. You must still create a policy for LAPS to be active on all devices. Use the template standards to deploy this by default.", + "executiveText": "Enables Local Administrator Password Solution (LAPS) capability, which automatically manages and rotates local administrator passwords on company computers. This significantly improves security by preventing the use of shared or static administrator passwords that could be exploited by attackers.", + "docsDescription": "Enables the LAPS functionality on the tenant. Prerequisite for using Windows LAPS via Azure AD.", + "impactColour": "info", + "addedDate": "2023-04-25", + "powershellEquivalent": "Portal or Graph API", + "appliesToTest": [ + "CIS_5_1_4_5", + "SMB1001_2_2", + "ZTNA21953", + "ZTNA21955", + "ZTNA24560" + ], + "recommendedBy": [ + "CIPP" + ], + "requiredCapabilities": [], + "secureScoreImpact": 0, + "compare": "subset", + "variables": {}, + "expected": { + "localAdminPasswordEnabled": true + }, + "read": { + "cacheType": "DeviceRegistrationPolicy" + }, + "prepare": "Get-CIPPBaselineDeviceRegistrationPolicyState", + "remediate": { + "executor": "DeviceRegistrationPolicy", + "set": { + "localAdminPassword.isEnabled": true + } + } +} diff --git a/backend/Config/BaselineStandards/Exchange Standards/OutBoundSpamAlert.json b/backend/Config/BaselineStandards/Exchange Standards/OutBoundSpamAlert.json index dc757e2e5a..6234e572df 100644 --- a/backend/Config/BaselineStandards/Exchange Standards/OutBoundSpamAlert.json +++ b/backend/Config/BaselineStandards/Exchange Standards/OutBoundSpamAlert.json @@ -37,7 +37,7 @@ "OutboundSpamContact": { "type": "textField", "label": "Outbound spam contact", - "default": "" + "required": true } }, "expected": { diff --git a/backend/Config/BaselineStandards/Intune Standards/intuneDeviceReg.json b/backend/Config/BaselineStandards/Intune Standards/intuneDeviceReg.json new file mode 100644 index 0000000000..63936bd5cd --- /dev/null +++ b/backend/Config/BaselineStandards/Intune Standards/intuneDeviceReg.json @@ -0,0 +1,53 @@ +{ + "name": "intuneDeviceReg", + "label": "Set Maximum Number of Devices per user", + "cat": "Intune Standards", + "tag": [ + "CIS M365 7.0.0 (5.1.4.2)", + "CISA (MS.AAD.17.1v1)" + ], + "impact": "Medium Impact", + "helpText": "Sets the maximum number of devices that can be registered by a user. A value of 0 disables device registration by users", + "executiveText": "Limits how many devices each employee can register for corporate access, preventing excessive device proliferation while accommodating legitimate business needs. This helps maintain security oversight and prevents potential abuse of device registration privileges.", + "docsDescription": "Sets the maximum number of devices that can be registered by a user. A value of 0 disables device registration by users", + "impactColour": "warning", + "addedDate": "2023-03-27", + "powershellEquivalent": "Update-MgBetaPolicyDeviceRegistrationPolicy", + "appliesToTest": [ + "CIS_5_1_4_2", + "ZTNA21801", + "ZTNA21802", + "ZTNA21837" + ], + "recommendedBy": [], + "requiredCapabilities": [ + "INTUNE_A", + "MDM_Services", + "EMS", + "SCCM", + "MICROSOFTINTUNEPLAN1" + ], + "secureScoreImpact": 0, + "compare": "subset", + "variables": { + "max": { + "type": "number", + "label": "Maximum devices (Enter 2147483647 for unlimited.)", + "required": true, + "default": 50 + } + }, + "expected": { + "userDeviceQuota": "%max%" + }, + "read": { + "cacheType": "DeviceRegistrationPolicy" + }, + "prepare": "Get-CIPPBaselineDeviceRegistrationPolicyState", + "remediate": { + "executor": "DeviceRegistrationPolicy", + "set": { + "userDeviceQuota": "%max%" + } + } +} diff --git a/backend/Config/BaselineStandards/Intune Standards/intuneRequireMFA.json b/backend/Config/BaselineStandards/Intune Standards/intuneRequireMFA.json new file mode 100644 index 0000000000..d507be5cc7 --- /dev/null +++ b/backend/Config/BaselineStandards/Intune Standards/intuneRequireMFA.json @@ -0,0 +1,36 @@ +{ + "name": "intuneRequireMFA", + "label": "Require Multi-factor Authentication to register or join devices with Microsoft Entra", + "cat": "Intune Standards", + "tag": [], + "impact": "Medium Impact", + "helpText": "Requires MFA for all users to register devices with Intune. This is useful when not using Conditional Access.", + "executiveText": "Requires employees to use multi-factor authentication when registering devices for corporate access, adding an extra security layer to prevent unauthorized device enrollment. This helps ensure only legitimate users can connect their devices to company systems.", + "docsDescription": "Requires MFA for all users to register devices with Intune. This is useful when not using Conditional Access.", + "impactColour": "warning", + "addedDate": "2023-10-23", + "powershellEquivalent": "Update-MgBetaPolicyDeviceRegistrationPolicy", + "appliesToTest": [ + "ZTNA21782", + "ZTNA21796", + "ZTNA21872" + ], + "recommendedBy": [], + "requiredCapabilities": [], + "secureScoreImpact": 0, + "compare": "subset", + "variables": {}, + "expected": { + "multiFactorAuthConfiguration": "required" + }, + "read": { + "cacheType": "DeviceRegistrationPolicy" + }, + "prepare": "Get-CIPPBaselineDeviceRegistrationPolicyState", + "remediate": { + "executor": "DeviceRegistrationPolicy", + "set": { + "multiFactorAuthConfiguration": "required" + } + } +} diff --git a/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineDeviceRegistrationPolicyState.ps1 b/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineDeviceRegistrationPolicyState.ps1 new file mode 100644 index 0000000000..7b4bed4303 --- /dev/null +++ b/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineDeviceRegistrationPolicyState.ps1 @@ -0,0 +1,45 @@ +function Get-CIPPBaselineDeviceRegistrationPolicyState { + <# + .SYNOPSIS + Shared prepare hook for the six standards that govern policies/deviceRegistrationPolicy. + .DESCRIPTION + Flattens the cached policy into one scalar per governed setting. Two reasons it + cannot be read declaratively: + + 1. Three of the settings ARE an '@odata.type' value (allowedToJoin, + allowedToRegister, localAdmins.registeringUsers). Compare-CIPPIntuneObject skips + every property matching '*@OData*' - correctly, because everywhere else that key + is Graph metadata rather than a value. Compared in place they would be silently + ignored, scoring Compliant forever and never remediating. Lifting them to plain + properties is what makes them gradeable. + 2. The projection hands a prepared sub-object to the compare WHOLE, and the compare + reports properties present only on the current side as drift. A nested shape + would therefore flag siblings like isAdminConfigurable. Flat scalars have no + siblings, so each definition grades exactly the keys it declares. + + The write is the raw Graph shape and lives in the executor - the two are deliberately + different vocabularies: this one is for grading, that one is for merging. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Item, + $TenantFilter + ) + + $Policy = @(New-CIPPDbRequest -TenantFilter $TenantFilter -Type 'DeviceRegistrationPolicy' | Where-Object { $_ }) | Select-Object -First 1 + if ($null -eq $Policy) { return @{ Current = $null } } + + @{ + Current = [PSCustomObject]@{ + userDeviceQuota = $Policy.userDeviceQuota + multiFactorAuthConfiguration = $Policy.multiFactorAuthConfiguration + localAdminPasswordEnabled = [bool]$Policy.localAdminPassword.isEnabled + allowedToJoin = "$($Policy.azureADJoin.allowedToJoin.'@odata.type')" + allowedToRegister = "$($Policy.azureADRegistration.allowedToRegister.'@odata.type')" + localAdminsRegisteringUsers = "$($Policy.azureADJoin.localAdmins.registeringUsers.'@odata.type')" + localAdminsEnableGlobalAdmins = [bool]$Policy.azureADJoin.localAdmins.enableGlobalAdmins + } + } +} diff --git a/backend/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineDeviceRegistrationPolicy.ps1 b/backend/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineDeviceRegistrationPolicy.ps1 new file mode 100644 index 0000000000..b53aff955e --- /dev/null +++ b/backend/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineDeviceRegistrationPolicy.ps1 @@ -0,0 +1,74 @@ +function Invoke-CIPPBaselineDeviceRegistrationPolicy { + <# + .SYNOPSIS + DeviceRegistrationPolicy executor: merge-writes one or more settings into + policies/deviceRegistrationPolicy. + .DESCRIPTION + Graph exposes no PATCH here - the whole object goes back on a PUT. Six standards + each own a different field of it, so a write that sent only its own field would + wipe the other five: enforcing the device quota would silently undo LAPS and the + MFA-on-join requirement. This executor GETs the object, assigns only the paths the + definition names, and PUTs the merged result. + + The merge base is a LIVE read, never the cached row: the cache can be hours old, and + merging from it would revert whatever a sibling standard wrote since the last + collection - the exact clobbering this exists to prevent. + + Spec (fully rendered): + set - { "": }, assigned verbatim, so a + membership setting supplies its whole + { '@odata.type', users, groups } object. + requireAdminConfigurable - optional dot-path to a node carrying + isAdminConfigurable. Graph refuses the write when that + is false (commonly because Intune manages the setting), + which is a tenant fact rather than a failure - the step + is skipped with a warning instead of erroring on every + run against most of the fleet. + + Delegated, matching the classic standards: deviceRegistrationPolicy updates are not + supported with application permissions. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Remediate, + $TenantFilter, + # The read result. Unused - the merge base has to be live, see above. + $Current + ) + + $Uri = 'https://graph.microsoft.com/beta/policies/deviceRegistrationPolicy' + $Policy = New-GraphGetRequest -uri $Uri -tenantid $TenantFilter + if ($null -eq $Policy) { throw 'Could not read policies/deviceRegistrationPolicy to merge into.' } + + $Guard = "$($Remediate.requireAdminConfigurable)" + if ($Guard) { + $Node = $Policy + foreach ($Segment in ($Guard -split '\.')) { $Node = $Node.$Segment } + if ($Node.isAdminConfigurable -eq $false) { + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "Device registration policy: $Guard.isAdminConfigurable is false for this tenant, so this setting cannot be changed - skipping the write." -Sev 'Warning' + return + } + } + + $Assigned = 0 + foreach ($Entry in ($Remediate.set ?? [PSCustomObject]@{}).PSObject.Properties) { + $Segments = @($Entry.Name -split '\.') + $Target = $Policy + for ($i = 0; $i -lt ($Segments.Count - 1); $i++) { + $Target = $Target.$($Segments[$i]) + if ($null -eq $Target) { throw "deviceRegistrationPolicy on this tenant has no '$($Entry.Name)' to write." } + } + $Leaf = $Segments[-1] + if ($Target.PSObject.Properties.Name -contains $Leaf) { + $Target.$Leaf = $Entry.Value + } else { + $Target | Add-Member -NotePropertyName $Leaf -NotePropertyValue $Entry.Value -Force + } + $Assigned++ + } + if ($Assigned -eq 0) { throw 'DeviceRegistrationPolicy: nothing configured to write.' } + + $null = New-GraphPostRequest -tenantid $TenantFilter -uri $Uri -Type PUT -Body (ConvertTo-Json -Compress -Depth 10 -InputObject $Policy) -ContentType 'application/json' +} diff --git a/backend/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineStandard.ps1 b/backend/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineStandard.ps1 index b13b6941a5..0a50a6bdbd 100644 --- a/backend/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineStandard.ps1 +++ b/backend/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineStandard.ps1 @@ -4,10 +4,19 @@ function Invoke-CIPPBaselineStandard { Runs one standard instance against one tenant: read, compare, triage, remediate, persist. .DESCRIPTION The engine for a single (tenant, standard) work item from Get-CIPPBaselineWorkItems. - Licensing is handled upstream: Start-CIPPBaselineOrchestrator strips unlicensed pairs - before anything runs. Work items arrive through the durable pipeline as Hashtables - - everything item-derived is normalized before use. Flow: - .FUNCTIONALITY + Licensing is handled upstream by Start-CIPPBaselineOrchestrator. Work items arrive + through the durable pipeline as Hashtables, so item-derived values are normalized + before use. + + There is ONE flow. A standard whose read or write cannot be expressed declaratively + replaces THAT PART ONLY - a prepare hook for the read, a named executor for the + write - and the engine still owns compare, hard gaps, accepted paths, triage, + conflict, deletion and persistence. Both are resolved by naming convention, so + adding either never touches this file: + remediate.executor 'Foo' -> Invoke-CIPPBaselineFoo + delete.executor 'Foo' -> Invoke-CIPPBaselineDeleteFoo + prepare -> the Get-CIPPBaseline*State function it names + .FUNCTIONALITY Internal #> [CmdletBinding()] @@ -23,20 +32,14 @@ function Invoke-CIPPBaselineStandard { $TenantFilter = $Item.TenantFilter $Now = [int64]([datetimeoffset]::UtcNow.ToUnixTimeSeconds()) - # Render a %var% template from this item's variable values: splice each value into the - # serialized template ("%var%" as an exact JSON token keeps its type), then - # Get-CIPPTextReplacement resolves tenant tokens - one %var% syntax. The durable pipeline - # hands the item back as Hashtables, so variables are normalized before enumeration. + # Splices variable values into the serialized template. A key whose value is exactly the + # "%var%" token keeps its JSON type; omitWhenBlank drops such a key entirely so expected + # and remediate specs stay consistent. Tenant tokens resolve last. $Render = { param($Template, $Variables) if ($null -eq $Template) { return $null } if ($Variables -is [System.Collections.IDictionary]) { $Variables = [PSCustomObject]$Variables } $Json = ConvertTo-Json -Compress -Depth 100 -InputObject $Template - # A variable declared omitWhenBlank that is left blank (or never configured) - # removes its key entirely - 'keep the tenant's current value': the setting is - # neither graded nor written. Pruned on the serialized template before - # substitution, so expected AND remediate specs stay consistent (keys whose - # value is exactly the "%var%" token). foreach ($Declared in (($Definition.variables ?? [PSCustomObject]@{}).PSObject.Properties)) { if ($Declared.Value.omitWhenBlank -ne $true) { continue } if (-not [string]::IsNullOrEmpty("$(($Variables ?? [PSCustomObject]@{}).($Declared.Name))")) { continue } @@ -58,26 +61,16 @@ function Invoke-CIPPBaselineStandard { try { $Definition = Get-CIPPBaselineDefinition -Name $Item.BaseName if (-not $Definition) { throw "No definition found for standard $($Item.BaseName)." } - # Package standards are authoring artifacts: the work-item resolver expands them - # into member instances before anything is queued. One reaching the engine is a - # resolver bug - fail loudly rather than comparing a package against nothing. if ($Definition.package) { throw "Package standard $($Item.BaseName) must be expanded by the resolver and never executes directly." } $Label = $Definition.label ?? $Item.Standard - # License gate (moved out of the starter so Run Baseline Now responds instantly - - # the capability lookup happens here, parallel across the durable workers). The - # capabilities cache is per tenant with a 24h TTL, so at most one Graph call per - # tenant per day. A oneoff is an explicit operator ask and bypasses the gate - the - # cache may not know about a license bought after the last sync. - # A flat requiredCapabilities list is any-of. A nested array is a GROUP that must - # also match: every group needs at least one licensed capability (AND of any-of - # groups) - AtpPolicyForO365 needs a SharePoint plan AND a Defender for Office 365 - # plan, exactly like the classic standard's two license gates. + # A flat requiredCapabilities list is any-of; a nested array is a group that must + # also match (AND of any-of groups). $Required = @($Definition.requiredCapabilities) if ($Required.Count -gt 0 -and $Mode -ne 'oneoff') { $Capabilities = $(try { Get-CIPPTenantCapabilities -TenantFilter $TenantFilter } catch { $null }) - # Built as a List: an if-expression's pipeline output unwraps one array - # level, which silently turned every capability into its own AND-group. + # Built as a List: an if-expression's pipeline output unwraps one array level, + # which silently turned every capability into its own AND-group. $Groups = [System.Collections.Generic.List[object]]::new() if (@($Required | Where-Object { $_ -is [System.Array] }).Count -gt 0) { foreach ($Entry in $Required) { $Groups.Add(@($Entry)) } @@ -116,39 +109,27 @@ function Invoke-CIPPBaselineStandard { Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "Started `"$Label`" ($Mode) - Run $RunId" -Sev 'Info' - # There is ONE flow. A standard whose read or write cannot be expressed - # declaratively replaces THAT PART ONLY - a prepare hook for a bespoke read, a - # named executor for a bespoke write - and the engine still owns compare, hard - # gaps, accepted paths, triage, conflict, deletion and persistence. Nothing - # short-circuits past this point: a standard that owned its whole flow was a fork - # of the logic below, and forks silently stop tracking it. - - # Prior resolved row: the deviation lifecycle and manual completion live on it. $ResolvedTable = Get-CippTable -tablename 'BaselineAlignment' $SafeTenant = ConvertTo-CIPPODataFilterValue -Value $TenantFilter $SafeStandard = ConvertTo-CIPPODataFilterValue -Value $Item.Standard + # $anyOf: an expected property may declare several acceptable values, e.g. - # { "$anyOf": ["migrationComplete", null] } - null and 'migrationComplete' both - # mean the auth-policy migration is done. Resolved here so Compare-CIPPIntuneObject - # stays untouched: with -UseCurrent, a current value inside the set resolves to - # itself (the compare sees a match); everywhere the value is displayed or deployed, - # the first non-null entry is the canonical expected value. %var% tokens render - # inside the set like anywhere else. Sets nest inside objects, not inside arrays. + # { "$anyOf": ["migrationComplete", null] }. With -UseCurrent a current value inside + # the set resolves to itself so the compare matches; everywhere the value is + # displayed or deployed, the first non-null entry is canonical. Membership is HARD: + # an empty current only matches an explicit null, a boolean only a real boolean. $ResolveAnyOf = { param($Node, $Current, $UseCurrent) if ($Node -is [System.Collections.IDictionary]) { $Node = [PSCustomObject]$Node } if ($Node -isnot [System.Management.Automation.PSCustomObject]) { - # The comma keeps single-element arrays as arrays - a bare return - # enumerates them, which flattened ['block'] to 'block' and broke - # every array-valued expected property. + # The comma keeps single-element arrays as arrays - a bare return enumerates + # them, which flattened ['block'] to 'block'. if ($Node -is [array]) { return , $Node } return $Node } $Names = @($Node.PSObject.Properties.Name) if ($Names.Count -eq 1 -and $Names[0] -eq '$anyOf') { $Allowed = @($Node.'$anyOf') - # Membership is HARD: an empty current value (null/''/[]) only matches an - # explicit null member, and a boolean member only matches a real boolean. $IsMember = @($Allowed | Where-Object { if ($null -eq $Current -or ('' -eq "$Current" -and $Current -isnot [bool])) { $null -eq $_ } elseif ($_ -is [bool]) { $Current -is [bool] -and $_ -eq $Current } @@ -164,10 +145,8 @@ function Invoke-CIPPBaselineStandard { $Resolved } - # One row per (tenant, standard): RowKey = the sanitized standard name. Rows - # written under the old '-' keys are self-healed here - - # the newest state (by LastRun) becomes Prior so triage survives, and every - # non-canonical sibling is deleted before this run writes the canonical row. + # One row per (tenant, standard). Rows written under the old '-' + # keys are self-healed: newest by LastRun becomes Prior, siblings are deleted. $PriorRows = @(Get-CIPPAzDataTableEntity @ResolvedTable -Filter "PartitionKey eq '$SafeTenant' and StandardName eq '$SafeStandard'") $CanonicalRowKey = $Item.Standard -replace '#', '~' $StaleRows = @($PriorRows | Where-Object { $_.RowKey -ne $CanonicalRowKey }) @@ -176,14 +155,12 @@ function Invoke-CIPPBaselineStandard { } $Prior = $PriorRows | Sort-Object -Property { [int64]($_.LastRun ?? 0) } -Descending | Select-Object -First 1 $PriorStatus = $Prior.Status - # Per-property acceptances (design addendum): parsed up front because they shape the - # compare, the remediation gate, and the resulting status. + + # Per-property verdicts default to 'accept' (tolerate); 'denyDelete' marks the path's + # object for deletion. Both filter the diff; deny-delete parks the row at Delete + # Pending instead of scoring it Accepted. $AcceptedPaths = $(try { $Prior.AcceptedPaths | ConvertFrom-Json } catch { $null }) $AcceptedKeys = @($AcceptedPaths.PSObject.Properties.Name | Where-Object { $_ }) - # Per-path verdicts: entries default to 'accept' (tolerate); 'denyDelete' marks - # the path's object for deletion once delete executors exist. Both filter the - # diff (the operator decided), but deny-delete parks the row at Delete Pending - # instead of scoring it Accepted. $DenyDeleteKeys = @($AcceptedPaths.PSObject.Properties | Where-Object { $_.Name -and $_.Value.verdict -eq 'denyDelete' } | ForEach-Object { $_.Name }) $ExpectedTemplate = & $Render $Definition.expected $Item.Variables $Expected = & $ResolveAnyOf $ExpectedTemplate $null $false @@ -192,12 +169,9 @@ function Invoke-CIPPBaselineStandard { [PSCustomObject]@{ templateName = $Tier.templateName assignedTo = $Tier.assignedTo - # Template-backed (prepare) standards: the rendered declarative expected - # is just a template reference and misleads - show what the tier - # CONFIGURES instead; the full expected value lives on the resolved row. + # For prepare-backed standards the rendered expected is just a template + # reference, so show what the tier CONFIGURES instead. value = $(if ($Definition.prepare) { $Tier.variables } else { & $ResolveAnyOf (& $Render $Definition.expected $Tier.variables) $null $false }) - # Action posture per source, so the UI can show WHY two tiers with - # identical settings still conflict (differing remediate/alert flags). remediateEnabled = [bool]$Tier.remediateEnabled alertEnabled = [bool]$Tier.alertEnabled alertOnRemediate = [bool]$Tier.alertOnRemediate @@ -218,25 +192,18 @@ function Invoke-CIPPBaselineStandard { Remediated = $false Outcome = 'Error' Diff = $null - # RowDiff = the PRE-acceptance per-property deviations, persisted on the - # resolved row so the frontend renders the ENGINE's verdict per property - - # it never re-derives compares (single source of truth). Accepted-path - # tolerated properties stay listed; the UI dims them via acceptedPaths. + # Pre-acceptance per-property deviations. The frontend renders these verbatim + # rather than re-deriving compares, so a custom flow that omits it shows an + # empty property list on a drifted row. RowDiff = @() - # The rendered manual block (taskName/instructions/documentationUrl/reopen) - # persists on the resolved row so the offcanvas can show the operator what - # to actually do. Manual = $null Inheritance = @($Tiers) AlertEvent = $null CacheType = $Definition.read.cacheType } - # 1b0. Conflict: two baselines configure this identity at the same level with - # different settings, so even the expected value is ambiguous - nothing is - # compared, nothing is written to the tenant. The row parks at 'Conflict' (with - # every colliding source in its inheritance tiers) until an operator changes one - # of the baselines. Alerts fire on the transition in. + # Two baselines configure this identity at the same level with different settings, so + # even the expected value is ambiguous: nothing is compared, nothing is written. if ($Item.Conflicted -eq $true) { $Result.ExpectedValue = $null $Result.Outcome = 'Conflict' @@ -248,16 +215,9 @@ function Invoke-CIPPBaselineStandard { return $Result } - # 1b00. Unconfigured REQUIRED variable: the baseline was saved without a value the - # definition cannot substitute for, so the render leaves the raw "%var%" token in the - # spec. That token is not a value - comparing it is permanent drift, and remediating - # it sends the literal string to the API (CSOM/Graph/EXO accept a garbage string for - # a typed setting). Nothing is compared and nothing is written; the row keeps - # whatever it last knew and the operator gets a named error, the way the classic - # standards validated their input and aborted. Only variables the definition marks - # `required` are gated: a blank optional field (an empty exclude group, no - # documentation link) is a legitimate configuration, and omitWhenBlank fields have - # their key pruned rather than left behind. + # A required variable left blank leaves the raw "%var%" token in the spec. That is not + # a value: comparing it is permanent drift and writing it sends the literal string to + # the API. Blank OPTIONAL fields are legitimate, and omitWhenBlank keys are pruned. $ConfiguredVariables = $Item.Variables ?? [PSCustomObject]@{} $Unresolved = @(($Definition.variables ?? [PSCustomObject]@{}).PSObject.Properties | Where-Object { $_.Value.required -eq $true -and @@ -272,7 +232,7 @@ function Invoke-CIPPBaselineStandard { return $Result } - # 1b. Manual tasks: state lives on the resolved row; the operator completes them. + # Manual tasks: state lives on the resolved row; the operator completes them. if ($Definition.manual) { $Manual = & $Render $Definition.manual $Item.Variables $Result.Manual = $Manual @@ -282,10 +242,10 @@ function Invoke-CIPPBaselineStandard { 'weekly' { 7 * 86400 } 'monthly' { 30 * 86400 } 'quarterly' { 91 * 86400 } - default { 0 } # once - never reopens + default { 0 } } if ($Completed -and $ReopenSeconds -gt 0 -and $LastDone -gt 0 -and $Now -ge ($LastDone + $ReopenSeconds)) { - $Completed = $false # the recurrence elapsed - the task is due again + $Completed = $false } $Result.CurrentValue = [PSCustomObject]@{ completed = $Completed } $Result.Compliant = $Completed @@ -302,15 +262,10 @@ function Invoke-CIPPBaselineStandard { return $Result } - # 2. Read the current value from CIPPDb. On a miss, trigger the central collector for - # this cacheType and re-read once - a new standard must be able to run on its first - # pass instead of skipping forever. + # read.array descends into each cached row's nested array and flattens the elements + # into the candidate set BEFORE the filters run. filter.property may be a dot-path. $ReadCurrent = { $Data = @(New-CIPPDbRequest -TenantFilter $TenantFilter -Type $Definition.read.cacheType | Where-Object { $_ }) - # read.array (a dot-path) descends into each cached row's nested array and - # flattens the elements into the candidate set BEFORE the filters run, so a - # definition can select e.g. one entry of authenticationMethodConfigurations - # declaratively. if ($Definition.read.array) { $Data = @($Data | ForEach-Object { $Nested = $_ @@ -323,7 +278,6 @@ function Invoke-CIPPBaselineStandard { $Match = & $Render $Condition.value $Item.Variables $Property = $Condition.property $Data = @($Data | Where-Object { - # filter.property may itself be a dot-path into the candidate. $Candidate = $_ foreach ($Segment in ($Property -split '\.')) { $Candidate = $Candidate.$Segment } switch ($Condition.operator) { @@ -342,32 +296,23 @@ function Invoke-CIPPBaselineStandard { } $Value } - # Pre-check gate, for TEMPLATE standards only - keyed on read.requiredCaches, - # which only they declare (a prepare hook alone no longer implies it, now that - # prepare is the general escape hatch for any bespoke read): their verdicts drive - # policy deploys and their domains change under external hands. - # Wait-CIPPBaselineCacheReady verifies the cache is complete (all required - # family caches collected), recent (3h), and consistent with live state (CA - # live-count probe) - and enforces SINGLE-FLIGHT collection: one job per - # (tenant, cacheType) collects while every parallel peer waits, so activities - # never compare against a half-written cache and users never get race alerts. - # Everything else tolerates normal CIPPDb cadence staleness. + + # Cache pre-check for TEMPLATE standards only, keyed on read.requiredCaches which only + # they declare: their verdicts drive policy deploys and their domains change under + # external hands, so the cache must be complete, recent and live-consistent, collected + # single-flight. Everything else tolerates normal CIPPDb cadence staleness. + # The Where-Object is load-bearing: @($null).Count is 1, so an unfiltered @() test is + # true for every definition that simply omits the property. $JustRefreshed = $false $CacheCollector = Get-Command -Name "Set-CIPPDBCache$($Definition.read.cacheType)" -ErrorAction SilentlyContinue - # The Where-Object is load-bearing: @($null).Count is 1, so an unfiltered @() test - # is true for every definition that simply omits the property. if ($CacheCollector -and @($Definition.read.requiredCaches | Where-Object { $_ }).Count -gt 0) { $JustRefreshed = Wait-CIPPBaselineCacheReady -TenantFilter $TenantFilter -Definition $Definition -RunId $RunId } if ($Definition.prepare) { - # Prepare hook: complex standards (CA/Intune templates) produce their own - # NORMALIZED Expected/Current pair - both sides translated to one canonical - # vocabulary from CIPPDb caches only. The engine still owns everything else: - # compare, hard gaps, accepted paths, triage, conflict, remediation and - # persistence. Collector-on-miss applies exactly like the declarative read. - $PrepareFunction = Get-Command -Name $Definition.prepare -ErrorAction SilentlyContinue - if (-not $PrepareFunction) { throw "Prepare function $($Definition.prepare) is not available." } + if ($Definition.prepare -notmatch '^Get-CIPPBaseline[A-Za-z0-9]+$' -or -not (Get-Command -Name $Definition.prepare -ErrorAction SilentlyContinue)) { + throw "Prepare function $($Definition.prepare) is not available." + } $Prepared = & $Definition.prepare -Item $Item -TenantFilter $TenantFilter if ($null -eq $Prepared.Current -and $CacheCollector -and -not $JustRefreshed) { try { @@ -382,16 +327,10 @@ function Invoke-CIPPBaselineStandard { $Expected = $Prepared.Expected $Result.ExpectedValue = $Expected } - # Fail-safe against poisoned-empty caches: when the WHOLE policy family came - # back empty but this policy was observed live within the last 7 days, a - # failed or flaky collection (Graph intermittently returns empty collections) - # is far more likely than a mass deletion. Report No Data and retry instead - # of declaring every policy missing and fanning out false drift/deploys. A - # genuinely emptied family resumes drifting once the hold ages out (skips - # never advance LastRun). + # Poisoned-empty cache: the WHOLE policy family came back empty but this policy was + # live within 7 days, so a flaky collection is likelier than a mass deletion. + # Report No Data and retry rather than fanning out false drift and deploys. if ($Prepared.EmptyFamily) { - # 'Seen live' means the prior current state carried actual policy data - - # not the missing-policy marker, and not the marker's all-null projection. $PriorCurrentParsed = $(try { $Prior.CurrentValue | ConvertFrom-Json -ErrorAction Stop } catch { $null }) $PriorHadLiveData = if ($PriorCurrentParsed -is [System.Management.Automation.PSCustomObject]) { (-not $PriorCurrentParsed.PSObject.Properties['policyStatus']) -and @@ -420,7 +359,6 @@ function Invoke-CIPPBaselineStandard { } $CheckBeforeRun = $Definition.checkBeforeRun -ne $false - $ReadDefaults = $Definition.read.defaults $Differences = @() $PreFilterDifferences = @() @@ -481,8 +419,7 @@ function Invoke-CIPPBaselineStandard { } } # StrictCompare: properties a prepare declares as always-compared, exact and - # type-strict, regardless of compare type (e.g. isAssigned - the Catalog - # flatten only sees settings arrays and would silently ignore it). + # type-strict, regardless of compare type. foreach ($StrictProperty in @($Prepared.StrictCompare | Where-Object { $_ })) { $ExpectedStrict = $CompareExpected.$StrictProperty $CurrentStrict = $Projected.$StrictProperty @@ -504,7 +441,7 @@ function Invoke-CIPPBaselineStandard { $Differences = @($Merged) } - # An accepted path tolerates that property's drift - and only that property's. + # An accepted path tolerates that property's drift and only that property's. # Prefix matches cover nested paths. $PreFilterDifferences = $Differences if ($AcceptedKeys.Count -gt 0) { @@ -517,29 +454,26 @@ function Invoke-CIPPBaselineStandard { $Result.RowDiff = $PreFilterDifferences } $Compliant = ($null -ne $Current) -and ($Differences.Count -eq 0) - # True when accepted paths actually swallowed drift this run - the row's alignment - # (full or partial) is owed to acceptances, not to the tenant matching the baseline. $PathAccepted = $PreFilterDifferences.Count -gt $Differences.Count if ($Mode -ne 'compare' -and $Definition.delete -and $DenyDeleteKeys.Count -gt 0 -and $null -ne $Current) { $DeletedKeys = [System.Collections.Generic.List[string]]::new() foreach ($DenyKey in $DenyDeleteKeys) { $Target = $Current.$DenyKey - # No target means the object is already gone from the tenant - the - # verdict is stale, drop it rather than calling Graph. + # No target means the object is already gone - the verdict is stale. if (-not $Target -or -not "$($Target.id)") { $DeletedKeys.Add($DenyKey) continue } - # The verdict author is the accountable party - captured before the - # carried-out verdict is cleared, and stamped on the audit event. + # The verdict author is the accountable party, captured before the verdict is + # cleared and stamped on the audit event. $VerdictBy = "$($AcceptedPaths.$DenyKey.by)" try { - switch ($Definition.delete.executor) { - 'IntunePolicy' { Invoke-CIPPBaselineDeleteIntunePolicy -Target $Target -TenantFilter $TenantFilter } - 'CAPolicy' { Invoke-CIPPBaselineDeleteCAPolicy -Target $Target -TenantFilter $TenantFilter } - default { throw "Unknown delete executor '$($Definition.delete.executor)' on $($Definition.name)." } + $DeleteExecutor = "Invoke-CIPPBaselineDelete$($Definition.delete.executor)" + if ($Definition.delete.executor -notmatch '^[A-Za-z0-9]+$' -or -not (Get-Command -Name $DeleteExecutor -ErrorAction SilentlyContinue)) { + throw "Unknown delete executor '$($Definition.delete.executor)' on $($Definition.name)." } + & $DeleteExecutor -Target $Target -TenantFilter $TenantFilter $DeletedKeys.Add($DenyKey) Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "Deleted `"$DenyKey`" for `"$Label`" as ordered by the denied deviation - Run $RunId" -Sev 'Info' # A deletion is irreversible: it gets its own immutable history event @@ -573,34 +507,28 @@ function Invoke-CIPPBaselineStandard { } } - # 4. Status lifecycle + write gate. $Expires = if ("$($Prior.DeviationExpires)" -match '^\d+$') { [int64]$Prior.DeviationExpires } else { 0 } $AcceptActive = $PriorStatus -eq 'Accepted' -and ($Expires -eq 0 -or $Now -lt $Expires) $RemediateOnExpire = $PriorStatus -eq 'Accepted' -and $Expires -gt 0 -and $Now -ge $Expires -and [bool]$Prior.RemediateOnExpire - # A denied deviation is an operator order: remediate regardless of the configured posture. + # A denied deviation is an operator order: remediate regardless of configured posture. $DeniedRemediate = $PriorStatus -eq 'Denied - Remediate Pending' if (-not $Compliant -and $null -ne $Current -and $AcceptActive) { - # Tolerated: no remediation, no alert; Accepted counts aligned (shown as inflating). $Result.Outcome = 'Drift' $Result.Status = 'Accepted' Set-CIPPBaselineResult -Result $Result -Prior $Prior -RunId $RunId return $Result } if (-not $Compliant -and $null -ne $Current -and $PriorStatus -eq 'Denied - Delete Pending') { - # Held: either a ROW-level deny (which never bulk-deletes - deletion is a - # per-object decision, so it waits for per-path verdicts), or a per-path - # verdict whose delete failed this run. Both retry on the next run. + # Either a ROW-level deny (which never bulk-deletes - deletion is a per-object + # decision) or a per-path verdict whose delete failed. Both retry next run. $Result.Outcome = 'Drift' $Result.Status = 'Denied - Delete Pending' Set-CIPPBaselineResult -Result $Result -Prior $Prior -RunId $RunId return $Result } if ($Compliant -and $PathAccepted -and $PriorStatus -ne 'Denied - Remediate Pending') { - # Aligned only because every deviating property is individually triaged: all - # accepts score as Accepted (aligned via acceptance, not compliance); any - # deny-delete verdict with live drift parks the row at Delete Pending until - # delete executors exist. The tolerated diff stays visible in history. + # Aligned only because every deviating property is individually triaged. $DenyDeleteLive = $DenyDeleteKeys.Count -gt 0 -and @($PreFilterDifferences | Where-Object { $Property = $_.Property $DenyDeleteKeys | Where-Object { $Property -eq $_ -or $Property.StartsWith("$_.") } @@ -612,37 +540,27 @@ function Invoke-CIPPBaselineStandard { return $Result } - # An active Accept, a pending delete, or a live path acceptance always blocks - # remediation - including the fail-open path. Remediation writes the WHOLE expected - # object, which would wipe an accepted property's deviation along with the rest. + # Any live triage blocks remediation, including the fail-open path: remediation writes + # the WHOLE expected object and would wipe an accepted property's deviation with it. $PathHold = $AcceptedKeys.Count -gt 0 -and ($PathAccepted -or $null -eq $Current) $TriageHold = $AcceptActive -or $PriorStatus -eq 'Denied - Delete Pending' -or $PathHold $RemediationAllowed = (($Mode -eq 'oneoff') -or ($Mode -eq 'run' -and ($Item.RemediateEnabled -or $RemediateOnExpire -or $DeniedRemediate))) -and -not $TriageHold - # Write only when needed: drift proves it, -Force (manual runs) demands it, and - # checkBeforeRun=false standards cannot prove a write unnecessary. $WriteNeeded = (-not $Compliant) -or $Force.IsPresent -or (-not $CheckBeforeRun) - # Detect standards carry no remediate executor by design - they are report-only - # tripwires; deletion happens only via explicit per-path deny verdicts. + # Detect standards carry no remediate executor by design - report-only tripwires. if ($Mode -ne 'compare' -and $RemediationAllowed -and $WriteNeeded -and $Definition.remediate) { $ExpectedJson = ConvertTo-Json -Compress -Depth 100 -InputObject $Expected try { $Rendered = & $Render $Definition.remediate $Item.Variables - # Every executor takes the same three arguments. -Current is the read - # result (declarative or prepared): a sweep writes to the offender set the - # read computed, and an object-scoped write needs the id it found. Most - # executors ignore it. - switch ($Definition.remediate.executor) { - 'ExoRequest' { Invoke-CIPPBaselineExoRequest -Remediate $Rendered -TenantFilter $TenantFilter -Current $Current } - 'GraphRequest' { Invoke-CIPPBaselineGraphRequest -Remediate $Rendered -TenantFilter $TenantFilter -Current $Current } - 'TeamsRequest' { Invoke-CIPPBaselineTeamsRequest -Remediate $Rendered -TenantFilter $TenantFilter -Current $Current } - 'SPOTenant' { Invoke-CIPPBaselineSPOTenant -Remediate $Rendered -TenantFilter $TenantFilter -Current $Current } - 'CATemplate' { Invoke-CIPPBaselineCATemplate -Remediate $Rendered -TenantFilter $TenantFilter -Current $Current } - 'IntuneTemplate' { Invoke-CIPPBaselineIntuneTemplate -Remediate $Rendered -TenantFilter $TenantFilter -Current $Current } - 'ActivityBasedTimeout' { Invoke-CIPPBaselineActivityBasedTimeout -Remediate $Rendered -TenantFilter $TenantFilter -Current $Current } - 'DisableBasicAuthSMTP' { Invoke-CIPPBaselineDisableBasicAuthSMTP -Remediate $Rendered -TenantFilter $TenantFilter -Current $Current } - default { throw "Unknown remediate executor '$($Definition.remediate.executor)' on $($Definition.name)." } + # Resolved by convention, never a switch: a new executor is one new file. + # Every executor takes the same three arguments; -Current is the read result + # (declarative or prepared), which sweeps and object-scoped writes need and + # everything else ignores. + $ExecutorName = "Invoke-CIPPBaseline$($Definition.remediate.executor)" + if ($Definition.remediate.executor -notmatch '^[A-Za-z0-9]+$' -or -not (Get-Command -Name $ExecutorName -ErrorAction SilentlyContinue)) { + throw "Unknown remediate executor '$($Definition.remediate.executor)' on $($Definition.name)." } + & $ExecutorName -Remediate $Rendered -TenantFilter $TenantFilter -Current $Current } catch { Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "Failed to change `"$Label`" to $ExpectedJson`: $($_.Exception.Message) - Run $RunId" -Sev 'Error' $Result.Outcome = 'Error' @@ -650,7 +568,7 @@ function Invoke-CIPPBaselineStandard { return $Result } Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "Successfully changed `"$Label`" to $ExpectedJson - Run $RunId" -Sev 'Info' - # Optimistic post-write: currentValue = what we wrote; the next run's cache read verifies. + # Optimistic post-write: the next run's cache read verifies it. $Result.CurrentValue = $Expected $Result.Compliant = $true $Result.RowDiff = @() @@ -664,20 +582,16 @@ function Invoke-CIPPBaselineStandard { $Result.Outcome = 'Compliant' $Result.Status = 'Compliant' } elseif ($null -eq $Current) { - # No cache and remediation does not apply (compare mode / report-only): there is - # nothing to honestly report, so nothing is written - the row stays 'No Data' - # and retries next run. + # Nothing to honestly report and remediation does not apply, so nothing is written. Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "$($Item.Standard): no $($Definition.read.cacheType) data in CIPPDb after collection and remediation does not apply - skipped, nothing written." -Sev 'Info' $Result.Outcome = 'Skipped-NoCache' $Result.Status = $PriorStatus ?? 'No Data' return $Result } else { $Result.Outcome = 'Drift' - # A pending deny is an operator order - a run that could not remediate (compare - # mode, or a failed attempt) must not silently clear it. Drift partially covered - # by accepted paths surfaces as Partially Accepted. + # A pending deny is an operator order: a run that could not remediate must not + # silently clear it. Alerts fire on the transition INTO drift, not every run. $Result.Status = if ("$PriorStatus".StartsWith('Denied')) { $PriorStatus } elseif ($PathAccepted) { 'Partially Accepted' } else { 'Drift' } - # Alerts fire on the transition INTO drift (full or partial), not every run. if ($Result.Status -in @('Drift', 'Partially Accepted') -and $PriorStatus -notin @('Drift', 'Partially Accepted') -and $Item.AlertEnabled) { $Result.AlertEvent = 'Drift' } } diff --git a/backend/Tests/Baselines/BaselineDefinitions.Catalog.Tests.ps1 b/backend/Tests/Baselines/BaselineDefinitions.Catalog.Tests.ps1 new file mode 100644 index 0000000000..e0cc2e5904 --- /dev/null +++ b/backend/Tests/Baselines/BaselineDefinitions.Catalog.Tests.ps1 @@ -0,0 +1,133 @@ +# Static invariants over Config/BaselineStandards. These are cheap and they guard the three +# ways a definition can be silently wrong - wrong in the sense that nothing throws, nothing +# logs, and the standard simply stops doing its job on every tenant: +# +# 1. A missing 'requiredCapabilities' property. @($null).Count is 1, so the licence gate +# builds a group containing $null, no capability matches it, and the standard is scored +# 'Skipped - No License' forever. +# 2. A prepare hook or executor whose function does not exist. Both are resolved by naming +# convention at REMEDIATION time, so a typo surfaces as a failed write against a live +# tenant rather than at authoring time - the switch statement this replaced at least +# failed loudly in code review. +# 3. A read.cacheType with no collector, which disables collector-on-miss and parks the +# standard at 'No Data' until some other standard happens to populate the same type. + +BeforeAll { + $script:RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $RepoRoot = $script:RepoRoot + + $script:Definitions = Get-ChildItem -Path (Join-Path $RepoRoot 'Config/BaselineStandards') -Recurse -Filter '*.json' | ForEach-Object { + [PSCustomObject]@{ + File = $_ + Name = $_.BaseName + Definition = Get-Content $_.FullName -Raw | ConvertFrom-Json + } + } + $script:BaselineFunctions = (Get-ChildItem -Path (Join-Path $RepoRoot 'Modules/CIPPCore/Public/Baselines') -Filter '*.ps1').BaseName + $script:CollectorFunctions = (Get-ChildItem -Path (Join-Path $RepoRoot 'Modules/CIPPDB/Public/DBCache') -Filter '*.ps1').BaseName +} + +Describe 'Baseline definition catalog' { + + It 'has at least one definition to check' { + @($script:Definitions).Count | Should -BeGreaterThan 0 + } + + It 'gives every definition a name matching its filename' { + # Get-CIPPBaselineDefinition looks a standard up by file BaseName, so a definition + # whose 'name' disagrees can never be resolved by the work-item resolver. + $Mismatched = @($script:Definitions | Where-Object { $_.Definition.name -ne $_.Name } | ForEach-Object { "$($_.Name) declares name '$($_.Definition.name)'" }) + $Mismatched | Should -BeNullOrEmpty + } + + It 'declares every standard name exactly once' { + $Duplicated = @($script:Definitions.Definition.name | Group-Object | Where-Object Count -gt 1 | ForEach-Object { $_.Name }) + $Duplicated | Should -BeNullOrEmpty + } + + It 'declares requiredCapabilities on every definition, even when empty' { + # Omitting the property is NOT equivalent to an empty array: the engine's + # @($Definition.requiredCapabilities) becomes @($null), whose Count is 1, so the + # standard is skipped as unlicensed on every tenant with no error anywhere. + $Missing = @($script:Definitions | Where-Object { $_.Definition.PSObject.Properties.Name -notcontains 'requiredCapabilities' } | ForEach-Object { $_.Name }) + $Missing | Should -BeNullOrEmpty + } + + It 'resolves every prepare hook to a function, under the name guard the engine enforces' { + $Broken = @($script:Definitions | Where-Object { $_.Definition.prepare } | Where-Object { + $_.Definition.prepare -notmatch '^Get-CIPPBaseline[A-Za-z0-9]+$' -or + $script:BaselineFunctions -notcontains $_.Definition.prepare + } | ForEach-Object { "$($_.Name) -> $($_.Definition.prepare)" }) + $Broken | Should -BeNullOrEmpty + } + + It 'resolves every remediate executor to an Invoke-CIPPBaseline function' { + $Broken = @($script:Definitions | Where-Object { $_.Definition.remediate.executor } | Where-Object { + $_.Definition.remediate.executor -notmatch '^[A-Za-z0-9]+$' -or + $script:BaselineFunctions -notcontains "Invoke-CIPPBaseline$($_.Definition.remediate.executor)" + } | ForEach-Object { "$($_.Name) -> Invoke-CIPPBaseline$($_.Definition.remediate.executor)" }) + $Broken | Should -BeNullOrEmpty + } + + It 'resolves every delete executor to an Invoke-CIPPBaselineDelete function' { + $Broken = @($script:Definitions | Where-Object { $_.Definition.delete.executor } | Where-Object { + $_.Definition.delete.executor -notmatch '^[A-Za-z0-9]+$' -or + $script:BaselineFunctions -notcontains "Invoke-CIPPBaselineDelete$($_.Definition.delete.executor)" + } | ForEach-Object { "$($_.Name) -> Invoke-CIPPBaselineDelete$($_.Definition.delete.executor)" }) + $Broken | Should -BeNullOrEmpty + } + + It 'backs every read.cacheType with a Set-CIPPDBCache collector' { + # Without one the engine cannot collect on a cache miss, so the standard parks at + # 'No Data' on its first pass instead of running. + $Broken = @($script:Definitions | Where-Object { $_.Definition.read.cacheType } | Where-Object { + $script:CollectorFunctions -notcontains "Set-CIPPDBCache$($_.Definition.read.cacheType)" + } | ForEach-Object { "$($_.Name) -> Set-CIPPDBCache$($_.Definition.read.cacheType)" } | Sort-Object -Unique) + $Broken | Should -BeNullOrEmpty + } + + It 'gives every non-package, non-manual definition something to compare' { + $Broken = @($script:Definitions | Where-Object { + -not $_.Definition.package -and -not $_.Definition.manual -and + -not $_.Definition.expected -and -not $_.Definition.prepare + } | ForEach-Object { $_.Name }) + $Broken | Should -BeNullOrEmpty + } + + It 'requires a value for every variable that has no default' { + # An unrequired variable with no default renders as the literal "%var%" token: the + # engine only gates variables marked required, so an unmarked one reaches the compare + # as permanent drift and the write as a garbage string. + $Broken = @($script:Definitions | ForEach-Object { + $Name = $_.Name + ($_.Definition.variables ?? [PSCustomObject]@{}).PSObject.Properties | Where-Object { + $_.Value.required -ne $true -and -not $_.Value.PSObject.Properties['default'] -and -not $_.Value.PSObject.Properties['omitWhenBlank'] + } | ForEach-Object { "$Name.$($_.Name)" } + }) + $Broken | Should -BeNullOrEmpty + } +} + +Describe 'Baseline executor contract' { + + It 'exposes Remediate, TenantFilter and Current on every executor' { + # The engine calls every executor with the same three named arguments. A new executor + # that omits -Current fails at remediation time with a parameter-binding error, on a + # live tenant - nothing binds this contract but this test. + $Executors = @($script:Definitions | ForEach-Object { $_.Definition.remediate.executor } | Where-Object { $_ } | Sort-Object -Unique | ForEach-Object { "Invoke-CIPPBaseline$_" }) + $Executors.Count | Should -BeGreaterThan 0 + + $Broken = @(foreach ($Executor in $Executors) { + $Path = Join-Path $script:RepoRoot "Modules/CIPPCore/Public/Baselines/$Executor.ps1" + if (-not (Test-Path $Path)) { "$Executor (file missing)"; continue } + $Errors = $null + $Ast = [System.Management.Automation.Language.Parser]::ParseFile($Path, [ref]$null, [ref]$Errors) + $Function = $Ast.FindAll({ param($n) $n -is [System.Management.Automation.Language.FunctionDefinitionAst] }, $true) | Select-Object -First 1 + $Parameters = @($Function.Body.ParamBlock.Parameters.Name.VariablePath.UserPath) + foreach ($Required in @('Remediate', 'TenantFilter', 'Current')) { + if ($Parameters -notcontains $Required) { "$Executor is missing -$Required" } + } + }) + $Broken | Should -BeNullOrEmpty + } +} diff --git a/backend/Tests/Baselines/BaselineExecutors.Tests.ps1 b/backend/Tests/Baselines/BaselineExecutors.Tests.ps1 new file mode 100644 index 0000000000..e4f29dba57 --- /dev/null +++ b/backend/Tests/Baselines/BaselineExecutors.Tests.ps1 @@ -0,0 +1,189 @@ +# Executor behaviour. Each of these holds a decision in place that is invisible from the call +# site and expensive to get wrong, because the failure mode is a WRITE to a live tenant: +# +# - GraphRequest drops a PATCH whose body rendered empty. That is what a step looks like once +# omitWhenBlank prunes every key from it ("keep the tenant's current value"), and sending {} +# would be a write the baseline never asked for. +# - ExoRequest routes a step through the Security & Compliance endpoint only when it asks to. +# The *-ProtectionAlert family exists nowhere else, and the flag defaults off, so a +# regression here silently sends compliance cmdlets to Exchange Online. +# - DeviceRegistrationPolicy merges into a LIVE read. Graph has no PATCH for that object, six +# standards each own one field of it, and a write that sent only its own field would wipe +# the other five. + +BeforeAll { + $script:RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $Baselines = Join-Path $script:RepoRoot 'Modules/CIPPCore/Public/Baselines' + + # Parameter binding is case-insensitive, so one casing per name covers every call site. + function New-GraphPostRequest { param($tenantid, $uri, $Type, $Body, $AsApp, $ContentType, $AddedHeaders) } + function New-GraphGetRequest { param($uri, $tenantid, $AsApp, $SkipValueExtraction) } + function New-ExoRequest { param($tenantid, $cmdlet, $cmdParams, $useSystemMailbox, [switch]$Compliance) } + function Write-LogMessage { param($API, $tenant, $message, $Sev, $LogData) } + + . (Join-Path $Baselines 'Invoke-CIPPBaselineGraphRequest.ps1') + . (Join-Path $Baselines 'Invoke-CIPPBaselineExoRequest.ps1') + . (Join-Path $Baselines 'Invoke-CIPPBaselineDeviceRegistrationPolicy.ps1') + + $script:Tenant = 'contoso.onmicrosoft.com' + + # Specs reach an executor already rendered, i.e. as ConvertFrom-Json output. Building the + # fixtures the same way matters: ConvertFrom-Json yields Int64 where a PowerShell literal + # yields Int32, and the compare in the wider engine is type-strict. + function ConvertTo-Spec { param([Parameter(ValueFromPipeline = $true)]$InputObject) process { $InputObject | ConvertTo-Json -Depth 20 | ConvertFrom-Json } } +} + +Describe 'Invoke-CIPPBaselineGraphRequest' { + BeforeEach { Mock New-GraphPostRequest {} } + + It 'skips a PATCH whose body rendered empty' { + $Spec = @{ requests = @(@{ method = 'PATCH'; uri = 'admin/people/pronouns'; body = @{} }) } | ConvertTo-Spec + Invoke-CIPPBaselineGraphRequest -Remediate $Spec -TenantFilter $script:Tenant -Current $null + Should -Invoke New-GraphPostRequest -Times 0 + } + + It 'still sends a PATCH that has something to write' { + $Spec = @{ requests = @(@{ method = 'PATCH'; uri = 'admin/people/pronouns'; body = @{ isEnabledInOrganization = $true } }) } | ConvertTo-Spec + Invoke-CIPPBaselineGraphRequest -Remediate $Spec -TenantFilter $script:Tenant -Current $null + Should -Invoke New-GraphPostRequest -Times 1 -ParameterFilter { $uri -like '*admin/people/pronouns' } + } + + It 'does not treat a bodyless POST as nothing to do' { + # Only PATCH is dropped: a POST with no body can be a legitimate action call. + $Spec = @{ requests = @(@{ method = 'POST'; uri = 'someAction'; body = @{} }) } | ConvertTo-Spec + Invoke-CIPPBaselineGraphRequest -Remediate $Spec -TenantFilter $script:Tenant -Current $null + Should -Invoke New-GraphPostRequest -Times 1 + } + + It 'defaults to app-only and honours a per-step asApp:false' { + $Spec = @{ requests = @( + @{ method = 'PATCH'; uri = 'appOnly'; body = @{ a = 1 } }, + @{ method = 'PATCH'; uri = 'delegated'; asApp = $false; body = @{ a = 1 } } + ) } | ConvertTo-Spec + Invoke-CIPPBaselineGraphRequest -Remediate $Spec -TenantFilter $script:Tenant -Current $null + Should -Invoke New-GraphPostRequest -Times 1 -ParameterFilter { $uri -like '*appOnly' -and $AsApp -eq $true } + Should -Invoke New-GraphPostRequest -Times 1 -ParameterFilter { $uri -like '*delegated' -and $AsApp -eq $false } + } + + It 'continues past a failing step only when it says so' { + Mock New-GraphPostRequest { throw 'already exists' } -ParameterFilter { $uri -like '*first' } + $Tolerated = @{ requests = @( + @{ method = 'POST'; uri = 'first'; body = @{ a = 1 }; continueOnError = $true }, + @{ method = 'PATCH'; uri = 'second'; body = @{ a = 1 } } + ) } | ConvertTo-Spec + { Invoke-CIPPBaselineGraphRequest -Remediate $Tolerated -TenantFilter $script:Tenant -Current $null } | Should -Not -Throw + Should -Invoke New-GraphPostRequest -Times 1 -ParameterFilter { $uri -like '*second' } + + $Fatal = @{ requests = @(@{ method = 'POST'; uri = 'first'; body = @{ a = 1 } }) } | ConvertTo-Spec + { Invoke-CIPPBaselineGraphRequest -Remediate $Fatal -TenantFilter $script:Tenant -Current $null } | Should -Throw + } +} + +Describe 'Invoke-CIPPBaselineExoRequest' { + BeforeEach { Mock New-ExoRequest {} } + + It 'routes a compliance step through the Security and Compliance endpoint' { + $Spec = @{ cmdlets = @(@{ cmdlet = 'Set-ProtectionAlert'; compliance = $true; params = @{ Identity = 'x' } }) } | ConvertTo-Spec + Invoke-CIPPBaselineExoRequest -Remediate $Spec -TenantFilter $script:Tenant -Current $null + Should -Invoke New-ExoRequest -Times 1 -ParameterFilter { $cmdlet -eq 'Set-ProtectionAlert' -and $Compliance.IsPresent } + } + + It 'leaves an ordinary step on Exchange Online' { + $Spec = @{ cmdlets = @(@{ cmdlet = 'Set-TransportConfig'; params = @{ SmtpClientAuthenticationDisabled = $true } }) } | ConvertTo-Spec + Invoke-CIPPBaselineExoRequest -Remediate $Spec -TenantFilter $script:Tenant -Current $null + Should -Invoke New-ExoRequest -Times 1 -ParameterFilter { -not $Compliance.IsPresent } + } + + It 'passes params through as a hashtable of cmdlet arguments' { + $Spec = @{ cmdlets = @(@{ cmdlet = 'Set-HostedOutboundSpamFilterPolicy'; params = @{ Identity = 'Default'; NotifyOutboundSpam = $true } }) } | ConvertTo-Spec + Invoke-CIPPBaselineExoRequest -Remediate $Spec -TenantFilter $script:Tenant -Current $null + Should -Invoke New-ExoRequest -Times 1 -ParameterFilter { + $cmdParams['Identity'] -eq 'Default' -and $cmdParams['NotifyOutboundSpam'] -eq $true + } + } +} + +Describe 'Invoke-CIPPBaselineDeviceRegistrationPolicy' { + BeforeAll { + function New-SamplePolicy { + @{ + userDeviceQuota = 50 + multiFactorAuthConfiguration = 'required' + localAdminPassword = @{ isEnabled = $true } + azureADJoin = @{ + isAdminConfigurable = $true + allowedToJoin = @{ '@odata.type' = '#microsoft.graph.noDeviceRegistrationMembership' } + localAdmins = @{ + registeringUsers = @{ '@odata.type' = '#microsoft.graph.allDeviceRegistrationMembership' } + enableGlobalAdmins = $true + } + } + azureADRegistration = @{ + isAdminConfigurable = $false + allowedToRegister = @{ '@odata.type' = '#microsoft.graph.allDeviceRegistrationMembership' } + } + } | ConvertTo-Spec + } + } + BeforeEach { + Mock New-GraphGetRequest { New-SamplePolicy } + Mock New-GraphPostRequest {} + Mock Write-LogMessage {} + } + + It 'preserves every field it was not asked to change' { + # The whole point of the shared executor: six standards write to this one PUT-only + # object, so a write that sent only its own field would undo the other five. + $Spec = @{ set = @{ userDeviceQuota = 99 } } | ConvertTo-Spec + Invoke-CIPPBaselineDeviceRegistrationPolicy -Remediate $Spec -TenantFilter $script:Tenant -Current $null + Should -Invoke New-GraphPostRequest -Times 1 -ParameterFilter { + $Sent = $Body | ConvertFrom-Json + $Sent.userDeviceQuota -eq 99 -and + $Sent.localAdminPassword.isEnabled -eq $true -and + $Sent.multiFactorAuthConfiguration -eq 'required' -and + $Sent.azureADJoin.localAdmins.enableGlobalAdmins -eq $true -and + $Sent.azureADJoin.allowedToJoin.'@odata.type' -eq '#microsoft.graph.noDeviceRegistrationMembership' + } + } + + It 'assigns a nested dot-path verbatim' { + $Spec = @{ set = @{ 'azureADJoin.allowedToJoin' = @{ '@odata.type' = '#microsoft.graph.allDeviceRegistrationMembership'; users = $null; groups = $null } } } | ConvertTo-Spec + Invoke-CIPPBaselineDeviceRegistrationPolicy -Remediate $Spec -TenantFilter $script:Tenant -Current $null + Should -Invoke New-GraphPostRequest -Times 1 -ParameterFilter { + ($Body | ConvertFrom-Json).azureADJoin.allowedToJoin.'@odata.type' -eq '#microsoft.graph.allDeviceRegistrationMembership' + } + } + + It 'merges from a live read rather than the cached row' { + # Merging a cached object would revert whatever a sibling standard wrote since the + # last collection - the exact clobbering this executor exists to prevent. + $Stale = @{ userDeviceQuota = 1; localAdminPassword = @{ isEnabled = $false } } | ConvertTo-Spec + $Spec = @{ set = @{ userDeviceQuota = 99 } } | ConvertTo-Spec + Invoke-CIPPBaselineDeviceRegistrationPolicy -Remediate $Spec -TenantFilter $script:Tenant -Current $Stale + Should -Invoke New-GraphGetRequest -Times 1 + Should -Invoke New-GraphPostRequest -Times 1 -ParameterFilter { + ($Body | ConvertFrom-Json).localAdminPassword.isEnabled -eq $true + } + } + + It 'skips the write when the branch is not admin-configurable' { + # Common on Intune-enabled tenants. A tenant fact, not a failure: erroring here would + # turn most of the fleet red on every run. + $Spec = @{ requireAdminConfigurable = 'azureADRegistration'; set = @{ 'azureADRegistration.allowedToRegister' = @{ '@odata.type' = '#microsoft.graph.noDeviceRegistrationMembership' } } } | ConvertTo-Spec + Invoke-CIPPBaselineDeviceRegistrationPolicy -Remediate $Spec -TenantFilter $script:Tenant -Current $null + Should -Invoke New-GraphPostRequest -Times 0 + Should -Invoke Write-LogMessage -Times 1 -ParameterFilter { $Sev -eq 'Warning' -and $message -like '*isAdminConfigurable is false*' } + } + + It 'writes when the branch is admin-configurable' { + $Spec = @{ requireAdminConfigurable = 'azureADJoin'; set = @{ 'azureADJoin.allowedToJoin' = @{ '@odata.type' = '#microsoft.graph.noDeviceRegistrationMembership' } } } | ConvertTo-Spec + Invoke-CIPPBaselineDeviceRegistrationPolicy -Remediate $Spec -TenantFilter $script:Tenant -Current $null + Should -Invoke New-GraphPostRequest -Times 1 + } + + It 'refuses to PUT when the spec asks for no changes' { + $Spec = @{ set = @{} } | ConvertTo-Spec + { Invoke-CIPPBaselineDeviceRegistrationPolicy -Remediate $Spec -TenantFilter $script:Tenant -Current $null } | Should -Throw '*nothing configured*' + Should -Invoke New-GraphPostRequest -Times 0 + } +} diff --git a/backend/Tests/Baselines/BaselinePrepareHooks.Tests.ps1 b/backend/Tests/Baselines/BaselinePrepareHooks.Tests.ps1 new file mode 100644 index 0000000000..2f3aa6b43d --- /dev/null +++ b/backend/Tests/Baselines/BaselinePrepareHooks.Tests.ps1 @@ -0,0 +1,166 @@ +# Prepare hooks normalize a bespoke read into something the engine can compare. Each of these +# holds a normalization decision that the definition JSON cannot express and that fails +# SILENTLY if it regresses - the standard reports Compliant and never remediates: +# +# - DeviceRegistrationPolicy lifts three '@odata.type' values to plain properties, because +# Compare-CIPPIntuneObject skips every property matching '*@OData*'. Compared in place they +# would be ignored forever. It stays FLAT because the compare reports properties present +# only on the current side as drift, so a nested shape would flag siblings. +# - DisableBasicAuthSMTP grades the per-user override list only when the point is DISABLING +# SMTP AUTH; an operator who deliberately enabled it has not asked for enablements to be +# stripped. +# - ActivityBasedTimeout reads the timeout out of a JSON string nested inside the policy +# JSON, in both the portal/Graph shape and the legacy root shape. +# +# Fixtures go through ConvertFrom-Json on purpose: that is how New-CIPPDbRequest returns cached +# rows (CippJson preserves ConvertFrom-Json's Int64 number semantics) and how the rendered +# expected side arrives. A PowerShell literal would be Int32 and the type-strict compare would +# report drift that production never sees. + +BeforeAll { + $script:RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $Baselines = Join-Path $script:RepoRoot 'Modules/CIPPCore/Public/Baselines' + + function New-CIPPDbRequest { param($TenantFilter, $Type) } + function Write-LogMessage { param($API, $tenant, $message, $Sev, $LogData) } + + . (Join-Path $script:RepoRoot 'Modules/CIPPCore/Public/Get-CIPPIntuneCompareExclusions.ps1') + . (Join-Path $script:RepoRoot 'Modules/CIPPCore/Public/Compare-CIPPIntuneObject.ps1') + . (Join-Path $Baselines 'Get-CIPPBaselineDeviceRegistrationPolicyState.ps1') + . (Join-Path $Baselines 'Get-CIPPBaselineDisableBasicAuthSMTPState.ps1') + . (Join-Path $Baselines 'Get-CIPPBaselineActivityBasedTimeoutState.ps1') + + $script:Tenant = 'contoso.onmicrosoft.com' + + function ConvertTo-Cached { param([Parameter(ValueFromPipeline = $true)]$InputObject) process { $InputObject | ConvertTo-Json -Depth 20 | ConvertFrom-Json } } + + # Mirrors the engine: project Current down to the Expected keys, then compare. + function Get-Verdict { + param($Expected, $Current) + $Projected = [PSCustomObject]@{} + foreach ($Key in $Expected.PSObject.Properties.Name) { $Projected | Add-Member -NotePropertyName $Key -NotePropertyValue $Current.$Key } + @(Compare-CIPPIntuneObject -ReferenceObject $Expected -DifferenceObject $Projected | Where-Object { $_ }) + } +} + +Describe 'Get-CIPPBaselineDeviceRegistrationPolicyState' { + BeforeAll { + $script:Policy = @{ + userDeviceQuota = 50 + multiFactorAuthConfiguration = 'required' + localAdminPassword = @{ isEnabled = $true } + azureADJoin = @{ + isAdminConfigurable = $true + allowedToJoin = @{ '@odata.type' = '#microsoft.graph.noDeviceRegistrationMembership' } + localAdmins = @{ + registeringUsers = @{ '@odata.type' = '#microsoft.graph.allDeviceRegistrationMembership' } + enableGlobalAdmins = $true + } + } + azureADRegistration = @{ isAdminConfigurable = $false; allowedToRegister = @{ '@odata.type' = '#microsoft.graph.allDeviceRegistrationMembership' } } + } | ConvertTo-Cached + } + BeforeEach { Mock New-CIPPDbRequest { @($script:Policy) } } + + It 'lifts every @odata.type membership value to a plain, comparable property' { + $Current = (Get-CIPPBaselineDeviceRegistrationPolicyState -Item $null -TenantFilter $script:Tenant).Current + $Current.allowedToJoin | Should -Be '#microsoft.graph.noDeviceRegistrationMembership' + $Current.allowedToRegister | Should -Be '#microsoft.graph.allDeviceRegistrationMembership' + $Current.localAdminsRegisteringUsers | Should -Be '#microsoft.graph.allDeviceRegistrationMembership' + } + + It 'flattens every governed setting to a scalar' { + # Nested shapes get handed to the compare whole, which then flags current-only + # siblings such as isAdminConfigurable as drift. + $Current = (Get-CIPPBaselineDeviceRegistrationPolicyState -Item $null -TenantFilter $script:Tenant).Current + foreach ($Property in $Current.PSObject.Properties) { + $Property.Value | Should -Not -BeOfType ([System.Management.Automation.PSCustomObject]) -Because "$($Property.Name) must be a scalar" + } + } + + It 'grades a lifted membership value in both directions' { + # The regression this guards: comparing '@odata.type' in place scores Compliant + # forever, because Compare-CIPPIntuneObject skips that property name. Both cases are + # asserted deliberately - a mismatch alone would also pass if the property were + # always different (e.g. an un-lifted object compared against a string). + $Current = (Get-CIPPBaselineDeviceRegistrationPolicyState -Item $null -TenantFilter $script:Tenant).Current + + $Mismatched = @{ allowedToRegister = '#microsoft.graph.noDeviceRegistrationMembership' } | ConvertTo-Cached + (Get-Verdict -Expected $Mismatched -Current $Current).Count | Should -Be 1 + + $Matching = @{ allowedToRegister = '#microsoft.graph.allDeviceRegistrationMembership' } | ConvertTo-Cached + (Get-Verdict -Expected $Matching -Current $Current).Count | Should -Be 0 + } + + It 'scores a matching quota compliant across the JSON round-trip' { + $Current = (Get-CIPPBaselineDeviceRegistrationPolicyState -Item $null -TenantFilter $script:Tenant).Current + $Expected = @{ userDeviceQuota = 50 } | ConvertTo-Cached + (Get-Verdict -Expected $Expected -Current $Current).Count | Should -Be 0 + } + + It 'reports a null Current when nothing is cached' { + Mock New-CIPPDbRequest { @() } + (Get-CIPPBaselineDeviceRegistrationPolicyState -Item $null -TenantFilter $script:Tenant).Current | Should -BeNullOrEmpty + } +} + +Describe 'Get-CIPPBaselineDisableBasicAuthSMTPState' { + BeforeEach { + Mock Write-LogMessage {} + Mock New-CIPPDbRequest { + switch ($Type) { + 'ExoTransportConfig' { @(@{ SmtpClientAuthenticationDisabled = $script:FlagDisabled } | ConvertTo-Cached) } + 'ExoCASMailboxSmtpAuth' { @($script:Overrides | ConvertTo-Cached) } + } + } + $script:FlagDisabled = $true + $script:Overrides = @() + } + + It 'reports drift while per-user overrides remain, even with the tenant flag correct' { + # The defect the audit found in the first conversion: the tenant-wide flag was + # compliant while individual users kept SMTP AUTH. + $script:Overrides = @(@{ PrimarySmtpAddress = 'bob@contoso.com' }, @{ PrimarySmtpAddress = 'ann@contoso.com' }) + $Prepared = Get-CIPPBaselineDisableBasicAuthSMTPState -Item ([PSCustomObject]@{ Variables = [PSCustomObject]@{ disabled = $true } }) -TenantFilter $script:Tenant + (Get-Verdict -Expected $Prepared.Expected -Current $Prepared.Current).Count | Should -BeGreaterThan 0 + } + + It 'is compliant when the flag is set and no overrides remain' { + $Prepared = Get-CIPPBaselineDisableBasicAuthSMTPState -Item ([PSCustomObject]@{ Variables = [PSCustomObject]@{ disabled = $true } }) -TenantFilter $script:Tenant + (Get-Verdict -Expected $Prepared.Expected -Current $Prepared.Current).Count | Should -Be 0 + } + + It 'does not grade overrides when the operator deliberately enabled SMTP AUTH' { + $script:FlagDisabled = $false + $script:Overrides = @(@{ PrimarySmtpAddress = 'bob@contoso.com' }) + $Prepared = Get-CIPPBaselineDisableBasicAuthSMTPState -Item ([PSCustomObject]@{ Variables = [PSCustomObject]@{ disabled = $false } }) -TenantFilter $script:Tenant + $Prepared.Expected.PSObject.Properties.Name | Should -Not -Contain 'UsersWithSmtpAuthEnabled' + (Get-Verdict -Expected $Prepared.Expected -Current $Prepared.Current).Count | Should -Be 0 + } + + It 'reports a null Current when the transport config is not cached' { + Mock New-CIPPDbRequest { @() } + (Get-CIPPBaselineDisableBasicAuthSMTPState -Item ([PSCustomObject]@{ Variables = [PSCustomObject]@{ disabled = $true } }) -TenantFilter $script:Tenant).Current | Should -BeNullOrEmpty + } +} + +Describe 'Get-CIPPBaselineActivityBasedTimeoutState' { + It 'reads the timeout from the portal and Graph shape' { + $Definition = ConvertTo-Json -Compress -Depth 10 -InputObject @{ ActivityBasedTimeoutPolicy = @{ Version = 1; ApplicationPolicies = @(@{ ApplicationId = 'default'; WebSessionIdleTimeout = '01:00:00' }) } } + Mock New-CIPPDbRequest { @(@{ id = 'p1'; definition = @($Definition) } | ConvertTo-Cached) } + (Get-CIPPBaselineActivityBasedTimeoutState -Item $null -TenantFilter $script:Tenant).Current.timeout | Should -Be '01:00:00' + } + + It 'still reads policies written in the legacy root shape' { + # Written by an early engine build. Without this fallback those tenants report + # permanent drift against a policy that is actually correct. + $Definition = ConvertTo-Json -Compress -Depth 10 -InputObject @{ ActivityBasedTimeoutPolicy = @{ Version = 1; WebSessionIdleTimeout = '06:00:00' } } + Mock New-CIPPDbRequest { @(@{ id = 'p1'; definition = @($Definition) } | ConvertTo-Cached) } + (Get-CIPPBaselineActivityBasedTimeoutState -Item $null -TenantFilter $script:Tenant).Current.timeout | Should -Be '06:00:00' + } + + It 'reports a null Current when nothing is cached' { + Mock New-CIPPDbRequest { @() } + (Get-CIPPBaselineActivityBasedTimeoutState -Item $null -TenantFilter $script:Tenant).Current | Should -BeNullOrEmpty + } +} From 60636ffe4f37f20e877d95741ffb94a98787aafb Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Sat, 15 Aug 2026 21:41:42 +0200 Subject: [PATCH 069/226] new baselines conversion --- .../Entra (AAD) Standards/DisableGuests.json | 60 +++++++++ .../DisableInactiveUsers.json | 62 +++++++++ .../EnforcePrivateGroups.json | 65 ++++++++++ .../PasswordExpireDisabled.json | 50 ++++++++ .../Entra (AAD) Standards/PerUserMFA.json | 58 +++++++++ .../StaleEntraDevices.json | 75 +++++++++++ .../UserPreferredLanguage.json | 53 ++++++++ .../DisableResourceMailbox.json | 55 ++++++++ .../DisableSharedMailbox.json | 52 ++++++++ .../LegacyEmailReportAddins.json | 40 ++++++ .../TeamsDisableResourceAccounts.json | 45 +++++++ .../Get-CIPPBaselineDisableGuestsState.ps1 | 55 ++++++++ ...-CIPPBaselineDisableInactiveUsersState.ps1 | 62 +++++++++ ...IPPBaselineDisableResourceMailboxState.ps1 | 47 +++++++ ...-CIPPBaselineDisableSharedMailboxState.ps1 | 48 +++++++ ...-CIPPBaselineEnforcePrivateGroupsState.ps1 | 38 ++++++ ...PPBaselineLegacyEmailReportAddinsState.ps1 | 41 ++++++ ...IPPBaselinePasswordExpireDisabledState.ps1 | 50 ++++++++ .../Get-CIPPBaselinePerUserMFAState.ps1 | 33 +++++ ...Get-CIPPBaselineStaleEntraDevicesState.ps1 | 58 +++++++++ ...elineTeamsDisableResourceAccountsState.ps1 | 40 ++++++ ...CIPPBaselineUserPreferredLanguageState.ps1 | 34 +++++ .../Invoke-CIPPBaselineGraphBulkSweep.ps1 | 118 ++++++++++++++++++ .../Baselines/BaselineExecutors.Tests.ps1 | 106 ++++++++++++++++ 24 files changed, 1345 insertions(+) create mode 100644 backend/Config/BaselineStandards/Entra (AAD) Standards/DisableGuests.json create mode 100644 backend/Config/BaselineStandards/Entra (AAD) Standards/DisableInactiveUsers.json create mode 100644 backend/Config/BaselineStandards/Entra (AAD) Standards/EnforcePrivateGroups.json create mode 100644 backend/Config/BaselineStandards/Entra (AAD) Standards/PasswordExpireDisabled.json create mode 100644 backend/Config/BaselineStandards/Entra (AAD) Standards/PerUserMFA.json create mode 100644 backend/Config/BaselineStandards/Entra (AAD) Standards/StaleEntraDevices.json create mode 100644 backend/Config/BaselineStandards/Entra (AAD) Standards/UserPreferredLanguage.json create mode 100644 backend/Config/BaselineStandards/Exchange Standards/DisableResourceMailbox.json create mode 100644 backend/Config/BaselineStandards/Exchange Standards/DisableSharedMailbox.json create mode 100644 backend/Config/BaselineStandards/Exchange Standards/LegacyEmailReportAddins.json create mode 100644 backend/Config/BaselineStandards/Teams Standards/TeamsDisableResourceAccounts.json create mode 100644 backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineDisableGuestsState.ps1 create mode 100644 backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineDisableInactiveUsersState.ps1 create mode 100644 backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineDisableResourceMailboxState.ps1 create mode 100644 backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineDisableSharedMailboxState.ps1 create mode 100644 backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineEnforcePrivateGroupsState.ps1 create mode 100644 backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineLegacyEmailReportAddinsState.ps1 create mode 100644 backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselinePasswordExpireDisabledState.ps1 create mode 100644 backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselinePerUserMFAState.ps1 create mode 100644 backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineStaleEntraDevicesState.ps1 create mode 100644 backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineTeamsDisableResourceAccountsState.ps1 create mode 100644 backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineUserPreferredLanguageState.ps1 create mode 100644 backend/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineGraphBulkSweep.ps1 diff --git a/backend/Config/BaselineStandards/Entra (AAD) Standards/DisableGuests.json b/backend/Config/BaselineStandards/Entra (AAD) Standards/DisableGuests.json new file mode 100644 index 0000000000..2af5ca4a2f --- /dev/null +++ b/backend/Config/BaselineStandards/Entra (AAD) Standards/DisableGuests.json @@ -0,0 +1,60 @@ +{ + "name": "DisableGuests", + "label": "Disable Guest accounts that have not logged on for a number of days", + "cat": "Entra (AAD) Standards", + "tag": [ + "SMB1001 (2.8)" + ], + "impact": "Medium Impact", + "helpText": "Blocks login for guest users that have not logged in for a number of days. Guests still pending invitation acceptance are included. Accounts an administrator re-enabled in the last 7 days are left alone.", + "executiveText": "Automatically disables external guest accounts that haven't been used for a number of days, reducing security risks from dormant accounts while maintaining access for active external collaborators. This helps maintain a clean user directory and reduces potential attack vectors.", + "docsDescription": "Blocks login for guest users that have not logged in for a number of days, and for guests that never accepted their invitation.", + "impactColour": "warning", + "addedDate": "2022-10-20", + "powershellEquivalent": "Graph API", + "appliesToTest": [ + "SMB1001_2_8", + "ZTNA21858" + ], + "recommendedBy": [ + "CIS", + "CIPP" + ], + "requiredCapabilities": [ + "AAD_PREMIUM", + "AAD_PREMIUM_P2" + ], + "secureScoreImpact": 0, + "compare": "subset", + "variables": { + "days": { + "type": "number", + "label": "Days of inactivity", + "required": true, + "default": 90 + } + }, + "expected": { + "offenders": [] + }, + "read": { + "cacheType": "Guests" + }, + "prepare": "Get-CIPPBaselineDisableGuestsState", + "remediate": { + "executor": "GraphBulkSweep", + "refreshCache": [ + "Guests", + "Users" + ], + "writes": [ + { + "method": "PATCH", + "uri": "users/%id%", + "body": { + "accountEnabled": false + } + } + ] + } +} diff --git a/backend/Config/BaselineStandards/Entra (AAD) Standards/DisableInactiveUsers.json b/backend/Config/BaselineStandards/Entra (AAD) Standards/DisableInactiveUsers.json new file mode 100644 index 0000000000..c82bbefa36 --- /dev/null +++ b/backend/Config/BaselineStandards/Entra (AAD) Standards/DisableInactiveUsers.json @@ -0,0 +1,62 @@ +{ + "name": "DisableInactiveUsers", + "label": "Disable Member accounts that have not logged on for a number of days", + "cat": "Entra (AAD) Standards", + "tag": [ + "CMMC (IA.L2-3.5.6)", + "NIST SP 800-171 (3.5.6)" + ], + "impact": "High Impact", + "helpText": "Blocks login for cloud-only member users that have not signed in for a configurable number of days (minimum 30). Hybrid (on-premises synced) users are skipped. Users without sign-in activity data are not disabled.", + "executiveText": "Automatically disables unused employee accounts that have not signed in for a configured number of days, reducing risk from dormant accounts and supporting CMMC / NIST inactive-identifier requirements. Hybrid directory-synced accounts are left alone so on-premises identity remains the source of truth for those users.", + "docsDescription": "Disables enabled Member user accounts after a defined period of inactivity (minimum 30 days), supporting CMMC IA.L2-3.5.6 / NIST SP 800-171 3.5.6. Inactivity is based on signInActivity.lastSuccessfulSignInDateTime. Users missing signInActivity entirely are skipped so incomplete Graph data cannot cause accidental disables. Hybrid-synced (onPremisesSyncEnabled) users are skipped because Entra disable often will not stick. Recently re-enabled accounts (last 7 days) are also skipped. Values below 30 days are rejected at runtime.", + "impactColour": "danger", + "addedDate": "2026-07-22", + "powershellEquivalent": "Get-MgUser -Property SignInActivity & Update-MgUser -AccountEnabled $false", + "recommendedBy": [ + "CIPP", + "CMMC" + ], + "requiredCapabilities": [ + "AAD_PREMIUM", + "AAD_PREMIUM_P2" + ], + "secureScoreImpact": 0, + "compare": "subset", + "variables": { + "days": { + "type": "number", + "label": "Days of inactivity (minimum 30)", + "required": true, + "default": 180, + "validators": { + "min": { + "value": 30, + "message": "Minimum value is 30" + } + } + } + }, + "expected": { + "offenders": [] + }, + "read": { + "cacheType": "Users" + }, + "prepare": "Get-CIPPBaselineDisableInactiveUsersState", + "remediate": { + "executor": "GraphBulkSweep", + "refreshCache": [ + "Users" + ], + "writes": [ + { + "method": "PATCH", + "uri": "users/%id%", + "body": { + "accountEnabled": false + } + } + ] + } +} diff --git a/backend/Config/BaselineStandards/Entra (AAD) Standards/EnforcePrivateGroups.json b/backend/Config/BaselineStandards/Entra (AAD) Standards/EnforcePrivateGroups.json new file mode 100644 index 0000000000..fa51f6c566 --- /dev/null +++ b/backend/Config/BaselineStandards/Entra (AAD) Standards/EnforcePrivateGroups.json @@ -0,0 +1,65 @@ +{ + "name": "EnforcePrivateGroups", + "label": "Enforce Private M365 Groups", + "cat": "Entra (AAD) Standards", + "tag": [ + "CIS M365 7.0.0 (1.2.1)" + ], + "impact": "Medium Impact", + "helpText": "Sets all public Microsoft 365 groups to private automatically. Groups can be excluded by display name keyword.", + "executiveText": "Enforces private visibility on all Microsoft 365 groups to prevent unauthorised external access to group resources such as Teams, SharePoint sites, and Planner boards. Approved public groups can be excluded by name, ensuring governance while retaining flexibility for intentionally public collaboration spaces.", + "docsDescription": "Ensures only organisation-managed or approved public groups exist by automatically switching public Microsoft 365 (Unified) groups to private visibility. Groups whose display name matches any of the configured exclusion keywords are left unchanged. This aligns with CIS M365 7.0.0 benchmark control 1.2.1.", + "impactColour": "warning", + "addedDate": "2026-05-06", + "powershellEquivalent": "Update-MgGroup -GroupId -Visibility Private", + "appliesToTest": [ + "CIS_1_2_1" + ], + "recommendedBy": [ + "CIS" + ], + "requiredCapabilities": [ + "SHAREPOINTWAC", + "SHAREPOINTSTANDARD", + "SHAREPOINTENTERPRISE", + "SHAREPOINTENTERPRISE_EDU", + "SHAREPOINTENTERPRISE_GOV", + "ONEDRIVE_BASIC", + "ONEDRIVE_ENTERPRISE" + ], + "secureScoreImpact": 0, + "compare": "subset", + "variables": { + "ExcludedGroupNames": { + "type": "autoComplete", + "multiple": true, + "creatable": true, + "label": "Exclude groups by display name keyword", + "omitWhenBlank": true, + "default": "" + } + }, + "expected": { + "offenders": [] + }, + "read": { + "cacheType": "Groups" + }, + "prepare": "Get-CIPPBaselineEnforcePrivateGroupsState", + "remediate": { + "executor": "GraphBulkSweep", + "refreshCache": [ + "Groups" + ], + "writes": [ + { + "method": "PATCH", + "asApp": false, + "uri": "groups/%id%", + "body": { + "visibility": "Private" + } + } + ] + } +} diff --git a/backend/Config/BaselineStandards/Entra (AAD) Standards/PasswordExpireDisabled.json b/backend/Config/BaselineStandards/Entra (AAD) Standards/PasswordExpireDisabled.json new file mode 100644 index 0000000000..eb8a37a813 --- /dev/null +++ b/backend/Config/BaselineStandards/Entra (AAD) Standards/PasswordExpireDisabled.json @@ -0,0 +1,50 @@ +{ + "name": "PasswordExpireDisabled", + "label": "Do not expire passwords", + "cat": "Entra (AAD) Standards", + "tag": [ + "CIS M365 7.0.0 (1.3.1)", + "PWAgePolicyNew" + ], + "impact": "Low Impact", + "helpText": "Disables the expiration of passwords for the tenant by setting the password expiration policy to never expire for any user. Subdomains inherit their parent's policy and are left alone.", + "executiveText": "Eliminates mandatory password expiration requirements, allowing employees to keep strong passwords indefinitely rather than forcing frequent changes that often lead to weaker passwords. This modern security approach reduces help desk calls and improves overall password security when combined with multi-factor authentication.", + "docsDescription": "Sets passwords to never expire for tenant, recommended to use in conjunction with secure password requirements.", + "impactColour": "info", + "addedDate": "2021-11-16", + "powershellEquivalent": "Update-MgDomain", + "appliesToTest": [ + "CIS_1_3_1", + "ZTNA21811" + ], + "recommendedBy": [ + "CIS", + "CIPP" + ], + "requiredCapabilities": [], + "secureScoreImpact": 0, + "compare": "subset", + "variables": {}, + "expected": { + "offenders": [] + }, + "read": { + "cacheType": "Domains" + }, + "prepare": "Get-CIPPBaselinePasswordExpireDisabledState", + "remediate": { + "executor": "GraphBulkSweep", + "version": "v1.0", + "writes": [ + { + "method": "PATCH", + "asApp": false, + "uri": "domains/%id%", + "body": { + "passwordValidityPeriodInDays": 2147483647, + "passwordNotificationWindowInDays": "%passwordNotificationWindowInDays%" + } + } + ] + } +} diff --git a/backend/Config/BaselineStandards/Entra (AAD) Standards/PerUserMFA.json b/backend/Config/BaselineStandards/Entra (AAD) Standards/PerUserMFA.json new file mode 100644 index 0000000000..c820e34c1b --- /dev/null +++ b/backend/Config/BaselineStandards/Entra (AAD) Standards/PerUserMFA.json @@ -0,0 +1,58 @@ +{ + "name": "PerUserMFA", + "label": "Enables per user MFA for all users.", + "cat": "Entra (AAD) Standards", + "tag": [ + "CISA (MS.AAD.1.1v1)", + "CISA (MS.AAD.1.2v1)", + "Essential 8 (1504)", + "Essential 8 (1173)", + "Essential 8 (1401)", + "NIST CSF 2.0 (PR.AA-03)", + "SMB1001 (2.5)", + "SMB1001 (2.6)", + "SMB1001 (2.9)" + ], + "impact": "High Impact", + "helpText": "Enables per user MFA for all users. The directory synchronisation service account is excluded - it cannot complete MFA and enforcing it breaks sync.", + "executiveText": "Requires all employees to use multi-factor authentication for enhanced account security, significantly reducing the risk of unauthorized access from compromised passwords. This fundamental security measure protects against the majority of account-based attacks and is essential for maintaining strong cybersecurity posture.", + "docsDescription": "Enables per user MFA for all enabled member accounts.", + "impactColour": "danger", + "addedDate": "2024-06-14", + "powershellEquivalent": "Graph API", + "appliesToTest": [ + "SMB1001_2_5", + "SMB1001_2_6", + "SMB1001_2_9", + "ZTNA21780", + "ZTNA21782", + "ZTNA21796" + ], + "recommendedBy": [], + "requiredCapabilities": [], + "secureScoreImpact": 0, + "compare": "subset", + "variables": {}, + "expected": { + "offenders": [] + }, + "read": { + "cacheType": "Users" + }, + "prepare": "Get-CIPPBaselinePerUserMFAState", + "remediate": { + "executor": "GraphBulkSweep", + "refreshCache": [ + "Users" + ], + "writes": [ + { + "method": "PATCH", + "uri": "users/%id%/authentication/requirements", + "body": { + "perUserMFAstate": "enforced" + } + } + ] + } +} diff --git a/backend/Config/BaselineStandards/Entra (AAD) Standards/StaleEntraDevices.json b/backend/Config/BaselineStandards/Entra (AAD) Standards/StaleEntraDevices.json new file mode 100644 index 0000000000..dd1e9d2946 --- /dev/null +++ b/backend/Config/BaselineStandards/Entra (AAD) Standards/StaleEntraDevices.json @@ -0,0 +1,75 @@ +{ + "name": "StaleEntraDevices", + "label": "Cleanup stale Entra devices", + "cat": "Entra (AAD) Standards", + "tag": [ + "Essential 8 (1501)", + "NIST CSF 2.0 (ID.AM-08)", + "NIST CSF 2.0 (PR.PS-03)" + ], + "impact": "High Impact", + "helpText": "Cleans up Entra devices that have not connected/signed in for the specified number of days. Remediation first disables stale enabled devices and, on a later run, deletes stale devices that are already disabled. Hybrid-joined, Intune-managed and Autopilot devices are skipped. Deleting a device permanently removes any BitLocker recovery keys stored on it.", + "executiveText": "Automatically identifies and removes inactive devices that haven't connected to company systems for a specified period, reducing security risks from abandoned or lost devices. This maintains a clean device inventory and prevents potential unauthorized access through dormant device registrations.", + "docsDescription": "Cleans up Entra devices that have not connected/signed in for the specified number of days. Remediation first disables stale enabled devices once they pass the disable threshold, and later deletes devices that are already disabled once they have been inactive for the disable threshold plus the configured grace delta (deletion age = disable threshold + grace days). The disable-before-delete grace period is further guaranteed by never deleting a device in the same pass it was disabled. Hybrid-joined (on-premises synced), Intune-managed/compliant, and system-managed Autopilot devices are excluded. **Warning:** deleting a device permanently removes any BitLocker recovery keys stored on that device object.", + "impactColour": "danger", + "addedDate": "2025-01-19", + "powershellEquivalent": "Remove-MgDevice, Update-MgDevice or Graph API", + "recommendedBy": [], + "requiredCapabilities": [], + "secureScoreImpact": 0, + "compare": "subset", + "variables": { + "deviceAgeThreshold": { + "type": "number", + "label": "Days before stale (disables the device after this many days of inactivity, minimum 30)", + "required": true, + "default": 90, + "validators": { + "min": { + "value": 30, + "message": "Minimum value is 30" + } + } + }, + "deviceDeleteThreshold": { + "type": "number", + "label": "Grace days after disable before deletion (0 = never delete). Devices are deleted once inactive for the disable threshold plus this many additional days.", + "default": 0, + "validators": { + "min": { + "value": 0, + "message": "Minimum value is 0" + } + } + } + }, + "expected": { + "offenders": [] + }, + "read": { + "cacheType": "Devices" + }, + "prepare": "Get-CIPPBaselineStaleEntraDevicesState", + "remediate": { + "executor": "GraphBulkSweep", + "version": "v1.0", + "refreshCache": [ + "Devices" + ], + "writes": [ + { + "from": "devicesToDisable", + "method": "PATCH", + "uri": "devices/%id%", + "body": { + "accountEnabled": false + } + }, + { + "from": "devicesToDelete", + "method": "DELETE", + "uri": "devices/%id%" + } + ] + } +} diff --git a/backend/Config/BaselineStandards/Entra (AAD) Standards/UserPreferredLanguage.json b/backend/Config/BaselineStandards/Entra (AAD) Standards/UserPreferredLanguage.json new file mode 100644 index 0000000000..7297ba79e9 --- /dev/null +++ b/backend/Config/BaselineStandards/Entra (AAD) Standards/UserPreferredLanguage.json @@ -0,0 +1,53 @@ +{ + "name": "UserPreferredLanguage", + "label": "Preferred language for all users", + "cat": "Entra (AAD) Standards", + "tag": [], + "impact": "High Impact", + "helpText": "Sets the preferred language property for all users in the tenant. This will override the user's language settings. Directory-synced accounts are skipped - the property is mastered on premises for those.", + "executiveText": "Standardises the display language across every Microsoft 365 account so employees see a consistent interface, and new accounts inherit the same setting.", + "docsDescription": "Sets the preferred language property for all users in the tenant. This will override the user's language settings.", + "impactColour": "info", + "addedDate": "2025-02-26", + "powershellEquivalent": "Update-MgUser -UserId user@domain.com -BodyParameter @{preferredLanguage='en-US'}", + "recommendedBy": [], + "requiredCapabilities": [], + "secureScoreImpact": 0, + "compare": "subset", + "variables": { + "preferredLanguage": { + "type": "autoComplete", + "multiple": false, + "creatable": false, + "label": "Preferred Language", + "required": true, + "api": { + "url": "/languageList.json", + "labelField": "tag", + "valueField": "tag" + } + } + }, + "expected": { + "offenders": [] + }, + "read": { + "cacheType": "Users" + }, + "prepare": "Get-CIPPBaselineUserPreferredLanguageState", + "remediate": { + "executor": "GraphBulkSweep", + "refreshCache": [ + "Users" + ], + "writes": [ + { + "method": "PATCH", + "uri": "users/%id%", + "body": { + "preferredLanguage": "%preferredLanguage%" + } + } + ] + } +} diff --git a/backend/Config/BaselineStandards/Exchange Standards/DisableResourceMailbox.json b/backend/Config/BaselineStandards/Exchange Standards/DisableResourceMailbox.json new file mode 100644 index 0000000000..c8be7a393f --- /dev/null +++ b/backend/Config/BaselineStandards/Exchange Standards/DisableResourceMailbox.json @@ -0,0 +1,55 @@ +{ + "name": "DisableResourceMailbox", + "label": "Disable Unlicensed Resource Mailbox Entra accounts", + "cat": "Exchange Standards", + "tag": [ + "NIST CSF 2.0 (PR.AA-01)", + "SMB1001 (2.3)" + ], + "impact": "Medium Impact", + "helpText": "Blocks login for all accounts that are marked as a resource mailbox and does not have a license assigned. Accounts that are synced from on-premises AD are excluded, as account state is managed in the on-premises AD.", + "executiveText": "Prevents direct login to resource mailbox accounts (like conference rooms or equipment), ensuring they can only be managed through proper administrative channels. This security measure eliminates potential unauthorized access to resource scheduling systems while maintaining proper booking functionality.", + "docsDescription": "Resource mailboxes can be directly logged into if the password is reset, this presents a security risk as do all shared login credentials. Microsoft's recommendation is to disable the user account for resource mailboxes. Accounts that are synced from on-premises AD are excluded, as account state is managed in the on-premises AD.", + "impactColour": "warning", + "addedDate": "2025-06-01", + "powershellEquivalent": "Get-Mailbox & Update-MgUser", + "appliesToTest": [ + "SMB1001_2_3" + ], + "recommendedBy": [ + "Microsoft", + "CIPP" + ], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ], + "secureScoreImpact": 0, + "compare": "subset", + "variables": {}, + "expected": { + "offenders": [] + }, + "read": { + "cacheType": "Mailboxes" + }, + "prepare": "Get-CIPPBaselineDisableResourceMailboxState", + "remediate": { + "executor": "GraphBulkSweep", + "refreshCache": [ + "Users" + ], + "writes": [ + { + "method": "PATCH", + "uri": "users/%id%", + "body": { + "accountEnabled": false + } + } + ] + } +} diff --git a/backend/Config/BaselineStandards/Exchange Standards/DisableSharedMailbox.json b/backend/Config/BaselineStandards/Exchange Standards/DisableSharedMailbox.json new file mode 100644 index 0000000000..6650c3e578 --- /dev/null +++ b/backend/Config/BaselineStandards/Exchange Standards/DisableSharedMailbox.json @@ -0,0 +1,52 @@ +{ + "name": "DisableSharedMailbox", + "label": "Disable Shared Mailbox Entra accounts", + "cat": "Exchange Standards", + "tag": [ + "CIS M365 7.0.0 (1.2.2)", + "CISA (MS.AAD.10.1v1)", + "NIST CSF 2.0 (PR.AA-01)", + "SMB1001 (2.3)" + ], + "impact": "Medium Impact", + "helpText": "Blocks login for all accounts that are marked as a shared mailbox. This is Microsoft best practice to prevent direct logons to shared mailboxes. Directory-synced accounts are excluded.", + "executiveText": "Prevents direct login to shared mailbox accounts (like info@company.com), ensuring they can only be accessed through authorized users accounts. This security measure eliminates the risk of shared passwords and unauthorized access while maintaining proper access control and audit trails.", + "docsDescription": "Shared mailboxes can be directly logged into if the password is reset, this presents a security risk as do all shared login credentials. Microsoft's recommendation is to disable the user account for shared mailboxes. It would be a good idea to review the sign-in reports to establish potential impact.", + "impactColour": "warning", + "addedDate": "2021-11-16", + "powershellEquivalent": "Get-Mailbox & Update-MgUser", + "appliesToTest": [ + "CIS_1_2_2", + "SMB1001_2_3" + ], + "recommendedBy": [ + "CIS", + "CIPP" + ], + "requiredCapabilities": [], + "secureScoreImpact": 0, + "compare": "subset", + "variables": {}, + "expected": { + "offenders": [] + }, + "read": { + "cacheType": "Mailboxes" + }, + "prepare": "Get-CIPPBaselineDisableSharedMailboxState", + "remediate": { + "executor": "GraphBulkSweep", + "refreshCache": [ + "Users" + ], + "writes": [ + { + "method": "PATCH", + "uri": "users/%id%", + "body": { + "accountEnabled": false + } + } + ] + } +} diff --git a/backend/Config/BaselineStandards/Exchange Standards/LegacyEmailReportAddins.json b/backend/Config/BaselineStandards/Exchange Standards/LegacyEmailReportAddins.json new file mode 100644 index 0000000000..44aba480fe --- /dev/null +++ b/backend/Config/BaselineStandards/Exchange Standards/LegacyEmailReportAddins.json @@ -0,0 +1,40 @@ +{ + "name": "LegacyEmailReportAddins", + "label": "Remove legacy Outlook Report add-ins", + "cat": "Exchange Standards", + "tag": [], + "impact": "Low Impact", + "helpText": "Removes legacy Report Phishing and Report Message Outlook add-ins.", + "executiveText": "The legacy Report Phishing and Report Message Outlook add-ins are security issues with the add-in which makes them unsafe for the organization.", + "docsDescription": "Removes the retired Report Phishing and Report Message Outlook add-in app registrations. Compliance is the absence of both, so a tenant that never had them reports compliant rather than 'No Data'.", + "impactColour": "info", + "addedDate": "2025-08-26", + "powershellEquivalent": "None", + "recommendedBy": [ + "Microsoft" + ], + "requiredCapabilities": [], + "secureScoreImpact": 0, + "compare": "subset", + "variables": {}, + "expected": { + "offenders": [] + }, + "read": { + "cacheType": "Apps" + }, + "prepare": "Get-CIPPBaselineLegacyEmailReportAddinsState", + "remediate": { + "executor": "GraphBulkSweep", + "refreshCache": [ + "Apps" + ], + "writes": [ + { + "method": "DELETE", + "asApp": false, + "uri": "applications/%id%" + } + ] + } +} diff --git a/backend/Config/BaselineStandards/Teams Standards/TeamsDisableResourceAccounts.json b/backend/Config/BaselineStandards/Teams Standards/TeamsDisableResourceAccounts.json new file mode 100644 index 0000000000..59d65789ed --- /dev/null +++ b/backend/Config/BaselineStandards/Teams Standards/TeamsDisableResourceAccounts.json @@ -0,0 +1,45 @@ +{ + "name": "TeamsDisableResourceAccounts", + "label": "Block sign-in for Teams resource accounts", + "cat": "Teams Standards", + "tag": [ + "NIST CSF 2.0 (PR.AA-01)" + ], + "impact": "Medium Impact", + "helpText": "Blocks sign-in for all Teams resource accounts used by Auto Attendants and Call Queues. Microsoft's guidance is to block sign-in for resource accounts as they do not require an interactive login to function.", + "executiveText": "Prevents direct login to the service accounts that power phone system features like Auto Attendants and Call Queues. These accounts work without anyone signing into them, so blocking sign-in removes an unnecessary attack surface while keeping the phone system fully functional.", + "docsDescription": "Teams resource accounts (the accounts backing Auto Attendants and Call Queues) do not require interactive sign-in to function. If sign-in is enabled and the password is reset, the account can be logged into directly, which presents a security risk. Microsoft's guidance is to block sign-in for these accounts. Accounts that are synced from on-premises AD are excluded, as account state is managed in the on-premises AD.", + "impactColour": "warning", + "addedDate": "2026-07-17", + "powershellEquivalent": "Get-CsOnlineApplicationInstance & Update-MgUser", + "recommendedBy": [ + "Microsoft", + "CIPP" + ], + "requiredCapabilities": [], + "secureScoreImpact": 0, + "compare": "subset", + "variables": {}, + "expected": { + "offenders": [] + }, + "read": { + "cacheType": "TeamsResourceAccounts" + }, + "prepare": "Get-CIPPBaselineTeamsDisableResourceAccountsState", + "remediate": { + "executor": "GraphBulkSweep", + "refreshCache": [ + "Users" + ], + "writes": [ + { + "method": "PATCH", + "uri": "users/%id%", + "body": { + "accountEnabled": false + } + } + ] + } +} diff --git a/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineDisableGuestsState.ps1 b/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineDisableGuestsState.ps1 new file mode 100644 index 0000000000..a014a5bfc0 --- /dev/null +++ b/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineDisableGuestsState.ps1 @@ -0,0 +1,55 @@ +function Get-CIPPBaselineDisableGuestsState { + <# + .SYNOPSIS + Prepare hook for DisableGuests: enabled guests that are stale or never accepted their + invitation. + .DESCRIPTION + Read live rather than from the Guests cache: that collector expands sponsors but + selects neither signInActivity nor externalUserState, and both decide the verdict here. + Extending it would let this move to cache like the other user sweeps. + + A guest counts when it has not signed in within the window, OR when it is still + PendingAcceptance - an invitation nobody ever took up is exactly the account this is + meant to close. Accounts an admin re-enabled in the last 7 days are left alone. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Item, + $TenantFilter + ) + + $CheckDays = if ([string]::IsNullOrWhiteSpace("$($Item.Variables.days)")) { 90 } else { [int]$Item.Variables.days } + $Cutoff = (Get-Date).AddDays(-$CheckDays).ToUniversalTime() + $Lookup = $Cutoff.ToString('o') + + $Guests = @(New-GraphGetRequest -uri "https://graph.microsoft.com/beta/users?`$filter=createdDateTime le $Lookup and userType eq 'Guest' and accountEnabled eq true&`$select=id,userPrincipalName,signInActivity,mail,userType,accountEnabled,createdDateTime,externalUserState" -scope 'https://graph.microsoft.com/.default' -tenantid $TenantFilter) + + $Stale = @($Guests | Where-Object { + if ($_.signInActivity -and $_.signInActivity.lastSuccessfulSignInDateTime) { + ([datetime]$_.signInActivity.lastSuccessfulSignInDateTime).ToUniversalTime() -le $Cutoff + } else { + $_.externalUserState -eq 'PendingAcceptance' + } + }) + + if ($Stale.Count -gt 0) { + $AuditLookup = (Get-Date).AddDays(-7).ToUniversalTime().ToString('o') + $Reactivated = @(try { + $Audits = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/auditLogs/directoryAudits?`$filter=activityDisplayName eq 'Enable account' and activityDateTime ge $AuditLookup&`$select=targetResources" -scope 'https://graph.microsoft.com/.default' -tenantid $TenantFilter + @($Audits | ForEach-Object { $_.targetResources[0].id }) | Select-Object -Unique + } catch { + Write-Information "Baselines: reactivation audit lookup on $TenantFilter failed: $($_.Exception.Message)" + @() + }) + $Stale = @($Stale | Where-Object { $Reactivated -notcontains $_.id }) + } + + @{ + Current = [PSCustomObject]@{ + offenders = @($Stale | ForEach-Object { "$($_.userPrincipalName ?? $_.mail)" } | Sort-Object) + targets = @($Stale | ForEach-Object { [PSCustomObject]@{ id = "$($_.id)" } }) + } + } +} diff --git a/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineDisableInactiveUsersState.ps1 b/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineDisableInactiveUsersState.ps1 new file mode 100644 index 0000000000..7c9128ce74 --- /dev/null +++ b/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineDisableInactiveUsersState.ps1 @@ -0,0 +1,62 @@ +function Get-CIPPBaselineDisableInactiveUsersState { + <# + .SYNOPSIS + Prepare hook for DisableInactiveUsers: enabled cloud-only members who have not signed + in within the configured window. + .DESCRIPTION + Cache-backed except for one live query. The Users cache carries createdDateTime and, + on tenants licensed for sign-in logs, signInActivity - a tenant without that licence + has no signInActivity at all, so no user can be judged inactive and the offender set + is empty, exactly as the classic standard behaved. + + The live query is the reactivation grace period: an account an admin deliberately + re-enabled in the last 7 days is left alone, otherwise the sweep would fight the + admin every night. directoryAudits is not cached anywhere and is a single small read. + + A threshold under 30 days is refused rather than clamped - the classic standard + aborted for the same reason, since a low value turns this into a mass account + disablement. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Item, + $TenantFilter + ) + + $CheckDays = if ([string]::IsNullOrWhiteSpace("$($Item.Variables.days)")) { 180 } else { [int]$Item.Variables.days } + if ($CheckDays -lt 30) { throw "DisableInactiveUsers: a threshold of $CheckDays days is below the 30-day floor - refusing to run to prevent mass account changes." } + + $Users = @(New-CIPPDbRequest -TenantFilter $TenantFilter -Type 'Users' | Where-Object { $_ }) + if ($Users.Count -eq 0) { return @{ Current = $null } } + + $Cutoff = (Get-Date).AddDays(-$CheckDays).ToUniversalTime() + $Inactive = @($Users | Where-Object { + $_.userType -eq 'Member' -and + $_.accountEnabled -eq $true -and + $_.onPremisesSyncEnabled -ne $true -and + $_.createdDateTime -and ([datetime]$_.createdDateTime).ToUniversalTime() -le $Cutoff -and + $_.signInActivity -and $_.signInActivity.lastSuccessfulSignInDateTime -and + ([datetime]$_.signInActivity.lastSuccessfulSignInDateTime).ToUniversalTime() -le $Cutoff + }) + + if ($Inactive.Count -gt 0) { + $AuditLookup = (Get-Date).AddDays(-7).ToUniversalTime().ToString('o') + $Reactivated = @(try { + $Audits = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/auditLogs/directoryAudits?`$filter=activityDisplayName eq 'Enable account' and activityDateTime ge $AuditLookup&`$select=targetResources" -scope 'https://graph.microsoft.com/.default' -tenantid $TenantFilter + @($Audits | ForEach-Object { $_.targetResources[0].id }) | Select-Object -Unique + } catch { + Write-Information "Baselines: reactivation audit lookup on $TenantFilter failed: $($_.Exception.Message)" + @() + }) + $Inactive = @($Inactive | Where-Object { $Reactivated -notcontains $_.id }) + } + + @{ + Current = [PSCustomObject]@{ + offenders = @($Inactive.userPrincipalName | Sort-Object) + targets = @($Inactive | ForEach-Object { [PSCustomObject]@{ id = "$($_.id)" } }) + } + } +} diff --git a/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineDisableResourceMailboxState.ps1 b/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineDisableResourceMailboxState.ps1 new file mode 100644 index 0000000000..0a6ea0fd80 --- /dev/null +++ b/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineDisableResourceMailboxState.ps1 @@ -0,0 +1,47 @@ +function Get-CIPPBaselineDisableResourceMailboxState { + <# + .SYNOPSIS + Prepare hook for DisableResourceMailbox: room and equipment mailboxes whose Entra + account is still enabled. + .DESCRIPTION + A join the declarative read cannot do: the mailbox type lives in the Mailboxes cache, + the account state in the Users cache, and they meet on ExternalDirectoryObjectId. + Both are cached, so this needs no live call - the classic standard read Get-Mailbox + live because the cache did not carry recipientTypeDetails at the time. + + Only unlicensed cloud-only members qualify: a licensed account behind a room mailbox + is somebody's real sign-in, and disabling a directory-synced one is rejected anyway. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Item, + $TenantFilter + ) + + $Users = @(New-CIPPDbRequest -TenantFilter $TenantFilter -Type 'Users' | Where-Object { $_ }) + $Mailboxes = @(New-CIPPDbRequest -TenantFilter $TenantFilter -Type 'Mailboxes' | Where-Object { $_ }) + if ($Users.Count -eq 0 -or $Mailboxes.Count -eq 0) { return @{ Current = $null } } + + $Candidates = @{} + foreach ($User in $Users) { + if ($User.accountEnabled -ne $true) { continue } + if ($User.onPremisesSyncEnabled -eq $true) { continue } + if ($User.userType -ne 'Member') { continue } + if (@($User.assignedLicenses).Count -gt 0) { continue } + $Candidates["$($User.id)"] = $User + } + + $Offending = @($Mailboxes | Where-Object { + $_.recipientTypeDetails -in @('RoomMailbox', 'EquipmentMailbox') -and + $Candidates.ContainsKey("$($_.ExternalDirectoryObjectId)") + }) + + @{ + Current = [PSCustomObject]@{ + offenders = @($Offending | ForEach-Object { "$($_.UPN ?? $_.primarySmtpAddress)" } | Sort-Object) + targets = @($Offending | ForEach-Object { [PSCustomObject]@{ id = "$($_.ExternalDirectoryObjectId)" } }) + } + } +} diff --git a/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineDisableSharedMailboxState.ps1 b/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineDisableSharedMailboxState.ps1 new file mode 100644 index 0000000000..8004d23a2e --- /dev/null +++ b/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineDisableSharedMailboxState.ps1 @@ -0,0 +1,48 @@ +function Get-CIPPBaselineDisableSharedMailboxState { + <# + .SYNOPSIS + Prepare hook for DisableSharedMailbox: shared and scheduling mailboxes whose Entra + account is still enabled. + .DESCRIPTION + Same join as DisableResourceMailbox, on the Mailboxes and Users caches. The classic + standard read the adminapi Mailbox endpoint live; the cache carries + recipientTypeDetails and ExternalDirectoryObjectId, so no live call is needed. + + NOTE - a deliberate behaviour change. The classic filter read + RecipientTypeDetails -eq 'SharedMailbox' -or RecipientTypeDetails -eq 'SchedulingMailbox' -and UserPrincipalName -in $UserList + and -and binds tighter than -or, so the enabled/cloud-only test only ever applied to + SchedulingMailbox. Every shared mailbox was swept regardless, including ones whose + account was already disabled or directory-synced. The join here applies to both types, + which is what the standard's own description says it does. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Item, + $TenantFilter + ) + + $Users = @(New-CIPPDbRequest -TenantFilter $TenantFilter -Type 'Users' | Where-Object { $_ }) + $Mailboxes = @(New-CIPPDbRequest -TenantFilter $TenantFilter -Type 'Mailboxes' | Where-Object { $_ }) + if ($Users.Count -eq 0 -or $Mailboxes.Count -eq 0) { return @{ Current = $null } } + + $Candidates = @{} + foreach ($User in $Users) { + if ($User.accountEnabled -ne $true) { continue } + if ($User.onPremisesSyncEnabled -eq $true) { continue } + $Candidates["$($User.id)"] = $User + } + + $Offending = @($Mailboxes | Where-Object { + $_.recipientTypeDetails -in @('SharedMailbox', 'SchedulingMailbox') -and + $Candidates.ContainsKey("$($_.ExternalDirectoryObjectId)") + }) + + @{ + Current = [PSCustomObject]@{ + offenders = @($Offending | ForEach-Object { "$($_.UPN ?? $_.primarySmtpAddress)" } | Sort-Object) + targets = @($Offending | ForEach-Object { [PSCustomObject]@{ id = "$($_.ExternalDirectoryObjectId)" } }) + } + } +} diff --git a/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineEnforcePrivateGroupsState.ps1 b/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineEnforcePrivateGroupsState.ps1 new file mode 100644 index 0000000000..02dba601cf --- /dev/null +++ b/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineEnforcePrivateGroupsState.ps1 @@ -0,0 +1,38 @@ +function Get-CIPPBaselineEnforcePrivateGroupsState { + <# + .SYNOPSIS + Prepare hook for EnforcePrivateGroups: public Microsoft 365 groups that are not excluded. + .DESCRIPTION + Exclusions are keyword CONTAINS matches on the display name, not exact names - the + classic standard used -match on an escaped keyword so an operator can exclude a whole + naming convention with one entry. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Item, + $TenantFilter + ) + + $Groups = @(New-CIPPDbRequest -TenantFilter $TenantFilter -Type 'Groups' | Where-Object { $_ }) + if ($Groups.Count -eq 0) { return @{ Current = $null } } + + $Keywords = @(@($Item.Variables.ExcludedGroupNames) | ForEach-Object { + if ($_ -is [string]) { $_ } else { "$($_.value ?? $_.label)" } + } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) + + $Public = @($Groups | Where-Object { + $_.groupTypes -contains 'Unified' -and $_.visibility -eq 'Public' + } | Where-Object { + $DisplayName = "$($_.displayName)" + -not @($Keywords | Where-Object { $DisplayName -match [regex]::Escape($_) }) + }) + + @{ + Current = [PSCustomObject]@{ + offenders = @($Public.displayName | Sort-Object) + targets = @($Public | ForEach-Object { [PSCustomObject]@{ id = "$($_.id)" } }) + } + } +} diff --git a/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineLegacyEmailReportAddinsState.ps1 b/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineLegacyEmailReportAddinsState.ps1 new file mode 100644 index 0000000000..29e6953cd7 --- /dev/null +++ b/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineLegacyEmailReportAddinsState.ps1 @@ -0,0 +1,41 @@ +function Get-CIPPBaselineLegacyEmailReportAddinsState { + <# + .SYNOPSIS + Prepare hook for LegacyEmailReportAddins: app registrations carrying a retired Report + Message or Report Phishing add-in. + .DESCRIPTION + Compliance here is ABSENCE, which a declarative read cannot express: a filter that + matches nothing yields a null Current, and the engine reads that as 'not collected' + rather than 'clean'. Returning an empty offender list against an empty expected list + is the honest way to say the tenant has none. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Item, + $TenantFilter + ) + + $Apps = @(New-CIPPDbRequest -TenantFilter $TenantFilter -Type 'Apps' | Where-Object { $_ }) + if ($Apps.Count -eq 0) { return @{ Current = $null } } + + $Legacy = @{ + '3f32746a-0586-4c54-b8ce-d3b611c5b6c8' = 'Report Phishing' + '6046742c-3aee-485e-a4ac-92ab7199db2e' = 'Report Message' + } + + $Installed = @($Apps | Where-Object { + @($_.addIns | Where-Object { $Legacy.ContainsKey("$($_.id)") }).Count -gt 0 + }) + + @{ + Current = [PSCustomObject]@{ + offenders = @($Installed | ForEach-Object { + $App = $_ + @($App.addIns | Where-Object { $Legacy.ContainsKey("$($_.id)") } | ForEach-Object { $Legacy["$($_.id)"] }) + } | Sort-Object -Unique) + targets = @($Installed | ForEach-Object { [PSCustomObject]@{ id = "$($_.id)" } }) + } + } +} diff --git a/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselinePasswordExpireDisabledState.ps1 b/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselinePasswordExpireDisabledState.ps1 new file mode 100644 index 0000000000..f21217c591 --- /dev/null +++ b/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselinePasswordExpireDisabledState.ps1 @@ -0,0 +1,50 @@ +function Get-CIPPBaselinePasswordExpireDisabledState { + <# + .SYNOPSIS + Prepare hook for PasswordExpireDisabled: verified domains whose passwords still expire. + .DESCRIPTION + Subdomains are excluded because they inherit the parent's password policy - writing to + them is rejected, and grading them would report drift no remediation can clear. + + Each target carries the notification window it should end up with: Graph refuses a + never-expires validity period while the window is unset, so a domain that has none + gets the classic standard's 14 days and one that already has a window keeps it. + Sending it unconditionally is equivalent to the old conditional body and keeps the + write spec uniform. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Item, + $TenantFilter + ) + + $Domains = @(New-CIPPDbRequest -TenantFilter $TenantFilter -Type 'Domains' | Where-Object { $_ }) + if ($Domains.Count -eq 0) { return @{ Current = $null } } + + $Ids = @($Domains.id) + $SubDomains = @(foreach ($Id in $Ids) { + foreach ($Parent in $Ids) { + if ($Id -ne $Parent -and "$Id".EndsWith(".$Parent")) { $Id; break } + } + }) + + $Offending = @($Domains | Where-Object { + $_.isVerified -eq $true -and + $_.passwordValidityPeriodInDays -ne 2147483647 -and + $_.id -notin $SubDomains + }) + + @{ + Current = [PSCustomObject]@{ + offenders = @($Offending.id | Sort-Object) + targets = @($Offending | ForEach-Object { + [PSCustomObject]@{ + id = "$($_.id)" + passwordNotificationWindowInDays = $(if ($null -eq $_.passwordNotificationWindowInDays) { 14 } else { [int]$_.passwordNotificationWindowInDays }) + } + }) + } + } +} diff --git a/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselinePerUserMFAState.ps1 b/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselinePerUserMFAState.ps1 new file mode 100644 index 0000000000..d17ce55c19 --- /dev/null +++ b/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselinePerUserMFAState.ps1 @@ -0,0 +1,33 @@ +function Get-CIPPBaselinePerUserMFAState { + <# + .SYNOPSIS + Prepare hook for PerUserMFA: enabled member accounts not on enforced per-user MFA. + .DESCRIPTION + The AD sync account is excluded by display name, exactly as the classic standard did: + it cannot complete MFA and enforcing it breaks directory synchronisation. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Item, + $TenantFilter + ) + + $Users = @(New-CIPPDbRequest -TenantFilter $TenantFilter -Type 'Users' | Where-Object { $_ }) + if ($Users.Count -eq 0) { return @{ Current = $null } } + + $WithoutMFA = @($Users | Where-Object { + $_.userType -eq 'Member' -and + $_.accountEnabled -eq $true -and + $_.displayName -ne 'On-Premises Directory Synchronization Service Account' -and + $_.perUserMfaState -ne 'enforced' + }) + + @{ + Current = [PSCustomObject]@{ + offenders = @($WithoutMFA.userPrincipalName | Sort-Object) + targets = @($WithoutMFA | ForEach-Object { [PSCustomObject]@{ id = "$($_.userPrincipalName)" } }) + } + } +} diff --git a/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineStaleEntraDevicesState.ps1 b/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineStaleEntraDevicesState.ps1 new file mode 100644 index 0000000000..e458062bdb --- /dev/null +++ b/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineStaleEntraDevicesState.ps1 @@ -0,0 +1,58 @@ +function Get-CIPPBaselineStaleEntraDevicesState { + <# + .SYNOPSIS + Prepare hook for StaleEntraDevices: device records past their last-seen thresholds. + .DESCRIPTION + Produces TWO write sets, because the lifecycle is two-phase and deliberately so: + devicesToDisable - stale and still enabled. + devicesToDelete - already disabled AND past disable+delete days. + A device is therefore never deleted in the same pass that disabled it; the disable is + the warning shot, and an admin has the delete delta to notice and re-enable. + + The safety filter is not optional: a device that is directory-synced, Intune-managed, + compliant, or carries a ZTDID (Autopilot-registered) is excluded regardless of age. + Those records are owned elsewhere and deleting one breaks enrolment. + + A disable threshold under 30 days is refused rather than clamped, matching the classic + standard - the delete phase makes a low value unrecoverable. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Item, + $TenantFilter + ) + + $DisableThreshold = if ([string]::IsNullOrWhiteSpace("$($Item.Variables.deviceAgeThreshold)")) { 0 } else { [int]$Item.Variables.deviceAgeThreshold } + if ($DisableThreshold -lt 30) { throw "StaleEntraDevices: a disable threshold of $DisableThreshold days is below the 30-day floor - refusing to run." } + + $DeleteDelta = if ([string]::IsNullOrWhiteSpace("$($Item.Variables.deviceDeleteThreshold)")) { 0 } else { [int]$Item.Variables.deviceDeleteThreshold } + if ($DeleteDelta -lt 0) { $DeleteDelta = 0 } + + $Devices = @(New-CIPPDbRequest -TenantFilter $TenantFilter -Type 'Devices' | Where-Object { $_ -and $_.approximateLastSignInDateTime }) + if ($Devices.Count -eq 0) { return @{ Current = $null } } + + $DisableDate = (Get-Date).AddDays(-$DisableThreshold) + $DeleteDate = (Get-Date).AddDays(-($DisableThreshold + $DeleteDelta)) + + $Safe = { + $_.onPremisesSyncEnabled -ne $true -and + $_.isManaged -ne $true -and + $_.isCompliant -ne $true -and + (@($_.physicalIds) -join ' ') -notmatch '\[ZTDID\]' + } + + $ToDisable = @($Devices | Where-Object { ([datetime]$_.approximateLastSignInDateTime) -lt $DisableDate } | Where-Object $Safe | Where-Object { $_.accountEnabled -eq $true }) + $ToDelete = @(if ($DeleteDelta -gt 0) { + $Devices | Where-Object { ([datetime]$_.approximateLastSignInDateTime) -lt $DeleteDate } | Where-Object $Safe | Where-Object { $_.accountEnabled -ne $true } + }) + + @{ + Current = [PSCustomObject]@{ + offenders = @(@($ToDisable | ForEach-Object { "Disable: $($_.displayName)" }) + @($ToDelete | ForEach-Object { "Delete: $($_.displayName)" }) | Sort-Object) + devicesToDisable = @($ToDisable | ForEach-Object { [PSCustomObject]@{ id = "$($_.id)" } }) + devicesToDelete = @($ToDelete | ForEach-Object { [PSCustomObject]@{ id = "$($_.id)" } }) + } + } +} diff --git a/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineTeamsDisableResourceAccountsState.ps1 b/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineTeamsDisableResourceAccountsState.ps1 new file mode 100644 index 0000000000..95e70b258d --- /dev/null +++ b/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineTeamsDisableResourceAccountsState.ps1 @@ -0,0 +1,40 @@ +function Get-CIPPBaselineTeamsDisableResourceAccountsState { + <# + .SYNOPSIS + Prepare hook for TeamsDisableResourceAccounts: auto attendant and call queue resource + accounts whose Entra account is still enabled. + .DESCRIPTION + Joins the TeamsResourceAccounts cache against the Users cache on objectId. Resource + accounts need no sign-in - they exist to own a phone number - so an enabled one is a + credential nobody monitors. + + An empty resource-account cache is NOT the same as 'all blocked': it more likely means + the Teams surface was not collected. That returns a null Current so the engine reports + No Data and retries, rather than scoring the tenant compliant for the wrong reason. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Item, + $TenantFilter + ) + + $Accounts = @(New-CIPPDbRequest -TenantFilter $TenantFilter -Type 'TeamsResourceAccounts' | Where-Object { $_ }) + if ($Accounts.Count -eq 0) { return @{ Current = $null } } + + $Users = @(New-CIPPDbRequest -TenantFilter $TenantFilter -Type 'Users' | Where-Object { $_ }) + $EnabledIds = @{} + foreach ($User in $Users) { + if ($User.accountEnabled -eq $true -and $User.onPremisesSyncEnabled -ne $true) { $EnabledIds["$($User.id)"] = $true } + } + + $Enabled = @($Accounts | Where-Object { $_.objectId -and $EnabledIds.ContainsKey("$($_.objectId)") }) + + @{ + Current = [PSCustomObject]@{ + offenders = @($Enabled | ForEach-Object { "$($_.userPrincipalName ?? $_.displayName)" } | Sort-Object) + targets = @($Enabled | ForEach-Object { [PSCustomObject]@{ id = "$($_.objectId)" } }) + } + } +} diff --git a/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineUserPreferredLanguageState.ps1 b/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineUserPreferredLanguageState.ps1 new file mode 100644 index 0000000000..22dad7da5d --- /dev/null +++ b/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineUserPreferredLanguageState.ps1 @@ -0,0 +1,34 @@ +function Get-CIPPBaselineUserPreferredLanguageState { + <# + .SYNOPSIS + Prepare hook for UserPreferredLanguage: users whose preferred language is not the + configured one. + .DESCRIPTION + Members only, and never a directory-synced account - the language is mastered on + premises for those and Graph rejects the write. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Item, + $TenantFilter + ) + + $Users = @(New-CIPPDbRequest -TenantFilter $TenantFilter -Type 'Users' | Where-Object { $_ }) + if ($Users.Count -eq 0) { return @{ Current = $null } } + + $Wanted = "$($Item.Variables.preferredLanguage)" + $Incorrect = @($Users | Where-Object { + $_.userType -eq 'Member' -and + $_.onPremisesSyncEnabled -ne $true -and + "$($_.preferredLanguage)" -ne $Wanted + }) + + @{ + Current = [PSCustomObject]@{ + offenders = @($Incorrect.userPrincipalName | Sort-Object) + targets = @($Incorrect | ForEach-Object { [PSCustomObject]@{ id = "$($_.userPrincipalName)" } }) + } + } +} diff --git a/backend/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineGraphBulkSweep.ps1 b/backend/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineGraphBulkSweep.ps1 new file mode 100644 index 0000000000..1fe0534eec --- /dev/null +++ b/backend/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineGraphBulkSweep.ps1 @@ -0,0 +1,118 @@ +function Invoke-CIPPBaselineGraphBulkSweep { + <# + .SYNOPSIS + GraphBulkSweep executor: applies one Graph write per object in a prepare hook's + offender set. + .DESCRIPTION + The per-object counterpart to GraphRequest. A sweep standard's prepare hook computes + WHICH objects are wrong (that part is bespoke - joins, relative-date windows, exclusion + lists); this applies the SAME write to each of them through $batch, so a tenant with + 900 stale guests costs 45 round trips rather than 900. + + A sweep's prepare hook returns TWO lists, because the compare and the write want + different shapes: + offenders - display strings (a UPN, a domain, a group name). This is the property + the definition grades against [], so drift reads as a list of names + rather than a wall of serialized objects. + targets - one object per offender carrying whatever the write needs (id, and any + value that varies per object). Not graded: the engine projects Current + down to the expected keys, so it never reaches the compare. + + Spec (fully rendered): + writes[] - ordered write groups, each { from, method, uri, body, asApp }. + 'from' names the property on -Current holding the objects (default + 'targets'); a group whose property does not exist is an authoring + error and throws, while one that exists and is empty is simply + nothing to do. uri and body may carry %property% tokens resolved + against each object, with the engine's token semantics: an exact + "%prop%" JSON token keeps the property's type, a bare %prop% inside + a longer string interpolates. + version - 'beta' (default) or 'v1.0'. + refreshCache - cache types to re-collect after a successful sweep, so the objects + just fixed do not read back as drift on the next run. + + Partial failure does NOT throw: the objects that were fixed stay fixed, the failures + are logged, and the next run's compare re-derives the offender set and retries the + remainder. A sweep where EVERY write failed does throw - that is a permission or + endpoint problem, and swallowing it would report Remediated forever while nothing + changed. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Remediate, + $TenantFilter, + $Current + ) + + $Expand = { + param($Template, $Object) + $Json = ConvertTo-Json -Compress -Depth 100 -InputObject $Template + foreach ($Property in $Object.PSObject.Properties) { + $Token = '%{0}%' -f $Property.Name + $Json = $Json.Replace(('"{0}"' -f $Token), (ConvertTo-Json -Compress -Depth 100 -InputObject $Property.Value)) + $Json = $Json.Replace($Token, "$($Property.Value)") + } + $Json | ConvertFrom-Json + } + + $Version = "$($Remediate.version)" + if ($Version -notin @('beta', 'v1.0')) { $Version = 'beta' } + + $Attempted = 0 + $Failed = 0 + $FailureDetail = [System.Collections.Generic.List[string]]::new() + + foreach ($Write in @($Remediate.writes)) { + if (-not $Write) { continue } + $From = "$($Write.from)" + if ([string]::IsNullOrWhiteSpace($From)) { $From = 'targets' } + if (-not ($Current -and $Current.PSObject.Properties.Name -contains $From)) { + throw "GraphBulkSweep: the prepare hook produced no '$From' set to sweep." + } + $Objects = @($Current.$From | Where-Object { $_ }) + if ($Objects.Count -eq 0) { continue } + + $Index = 0 + $Requests = foreach ($Object in $Objects) { + $Request = @{ + id = "$($Index++)" + method = ($Write.method ?? 'PATCH') + url = "/$((& $Expand $Write.uri $Object) -replace '^/')" + } + if ($Write.PSObject.Properties.Name -contains 'body' -and $null -ne $Write.body) { + $Request['body'] = & $Expand $Write.body $Object + $Request['headers'] = @{ 'Content-Type' = 'application/json' } + } + $Request + } + + $Attempted += $Objects.Count + $Responses = @(New-GraphBulkRequest -tenantid $TenantFilter -scope 'https://graph.microsoft.com/.default' -Requests @($Requests) -asapp ([bool]($Write.asApp ?? $true)) -Version $Version) + foreach ($Response in $Responses) { + if ([int]$Response.status -lt 200 -or [int]$Response.status -gt 299) { + $Failed++ + $Target = @($Requests)[[int]$Response.id].url + $FailureDetail.Add("$Target -> $($Response.status) $($Response.body.error.message)") + } + } + } + + if ($Attempted -eq 0) { return } + + if ($FailureDetail.Count -gt 0) { + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "Sweep: $Failed of $Attempted writes failed. $($FailureDetail -join ' | ')" -Sev 'Warning' + } + if ($Failed -eq $Attempted) { + throw "GraphBulkSweep: all $Attempted writes failed. $($FailureDetail | Select-Object -First 1)" + } + + foreach ($CacheType in @($Remediate.refreshCache | Where-Object { $_ })) { + $Collector = Get-Command -Name "Set-CIPPDBCache$CacheType" -ErrorAction SilentlyContinue + if (-not $Collector) { continue } + try { $null = & $Collector -TenantFilter $TenantFilter } catch { + Write-Information "Baselines: cache refresh for $CacheType on $TenantFilter after a sweep failed: $($_.Exception.Message)" + } + } +} diff --git a/backend/Tests/Baselines/BaselineExecutors.Tests.ps1 b/backend/Tests/Baselines/BaselineExecutors.Tests.ps1 index e4f29dba57..942dd3c915 100644 --- a/backend/Tests/Baselines/BaselineExecutors.Tests.ps1 +++ b/backend/Tests/Baselines/BaselineExecutors.Tests.ps1 @@ -18,12 +18,18 @@ BeforeAll { # Parameter binding is case-insensitive, so one casing per name covers every call site. function New-GraphPostRequest { param($tenantid, $uri, $Type, $Body, $AsApp, $ContentType, $AddedHeaders) } function New-GraphGetRequest { param($uri, $tenantid, $AsApp, $SkipValueExtraction) } + function New-GraphBulkRequest { param($tenantid, $scope, $asapp, $Requests, $Version, $Headers, $NoAuthCheck, $NoPaginateIds) } function New-ExoRequest { param($tenantid, $cmdlet, $cmdParams, $useSystemMailbox, [switch]$Compliance) } function Write-LogMessage { param($API, $tenant, $message, $Sev, $LogData) } + function Set-CIPPDBCacheUsers { param($TenantFilter) } . (Join-Path $Baselines 'Invoke-CIPPBaselineGraphRequest.ps1') . (Join-Path $Baselines 'Invoke-CIPPBaselineExoRequest.ps1') . (Join-Path $Baselines 'Invoke-CIPPBaselineDeviceRegistrationPolicy.ps1') + . (Join-Path $Baselines 'Invoke-CIPPBaselineGraphBulkSweep.ps1') + + # $batch answers one response per request, keyed by the id the caller supplied. + function New-BulkSuccess { param($Requests) @($Requests | ForEach-Object { [PSCustomObject]@{ id = $_.id; status = 204 } }) } $script:Tenant = 'contoso.onmicrosoft.com' @@ -187,3 +193,103 @@ Describe 'Invoke-CIPPBaselineDeviceRegistrationPolicy' { Should -Invoke New-GraphPostRequest -Times 0 } } + +Describe 'Invoke-CIPPBaselineGraphBulkSweep' { + BeforeEach { + Mock New-GraphBulkRequest { New-BulkSuccess -Requests $Requests } + Mock Write-LogMessage {} + Mock Set-CIPPDBCacheUsers {} + } + + It 'sends one request per offender, with the id spliced into the url' { + $Current = @{ targets = @(@{ id = 'a-1' }, @{ id = 'b-2' }) } | ConvertTo-Spec + $Spec = @{ writes = @(@{ method = 'PATCH'; uri = 'users/%id%'; body = @{ accountEnabled = $false } }) } | ConvertTo-Spec + Invoke-CIPPBaselineGraphBulkSweep -Remediate $Spec -TenantFilter $script:Tenant -Current $Current + Should -Invoke New-GraphBulkRequest -Times 1 -ParameterFilter { + @($Requests).Count -eq 2 -and + @($Requests)[0].url -eq '/users/a-1' -and @($Requests)[1].url -eq '/users/b-2' -and + @($Requests)[0].body.accountEnabled -eq $false + } + } + + It 'keeps a per-object token its JSON type' { + # PasswordExpireDisabled carries a per-domain notification window. Sent as the string + # "14" Graph rejects the body, so the exact-token rule has to survive per-object + # expansion the same way it does in the engine's render. + $Current = @{ targets = @(@{ id = 'contoso.com'; passwordNotificationWindowInDays = 14 }) } | ConvertTo-Spec + $Spec = @{ writes = @(@{ method = 'PATCH'; uri = 'domains/%id%'; body = @{ passwordValidityPeriodInDays = 2147483647; passwordNotificationWindowInDays = '%passwordNotificationWindowInDays%' } }) } | ConvertTo-Spec + Invoke-CIPPBaselineGraphBulkSweep -Remediate $Spec -TenantFilter $script:Tenant -Current $Current + Should -Invoke New-GraphBulkRequest -Times 1 -ParameterFilter { + @($Requests)[0].body.passwordNotificationWindowInDays -is [int] -or @($Requests)[0].body.passwordNotificationWindowInDays -is [long] + } + } + + It 'runs each write group against its own offender set' { + # StaleEntraDevices disables one set and deletes another in the same pass. + $Current = @{ + devicesToDisable = @(@{ id = 'd-1' }) + devicesToDelete = @(@{ id = 'd-2' }, @{ id = 'd-3' }) + } | ConvertTo-Spec + $Spec = @{ writes = @( + @{ from = 'devicesToDisable'; method = 'PATCH'; uri = 'devices/%id%'; body = @{ accountEnabled = $false } }, + @{ from = 'devicesToDelete'; method = 'DELETE'; uri = 'devices/%id%' } + ) } | ConvertTo-Spec + Invoke-CIPPBaselineGraphBulkSweep -Remediate $Spec -TenantFilter $script:Tenant -Current $Current + Should -Invoke New-GraphBulkRequest -Times 1 -ParameterFilter { @($Requests).Count -eq 1 -and @($Requests)[0].method -eq 'PATCH' } + Should -Invoke New-GraphBulkRequest -Times 1 -ParameterFilter { @($Requests).Count -eq 2 -and @($Requests)[0].method -eq 'DELETE' } + } + + It 'omits the body entirely for a DELETE' { + $Current = @{ targets = @(@{ id = 'app-1' }) } | ConvertTo-Spec + $Spec = @{ writes = @(@{ method = 'DELETE'; uri = 'applications/%id%' }) } | ConvertTo-Spec + Invoke-CIPPBaselineGraphBulkSweep -Remediate $Spec -TenantFilter $script:Tenant -Current $Current + Should -Invoke New-GraphBulkRequest -Times 1 -ParameterFilter { -not @($Requests)[0].ContainsKey('body') } + } + + It 'does nothing when there is nothing to sweep' { + $Current = @{ targets = @() } | ConvertTo-Spec + $Spec = @{ writes = @(@{ method = 'PATCH'; uri = 'users/%id%'; body = @{ accountEnabled = $false } }) } | ConvertTo-Spec + Invoke-CIPPBaselineGraphBulkSweep -Remediate $Spec -TenantFilter $script:Tenant -Current $Current + Should -Invoke New-GraphBulkRequest -Times 0 + } + + It 'throws when the prepare hook never produced the named set' { + # An authoring typo. Silently sweeping nothing would report Remediated forever. + $Current = @{ targets = @(@{ id = 'a-1' }) } | ConvertTo-Spec + $Spec = @{ writes = @(@{ from = 'typo'; method = 'PATCH'; uri = 'users/%id%'; body = @{ a = 1 } }) } | ConvertTo-Spec + { Invoke-CIPPBaselineGraphBulkSweep -Remediate $Spec -TenantFilter $script:Tenant -Current $Current } | Should -Throw '*no *typo* set*' + } + + It 'survives a partial failure and reports it' { + Mock New-GraphBulkRequest { + @( + [PSCustomObject]@{ id = '0'; status = 204 } + [PSCustomObject]@{ id = '1'; status = 403; body = [PSCustomObject]@{ error = [PSCustomObject]@{ message = 'Insufficient privileges' } } } + ) + } + $Current = @{ targets = @(@{ id = 'a-1' }, @{ id = 'b-2' }) } | ConvertTo-Spec + $Spec = @{ refreshCache = @('Users'); writes = @(@{ method = 'PATCH'; uri = 'users/%id%'; body = @{ accountEnabled = $false } }) } | ConvertTo-Spec + { Invoke-CIPPBaselineGraphBulkSweep -Remediate $Spec -TenantFilter $script:Tenant -Current $Current } | Should -Not -Throw + Should -Invoke Write-LogMessage -Times 1 -ParameterFilter { $Sev -eq 'Warning' -and $message -like '*1 of 2 writes failed*' } + Should -Invoke Set-CIPPDBCacheUsers -Times 1 + } + + It 'throws when every write failed' { + # A permissions or endpoint problem. Swallowing it reports Remediated forever while + # nothing on the tenant ever changes. + Mock New-GraphBulkRequest { + @($Requests | ForEach-Object { [PSCustomObject]@{ id = $_.id; status = 403; body = [PSCustomObject]@{ error = [PSCustomObject]@{ message = 'Insufficient privileges' } } } }) + } + $Current = @{ targets = @(@{ id = 'a-1' }, @{ id = 'b-2' }) } | ConvertTo-Spec + $Spec = @{ writes = @(@{ method = 'PATCH'; uri = 'users/%id%'; body = @{ accountEnabled = $false } }) } | ConvertTo-Spec + { Invoke-CIPPBaselineGraphBulkSweep -Remediate $Spec -TenantFilter $script:Tenant -Current $Current } | Should -Throw '*all 2 writes failed*' + } + + It 'refreshes the named caches after a successful sweep' { + # Objects just fixed must not read back as drift on the next run. + $Current = @{ targets = @(@{ id = 'a-1' }) } | ConvertTo-Spec + $Spec = @{ refreshCache = @('Users'); writes = @(@{ method = 'PATCH'; uri = 'users/%id%'; body = @{ accountEnabled = $false } }) } | ConvertTo-Spec + Invoke-CIPPBaselineGraphBulkSweep -Remediate $Spec -TenantFilter $script:Tenant -Current $Current + Should -Invoke Set-CIPPDBCacheUsers -Times 1 -ParameterFilter { $TenantFilter -eq $script:Tenant } + } +} From ea5da12391eaf26c8cfbab79071f23e96cdf5105 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Sat, 15 Aug 2026 15:55:42 -0400 Subject: [PATCH 070/226] fix: re-apply CPV consent when the last permissions run failed Push-UpdatePermissionsQueue skipped Set-CIPPCPVConsent whenever a cpvtenants row named the current SAM app. Its finally block writes that row on every run including failures, so the first failed attempt left behind exactly the record that suppressed all later attempts. The tenant then failed indefinitely on the token call that only consent could have made possible, and Start-UpdatePermissionsOrchestrator re-queued it nightly to no effect. The gate now also re-consents when LastStatus is Failed, matching the status check the orchestrator already makes. Rows predating LastStatus are left alone so deploying this does not re-consent an entire estate. A plain re-consent cannot repair every case: Set-CIPPCPVConsent short-circuits on 'Permission entry already exists', which leaves both a recorded-but- ineffective entry and one whose scopes no longer cover what CIPP needs unfixable. Escalate to ResetSP on a recognised consent error, or after a re-consent has already been tried, tracked in ConsentAttempts. Resets are limited to one per week via LastResetUtc since they briefly remove access. Test-CIPPAccessPermissions judged CPV freshness from the row Timestamp alone, which the failed runs kept current - a permanently broken tenant never appeared in "Some tenants need a CPV refresh". It now also reports failures. --- .../Push-UpdatePermissionsQueue.ps1 | 46 ++- .../Public/Test-CIPPAccessPermissions.ps1 | 8 +- .../Push-UpdatePermissionsQueue.Tests.ps1 | 316 ++++++++++++++++++ 3 files changed, 365 insertions(+), 5 deletions(-) create mode 100644 backend/Tests/ActivityTriggers/Push-UpdatePermissionsQueue.Tests.ps1 diff --git a/backend/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-UpdatePermissionsQueue.ps1 b/backend/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-UpdatePermissionsQueue.ps1 index d0318bbdde..f5cc448159 100644 --- a/backend/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-UpdatePermissionsQueue.ps1 +++ b/backend/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-UpdatePermissionsQueue.ps1 @@ -9,6 +9,12 @@ function Push-UpdatePermissionsQueue { $FailureMessage = $null $DomainRefreshRequired = $false + # Read by the finally block, so they must survive an early throw in the try. + $ConsentRow = $null + $ConsentAttempted = $false + $Attempts = 0 + $ResetSP = $false + try { if (!$Item.defaultDomainName) { $DomainRefreshRequired = $true @@ -20,10 +26,31 @@ function Push-UpdatePermissionsQueue { $Tenant = Get-Tenants -TenantFilter $Item.customerId -IncludeErrors - if ((!$CPVRows -or $env:ApplicationID -notin $CPVRows.applicationId) -and $Tenant.delegatedPrivilegeStatus -ne 'directTenant') { - Write-LogMessage -tenant $Item.defaultDomainName -tenantId $Item.customerId -message 'A New tenant has been added, or a new CIPP-SAM Application is in use' -Sev 'Warning' -API 'NewTenant' + $ConsentRow = $CPVRows | Where-Object { $_.applicationId -eq $env:ApplicationID } | Select-Object -First 1 + + # The finally block writes a row even on failure, so existence alone does not prove + # consent. -eq 'Failed' so status-less legacy rows don't re-consent the estate on deploy. + $NeedsConsent = !$ConsentRow -or $ConsentRow.LastStatus -eq 'Failed' + + if ($NeedsConsent -and $Tenant.delegatedPrivilegeStatus -ne 'directTenant') { + # Only a reset can fix an entry that exists but is wrong ('Permission entry already + # exists' short-circuits a plain re-consent). Escalate on a known consent error or + # after a failed re-consent; at most one reset per week since it briefly drops access. + $ConsentAttempted = $true + $Attempts = if ($ConsentRow.ConsentAttempts) { [int]$ConsentRow.ConsentAttempts } else { 0 } + $KnownConsentError = [bool]($ConsentRow -and $ConsentRow.LastError -match 'AADSTS(65001|90094|500011)|Insufficient privileges|Authorization_RequestDenied') + $ResetAllowed = $true + if ($ConsentRow.LastResetUtc) { + try { $ResetAllowed = ([datetime]::UtcNow - [datetime]::Parse($ConsentRow.LastResetUtc)).TotalDays -ge 7 } catch { $ResetAllowed = $true } + } + $ResetSP = [bool]($ConsentRow -and $ResetAllowed -and ($KnownConsentError -or $Attempts -ge 1)) + + $ConsentReason = if (!$ConsentRow) { 'A New tenant has been added, or a new CIPP-SAM Application is in use' } + elseif ($ResetSP) { "The last permissions run failed and re-applying consent has not fixed it (attempt $($Attempts + 1)), resetting the service principal" } + else { 'The last permissions run failed, re-applying CPV consent' } + Write-LogMessage -tenant $Item.defaultDomainName -tenantId $Item.customerId -message $ConsentReason -Sev 'Warning' -API 'NewTenant' Write-Information 'Adding CPV permissions' - Set-CIPPCPVConsent -Tenantfilter $Item.customerId + Set-CIPPCPVConsent -Tenantfilter $Item.customerId -ResetSP $ResetSP $DomainRefreshRequired = $true } Write-Information 'Updating permissions' @@ -83,6 +110,19 @@ function Push-UpdatePermissionsQueue { if ($FailureMessage) { $GraphRequest.LastError = "$FailureMessage" } + + # Failed re-consent counter drives the reset escalation; cleared on success. + if ($Status -eq 'Success') { + $GraphRequest.ConsentAttempts = '0' + } elseif ($ConsentAttempted) { + $GraphRequest.ConsentAttempts = "$($Attempts + 1)" + } elseif ($ConsentRow.ConsentAttempts) { + $GraphRequest.ConsentAttempts = "$($ConsentRow.ConsentAttempts)" + } + # The row is replaced, not merged - carry these forward or the weekly limit re-arms. + if ($ResetSP) { $GraphRequest.LastResetUtc = ([datetime]::UtcNow.ToString('o')) } + elseif ($ConsentRow.LastResetUtc) { $GraphRequest.LastResetUtc = "$($ConsentRow.LastResetUtc)" } + Add-CIPPAzDataTableEntity @CpvTable -Entity $GraphRequest -Force } catch { Write-Information "Failed to persist cpvtenants row for $($Item.displayName): $($_.Exception.Message)" diff --git a/backend/Modules/CIPPCore/Public/Test-CIPPAccessPermissions.ps1 b/backend/Modules/CIPPCore/Public/Test-CIPPAccessPermissions.ps1 index 99845e0216..1dc4048459 100644 --- a/backend/Modules/CIPPCore/Public/Test-CIPPAccessPermissions.ps1 +++ b/backend/Modules/CIPPCore/Public/Test-CIPPAccessPermissions.ps1 @@ -169,14 +169,18 @@ function Test-CIPPAccessPermissions { $CPVRefreshList = [System.Collections.Generic.List[object]]::new() $CPVSuccess = $true foreach ($Tenant in $TenantList) { - $LastRefresh = ($CpvRefresh | Where-Object { $_.RowKey -eq $Tenant.customerId }).Timestamp.DateTime - if ($LastRefresh -lt $LastUpdate) { + $CpvRow = $CpvRefresh | Where-Object { $_.RowKey -eq $Tenant.customerId } + $LastRefresh = $CpvRow.Timestamp.DateTime + # Timestamp is rewritten even on failed runs, so freshness alone hides a broken tenant. + if ($LastRefresh -lt $LastUpdate -or $CpvRow.LastStatus -eq 'Failed') { $CPVSuccess = $false $CPVRefreshList.Add([PSCustomObject]@{ CustomerId = $Tenant.customerId DisplayName = $Tenant.displayName DefaultDomainName = $Tenant.DefaultDomainName LastRefresh = $LastRefresh + LastStatus = $CpvRow.LastStatus + LastError = $CpvRow.LastError }) } } diff --git a/backend/Tests/ActivityTriggers/Push-UpdatePermissionsQueue.Tests.ps1 b/backend/Tests/ActivityTriggers/Push-UpdatePermissionsQueue.Tests.ps1 new file mode 100644 index 0000000000..dc43563e44 --- /dev/null +++ b/backend/Tests/ActivityTriggers/Push-UpdatePermissionsQueue.Tests.ps1 @@ -0,0 +1,316 @@ +# The CPV consent gate: a failed cpvtenants row must re-consent, and the cases +# that must NOT re-consent (legacy rows, direct tenants, success) still don't. + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $FunctionPath = Get-ChildItem -Path (Join-Path $RepoRoot 'Modules') -Recurse -Filter 'Push-UpdatePermissionsQueue.ps1' -File -ErrorAction SilentlyContinue | + Select-Object -First 1 -ExpandProperty FullName + if (-not $FunctionPath) { throw 'Could not locate Push-UpdatePermissionsQueue.ps1 under Modules/' } + + # Stubs so Mock has commands to replace. + function Get-CIPPTable { param($TableName) @{ Context = 'stub' } } + function Get-CIPPAzDataTableEntity { param($Context, $Filter) } + function Add-CIPPAzDataTableEntity { param($Context, $Entity, [switch]$Force) } + function Get-Tenants { param($TenantFilter, [switch]$IncludeErrors, [switch]$IncludeAll, [switch]$TriggerRefresh) } + function Set-CIPPCPVConsent { param($TenantFilter, $APIName, $Headers, [bool]$ResetSP) } + function Add-CIPPApplicationPermission { param($RequiredResourceAccess, $ApplicationId, $TenantFilter) } + function Add-CIPPDelegatedPermission { param($RequiredResourceAccess, $ApplicationId, $TenantFilter) } + function Set-CIPPSAMAdminRoles { param($TenantFilter) } + function Write-LogMessage { param($message, $tenant, $tenantId, $API, $Sev, $Headers, $LogData) } + function Get-CippException { param($Exception) @{ NormalizedError = "$Exception" } } + + . $FunctionPath + + $script:CurrentAppId = '11111111-1111-1111-1111-111111111111' + $env:ApplicationID = $script:CurrentAppId + + $script:Item = [pscustomobject]@{ + customerId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' + defaultDomainName = 'dev.example.com' + displayName = 'Example Dev' + } + + $script:ConsentError = "Could not get token: invalid_grant:AADSTS65001: The user or administrator has not consented to use the application with ID '$($script:CurrentAppId)' named 'CIPP-SAM'." +} + +Describe 'Push-UpdatePermissionsQueue CPV consent gate' { + BeforeEach { + $script:ConsentCalls = [System.Collections.Generic.List[object]]::new() + $script:WrittenRow = $null + + Mock -CommandName Set-CIPPCPVConsent -MockWith { + $script:ConsentCalls.Add([pscustomobject]@{ TenantFilter = $TenantFilter; ResetSP = [bool]$ResetSP }) + } + Mock -CommandName Add-CIPPAzDataTableEntity -MockWith { $script:WrittenRow = $Entity } + Mock -CommandName Add-CIPPApplicationPermission -MockWith { @('Succeeded') } + Mock -CommandName Add-CIPPDelegatedPermission -MockWith { @('Succeeded') } + Mock -CommandName Set-CIPPSAMAdminRoles -MockWith { } + Mock -CommandName Write-LogMessage -MockWith { } + Mock -CommandName Get-CIPPTable -MockWith { @{ Context = 'stub' } } + Mock -CommandName Get-Tenants -MockWith { + [pscustomobject]@{ + customerId = $script:Item.customerId + defaultDomainName = $script:Item.defaultDomainName + displayName = $script:Item.displayName + delegatedPrivilegeStatus = 'granularDelegatedAdminPrivileges' + } + } + } + + Context 'when no consent record exists' { + It 'consents, without resetting the service principal' { + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { @() } + + Push-UpdatePermissionsQueue -Item $script:Item + + $script:ConsentCalls.Count | Should -Be 1 + $script:ConsentCalls[0].ResetSP | Should -BeFalse + } + } + + Context 'when the last run succeeded' { + It 'does not re-consent' { + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { + @([pscustomobject]@{ Tenant = $script:Item.customerId; applicationId = $script:CurrentAppId; LastStatus = 'Success' }) + } + + Push-UpdatePermissionsQueue -Item $script:Item + + $script:ConsentCalls.Count | Should -Be 0 + } + } + + Context 'when the last run failed because consent is missing' { + It 're-consents instead of skipping the step forever' { + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { + @([pscustomobject]@{ + Tenant = $script:Item.customerId + applicationId = $script:CurrentAppId + LastStatus = 'Failed' + LastError = $script:ConsentError + }) + } + + Push-UpdatePermissionsQueue -Item $script:Item + + $script:ConsentCalls.Count | Should -Be 1 + } + + It 'resets the service principal, because a plain re-consent short-circuits on an existing entry' { + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { + @([pscustomobject]@{ + Tenant = $script:Item.customerId + applicationId = $script:CurrentAppId + LastStatus = 'Failed' + LastError = $script:ConsentError + }) + } + + Push-UpdatePermissionsQueue -Item $script:Item + + $script:ConsentCalls[0].ResetSP | Should -BeTrue + } + } + + Context 'when the last run failed for an unrelated reason' { + It 're-consents but does not reset the service principal on the first attempt' { + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { + @([pscustomobject]@{ + Tenant = $script:Item.customerId + applicationId = $script:CurrentAppId + LastStatus = 'Failed' + LastError = 'Set-CIPPSAMAdminRoles: the remote server returned an error (503)' + }) + } + + Push-UpdatePermissionsQueue -Item $script:Item + + $script:ConsentCalls.Count | Should -Be 1 + $script:ConsentCalls[0].ResetSP | Should -BeFalse + } + + It 'escalates to a reset once a plain re-consent has already been tried' { + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { + @([pscustomobject]@{ + Tenant = $script:Item.customerId + applicationId = $script:CurrentAppId + LastStatus = 'Failed' + LastError = 'something nobody wrote a pattern for' + ConsentAttempts = '1' + }) + } + + Push-UpdatePermissionsQueue -Item $script:Item + + $script:ConsentCalls[0].ResetSP | Should -BeTrue + } + } + + Context 'when consent exists but its scopes are insufficient' { + BeforeEach { + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { + @([pscustomobject]@{ + Tenant = $script:Item.customerId + applicationId = $script:CurrentAppId + LastStatus = 'Failed' + LastError = 'Failed to grant 9a5d68dd-52b0-4cc2-bd40-abcf44ac3a30 to 00000003-0000-0000-c000-000000000000: Insufficient privileges to complete the operation.' + }) + } + } + + It 'resets the service principal rather than re-applying the same consent' { + Push-UpdatePermissionsQueue -Item $script:Item + + $script:ConsentCalls.Count | Should -Be 1 + $script:ConsentCalls[0].ResetSP | Should -BeTrue + } + } + + Context 'reset rate limiting' { + It 'does not reset again within a week of the last one' { + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { + @([pscustomobject]@{ + Tenant = $script:Item.customerId + applicationId = $script:CurrentAppId + LastStatus = 'Failed' + LastError = $script:ConsentError + ConsentAttempts = '3' + LastResetUtc = ([datetime]::UtcNow.AddDays(-2).ToString('o')) + }) + } + + Push-UpdatePermissionsQueue -Item $script:Item + + $script:ConsentCalls.Count | Should -Be 1 + $script:ConsentCalls[0].ResetSP | Should -BeFalse + } + + It 'resets again once the week has elapsed' { + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { + @([pscustomobject]@{ + Tenant = $script:Item.customerId + applicationId = $script:CurrentAppId + LastStatus = 'Failed' + LastError = $script:ConsentError + ConsentAttempts = '3' + LastResetUtc = ([datetime]::UtcNow.AddDays(-8).ToString('o')) + }) + } + + Push-UpdatePermissionsQueue -Item $script:Item + + $script:ConsentCalls[0].ResetSP | Should -BeTrue + } + } + + Context 'the attempt counter' { + It 'increments while the tenant keeps failing' { + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { + @([pscustomobject]@{ + Tenant = $script:Item.customerId + applicationId = $script:CurrentAppId + LastStatus = 'Failed' + LastError = 'unrecognised' + ConsentAttempts = '2' + }) + } + Mock -CommandName Add-CIPPApplicationPermission -MockWith { @('Failed to grant something') } + + Push-UpdatePermissionsQueue -Item $script:Item + + $script:WrittenRow.ConsentAttempts | Should -Be '3' + } + + It 'clears on success, so a recovered tenant starts from zero' { + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { + @([pscustomobject]@{ + Tenant = $script:Item.customerId + applicationId = $script:CurrentAppId + LastStatus = 'Failed' + LastError = $script:ConsentError + ConsentAttempts = '4' + }) + } + + Push-UpdatePermissionsQueue -Item $script:Item + + $script:WrittenRow.ConsentAttempts | Should -Be '0' + } + + It 'does not count an attempt for a direct tenant, which never consents' { + Mock -CommandName Get-Tenants -MockWith { + [pscustomobject]@{ + customerId = $script:Item.customerId + defaultDomainName = $script:Item.defaultDomainName + delegatedPrivilegeStatus = 'directTenant' + } + } + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { + @([pscustomobject]@{ Tenant = $script:Item.customerId; applicationId = $script:CurrentAppId; LastStatus = 'Failed'; LastError = $script:ConsentError }) + } + Mock -CommandName Add-CIPPApplicationPermission -MockWith { @('Failed to grant something') } + + Push-UpdatePermissionsQueue -Item $script:Item + + $script:ConsentCalls.Count | Should -Be 0 + $script:WrittenRow.ConsentAttempts | Should -BeNullOrEmpty + } + } + + Context 'when the record predates the LastStatus field' { + It 'does not re-consent, so deploying this does not re-consent the whole estate' { + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { + @([pscustomobject]@{ Tenant = $script:Item.customerId; applicationId = $script:CurrentAppId }) + } + + Push-UpdatePermissionsQueue -Item $script:Item + + $script:ConsentCalls.Count | Should -Be 0 + } + } + + Context 'when the consent record names a different application' { + It 'consents for the new application' { + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { + @([pscustomobject]@{ Tenant = $script:Item.customerId; applicationId = '99999999-9999-9999-9999-999999999999'; LastStatus = 'Success' }) + } + + Push-UpdatePermissionsQueue -Item $script:Item + + $script:ConsentCalls.Count | Should -Be 1 + } + } + + Context 'for a direct tenant' { + It 'never consents, CPV does not apply' { + Mock -CommandName Get-Tenants -MockWith { + [pscustomobject]@{ + customerId = $script:Item.customerId + defaultDomainName = $script:Item.defaultDomainName + delegatedPrivilegeStatus = 'directTenant' + } + } + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { + @([pscustomobject]@{ Tenant = $script:Item.customerId; applicationId = $script:CurrentAppId; LastStatus = 'Failed'; LastError = $script:ConsentError }) + } + + Push-UpdatePermissionsQueue -Item $script:Item + + $script:ConsentCalls.Count | Should -Be 0 + } + } + + Context 'the record it leaves behind' { + It 'records the failure status that the gate now reads' { + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { + @([pscustomobject]@{ Tenant = $script:Item.customerId; applicationId = $script:CurrentAppId; LastStatus = 'Success' }) + } + Mock -CommandName Add-CIPPApplicationPermission -MockWith { throw $script:ConsentError } + + Push-UpdatePermissionsQueue -Item $script:Item + + $script:WrittenRow.LastStatus | Should -Be 'Failed' + $script:WrittenRow.LastError | Should -Match 'AADSTS65001' + } + } +} From 773a6a8bb02351c638ac2dc8857d8776b0037619 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Sat, 15 Aug 2026 15:55:42 -0400 Subject: [PATCH 071/226] fix: derive the SAM manifest timestamp from content, not file mtime Get-CippSamPermissions reported the manifest's LastWriteTime as "when the required permission set last changed". Git does not store mtimes, so every checkout and every container build restamps SAMManifest.json with the build time. Docker COPY faithfully preserves that fresh timestamp, so the value was really "when was this image built". Consumers compare it against each tenant's cpvtenants row, so every release made it newer than every row: Start-UpdatePermissionsOrchestrator re-queued the entire estate through the permission and admin-role calls, and Test-CIPPAccessPermissions reported that tenants needed a CPV refresh. Only instances with saved extra permissions escaped, their AppPermissions row being newer than the image. Hash SAMManifest.json + AdditionalPermissions.json instead and keep the time the hash was first seen, in an AppPermissions/ManifestHash row. A rebuild with unchanged permissions no longer moves the timestamp; a genuine change still does. If the row cannot be written, fall back to the mtime rather than treating every call as first sight. Upgrading records the hash once, which costs one final estate-wide refresh. --- .../GraphHelper/Get-CippSamPermissions.ps1 | 26 ++++- ...SamPermissions.ManifestTimestamp.Tests.ps1 | 100 ++++++++++++++++++ 2 files changed, 125 insertions(+), 1 deletion(-) create mode 100644 backend/Tests/GraphHelper/Get-CippSamPermissions.ManifestTimestamp.Tests.ps1 diff --git a/backend/Modules/CIPPCore/Public/GraphHelper/Get-CippSamPermissions.ps1 b/backend/Modules/CIPPCore/Public/GraphHelper/Get-CippSamPermissions.ps1 index 4b01cda92a..8391eaa00b 100644 --- a/backend/Modules/CIPPCore/Public/GraphHelper/Get-CippSamPermissions.ps1 +++ b/backend/Modules/CIPPCore/Public/GraphHelper/Get-CippSamPermissions.ps1 @@ -238,7 +238,31 @@ function Get-CippSamPermissions { } } - $Timestamp = $SamManifestFile.LastWriteTime.ToUniversalTime() + # When the permission set last changed. Content hash, not mtime: git doesn't store mtimes, + # so every checkout/build restamped the manifest and re-queued the whole estate for CPV. + $ManifestContent = (Get-Content -Path $SamManifestFile.FullName -Raw) + (Get-Content -Path $AdditionalPermissionsFile.FullName -Raw) + $ManifestHash = [System.Convert]::ToHexString([System.Security.Cryptography.SHA256]::HashData([System.Text.Encoding]::UTF8.GetBytes($ManifestContent))) + $HashRow = Get-CippAzDataTableEntity @Table -Filter "PartitionKey eq 'CIPP-SAM' and RowKey eq 'ManifestHash'" + + if ($HashRow.Hash -eq $ManifestHash -and $HashRow.FirstSeenUtc) { + $Timestamp = ([datetime]::Parse($HashRow.FirstSeenUtc)).ToUniversalTime() + } else { + # New permission set - advance the timestamp once and record it. + $Timestamp = [datetime]::UtcNow + try { + $null = Add-CIPPAzDataTableEntity @Table -Force -Entity @{ + PartitionKey = 'CIPP-SAM' + RowKey = 'ManifestHash' + Hash = $ManifestHash + FirstSeenUtc = $Timestamp.ToString('o') + } + } catch { + # Unpersisted, every call would look like first sight; mtime is at least stable. + Write-Information "Could not persist the SAM manifest hash: $($_.Exception.Message)" + $Timestamp = $SamManifestFile.LastWriteTime.ToUniversalTime() + } + } + if ($SavedRow.Timestamp) { $SavedTimestamp = $SavedRow.Timestamp.DateTime.ToUniversalTime() if ($SavedTimestamp -gt $Timestamp) { diff --git a/backend/Tests/GraphHelper/Get-CippSamPermissions.ManifestTimestamp.Tests.ps1 b/backend/Tests/GraphHelper/Get-CippSamPermissions.ManifestTimestamp.Tests.ps1 new file mode 100644 index 0000000000..eb1935fe8c --- /dev/null +++ b/backend/Tests/GraphHelper/Get-CippSamPermissions.ManifestTimestamp.Tests.ps1 @@ -0,0 +1,100 @@ +# The SAM manifest timestamp: a rebuild with unchanged content must not move it +# (mtime restamps re-queued the whole estate for CPV); a real change must. + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $FunctionPath = Get-ChildItem -Path (Join-Path $RepoRoot 'Modules') -Recurse -Filter 'Get-CippSamPermissions.ps1' -File -ErrorAction SilentlyContinue | + Select-Object -First 1 -ExpandProperty FullName + if (-not $FunctionPath) { throw 'Could not locate Get-CippSamPermissions.ps1 under Modules/' } + + function Get-CippTable { param($tablename) @{ Context = 'stub' } } + function Get-CippAzDataTableEntity { param($Context, $Filter) } + function Add-CIPPAzDataTableEntity { param($Context, $Entity, [switch]$Force) } + function New-GraphGetRequest { param($Uri, $tenantid, $NoAuthCheck, $AsApp) } + function New-GraphBulkRequest { param($tenantid, $Requests, $NoAuthCheck, $asapp) } + function Write-LogMessage { param($message, $tenant, $API, $sev, $Headers, $LogData) } + + . $FunctionPath + + $script:ConfigRoot = Join-Path ([IO.Path]::GetTempPath()) ("samman-" + [guid]::NewGuid()) + $null = New-Item -ItemType Directory -Path (Join-Path $script:ConfigRoot 'Config') -Force + $script:ManifestPath = Join-Path $script:ConfigRoot 'Config/SAMManifest.json' + $script:AdditionalPath = Join-Path $script:ConfigRoot 'Config/AdditionalPermissions.json' + $env:CIPPRootPath = $script:ConfigRoot + $env:TenantID = '00000000-0000-0000-0000-000000000001' + + function Set-Manifest { + param([string]$Scope = 'Directory.Read.All') + @{ requiredResourceAccess = @(@{ resourceAppId = '00000003-0000-0000-c000-000000000000'; resourceAccess = @(@{ id = '11111111-1111-1111-1111-111111111111'; type = 'Scope'; value = $Scope }) }) } | + ConvertTo-Json -Depth 10 | Set-Content -Path $script:ManifestPath + '[]' | Set-Content -Path $script:AdditionalPath + } + Set-Manifest +} + +AfterAll { + Remove-Item -Path $script:ConfigRoot -Recurse -Force -ErrorAction SilentlyContinue +} + +Describe 'Get-CippSamPermissions manifest timestamp' { + BeforeEach { + $script:HashRow = $null + $script:Written = [System.Collections.Generic.List[object]]::new() + # Clear the 5-minute -NoDiff memo between calls. + $script:CippSamPermissionsCache = $null + $script:CippSamPermissionsCacheTime = $null + + Mock -CommandName Get-CippTable -MockWith { @{ Context = 'stub' } } + Mock -CommandName New-GraphGetRequest -MockWith { @() } + Mock -CommandName New-GraphBulkRequest -MockWith { @() } + Mock -CommandName Get-CippAzDataTableEntity -MockWith { + if ($Filter -match 'ManifestHash') { return $script:HashRow } + return $null # no saved extra permissions + } + Mock -CommandName Add-CIPPAzDataTableEntity -MockWith { + $script:Written.Add($Entity) + $script:HashRow = [pscustomobject]$Entity + } + } + + It 'records the hash the first time it sees a permission set' { + $null = Get-CippSamPermissions -NoDiff + + $script:Written.Count | Should -Be 1 + $script:Written[0].RowKey | Should -Be 'ManifestHash' + $script:Written[0].Hash | Should -Not -BeNullOrEmpty + } + + It 'does not move the timestamp when only the file mtime changes' { + # Exactly what a checkout or container rebuild does: same bytes, new mtime. + $first = (Get-CippSamPermissions -NoDiff).Timestamp + (Get-Item $script:ManifestPath).LastWriteTime = [datetime]::Now.AddDays(1) + $script:CippSamPermissionsCache = $null; $script:CippSamPermissionsCacheTime = $null + $second = (Get-CippSamPermissions -NoDiff).Timestamp + + $second | Should -Be $first + $script:Written.Count | Should -Be 1 # nothing re-recorded + } + + It 'moves the timestamp when the permission set actually changes' { + $first = (Get-CippSamPermissions -NoDiff).Timestamp + Start-Sleep -Milliseconds 1100 # the stamp has second resolution + Set-Manifest -Scope 'Directory.ReadWrite.All' + $script:CippSamPermissionsCache = $null; $script:CippSamPermissionsCacheTime = $null + $second = (Get-CippSamPermissions -NoDiff).Timestamp + + $second | Should -BeGreaterThan $first + $script:Written.Count | Should -Be 2 + } + + It 'falls back to the mtime if the hash cannot be persisted' { + Mock -CommandName Add-CIPPAzDataTableEntity -MockWith { throw 'table unavailable' } + $mtime = [datetime]::Now.AddDays(-3) + (Get-Item $script:ManifestPath).LastWriteTime = $mtime + + $result = Get-CippSamPermissions -NoDiff + + ([datetime]$result.Timestamp).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') | + Should -Be $mtime.ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') + } +} From 91d9b39b0638ba85e697d37c3d8fa1ffaa1b693e Mon Sep 17 00:00:00 2001 From: Bobby <31723128+kris6673@users.noreply.github.com> Date: Sat, 15 Aug 2026 22:47:24 +0200 Subject: [PATCH 072/226] fix: remove forgotten label and add back in bug label name trigger --- .github/workflows/Comment_on_Issues.yml | 2 +- .github/workflows/Label_Issues.yml | 6 ++---- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/.github/workflows/Comment_on_Issues.yml b/.github/workflows/Comment_on_Issues.yml index 488b633b7d..e3f379208a 100644 --- a/.github/workflows/Comment_on_Issues.yml +++ b/.github/workflows/Comment_on_Issues.yml @@ -6,7 +6,7 @@ on: - labeled jobs: add-comment_bug: - if: github.repository_owner == 'CyberDrain' + if: github.repository_owner == 'CyberDrain' && github.event.label.name == 'bug' runs-on: ubuntu-slim permissions: issues: write diff --git a/.github/workflows/Label_Issues.yml b/.github/workflows/Label_Issues.yml index 821c3535e0..f7878cb2e1 100644 --- a/.github/workflows/Label_Issues.yml +++ b/.github/workflows/Label_Issues.yml @@ -14,8 +14,7 @@ jobs: - name: Label Issues uses: andymckay/labeler@e6c4322d0397f3240f0e7e30a33b5c5df2d39e90 with: - add-labels: "not-assigned" - repo-token: ${{ secrets.GITHUB_TOKEN }} + add-labels: "bug" label_issues_frs: if: github.repository_owner == 'CyberDrain' && contains(github.event.issue.title, 'Feature') runs-on: ubuntu-slim @@ -25,5 +24,4 @@ jobs: - name: Label Issues uses: andymckay/labeler@e6c4322d0397f3240f0e7e30a33b5c5df2d39e90 with: - add-labels: "enhancement, not-assigned" - repo-token: ${{ secrets.GITHUB_TOKEN }} + add-labels: "Feature, no-priority" From c98457d9bddfc8009c0bc75ce616e9b5c0ffccdb Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Sat, 15 Aug 2026 22:57:40 +0200 Subject: [PATCH 073/226] fixes for baselines preps --- .../DisableResourceMailbox.json | 5 +- .../DisableSharedMailbox.json | 5 +- .../Baselines/Get-CIPPBaselineCacheRows.ps1 | 51 ++++++++++++++++ ...-CIPPBaselineDisableBasicAuthSMTPState.ps1 | 11 +--- ...IPPBaselineDisableResourceMailboxState.ps1 | 4 +- ...-CIPPBaselineDisableSharedMailboxState.ps1 | 5 +- .../Get-CIPPBaselineIntuneTemplateState.ps1 | 6 +- ...elineTeamsDisableResourceAccountsState.ps1 | 3 +- .../Baselines/Invoke-CIPPBaselineStandard.ps1 | 8 ++- .../BaselineDefinitions.Catalog.Tests.ps1 | 46 ++++++++++++++ .../Baselines/BaselinePrepareHooks.Tests.ps1 | 61 +++++++++++++++++++ 11 files changed, 187 insertions(+), 18 deletions(-) create mode 100644 backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineCacheRows.ps1 diff --git a/backend/Config/BaselineStandards/Exchange Standards/DisableResourceMailbox.json b/backend/Config/BaselineStandards/Exchange Standards/DisableResourceMailbox.json index c8be7a393f..afe04ac324 100644 --- a/backend/Config/BaselineStandards/Exchange Standards/DisableResourceMailbox.json +++ b/backend/Config/BaselineStandards/Exchange Standards/DisableResourceMailbox.json @@ -34,7 +34,10 @@ "offenders": [] }, "read": { - "cacheType": "Mailboxes" + "cacheType": "Mailboxes", + "collectorArgs": { + "Types": "None" + } }, "prepare": "Get-CIPPBaselineDisableResourceMailboxState", "remediate": { diff --git a/backend/Config/BaselineStandards/Exchange Standards/DisableSharedMailbox.json b/backend/Config/BaselineStandards/Exchange Standards/DisableSharedMailbox.json index 6650c3e578..b38a4c6a02 100644 --- a/backend/Config/BaselineStandards/Exchange Standards/DisableSharedMailbox.json +++ b/backend/Config/BaselineStandards/Exchange Standards/DisableSharedMailbox.json @@ -31,7 +31,10 @@ "offenders": [] }, "read": { - "cacheType": "Mailboxes" + "cacheType": "Mailboxes", + "collectorArgs": { + "Types": "None" + } }, "prepare": "Get-CIPPBaselineDisableSharedMailboxState", "remediate": { diff --git a/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineCacheRows.ps1 b/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineCacheRows.ps1 new file mode 100644 index 0000000000..e05eeacfab --- /dev/null +++ b/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineCacheRows.ps1 @@ -0,0 +1,51 @@ +function Get-CIPPBaselineCacheRows { + <# + .SYNOPSIS + Reads a CIPPDb cache type for a prepare hook, collecting it once if it is empty. + .DESCRIPTION + The engine collects on a miss for exactly ONE cache type - the definition's + read.cacheType. A prepare hook that joins a SECOND type has no such safety net: if + that type has never been collected for a tenant, the hook returns a null Current, the + engine collects the primary type (which was never the problem), re-runs the hook, gets + null again and parks the row at No Data. Forever, on that tenant, with a message + naming the wrong cache. + + Every hook that reads a cache the definition does not declare must therefore go + through this. It reads, and on an empty read triggers Set-CIPPDBCache once and + re-reads. A type with no collector, or a collector that fails, yields an empty set - + the caller decides whether that means No Data. + + Pass CollectorArgs for umbrella collectors whose default is their heaviest option, the + same way a definition declares read.collectorArgs. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter, + [Parameter(Mandatory = $true)] + [string]$Type, + [hashtable]$CollectorArgs = @{} + ) + + $Rows = @(New-CIPPDbRequest -TenantFilter $TenantFilter -Type $Type | Where-Object { $_ }) + if ($Rows.Count -gt 0) { return $Rows } + + $Collector = Get-Command -Name "Set-CIPPDBCache$Type" -ErrorAction SilentlyContinue + if (-not $Collector) { + Write-Information "Baselines: no collector exists for cache type $Type on $TenantFilter." + return @() + } + + try { + $CollectParams = @{ TenantFilter = $TenantFilter } + foreach ($Key in $CollectorArgs.Keys) { $CollectParams[$Key] = $CollectorArgs[$Key] } + $null = & $Collector @CollectParams + } catch { + Write-Information "Baselines: collecting $Type on $TenantFilter failed: $($_.Exception.Message)" + return @() + } + + @(New-CIPPDbRequest -TenantFilter $TenantFilter -Type $Type | Where-Object { $_ }) +} diff --git a/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineDisableBasicAuthSMTPState.ps1 b/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineDisableBasicAuthSMTPState.ps1 index 805a8053e8..f7ed842aeb 100644 --- a/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineDisableBasicAuthSMTPState.ps1 +++ b/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineDisableBasicAuthSMTPState.ps1 @@ -34,16 +34,7 @@ function Get-CIPPBaselineDisableBasicAuthSMTPState { $ExpectedDisabled = "$($Item.Variables.disabled)" -in @('True', 'true', '1') - $Overrides = @(New-CIPPDbRequest -TenantFilter $TenantFilter -Type 'ExoCASMailboxSmtpAuth' | Where-Object { $_ }) - if ($Overrides.Count -eq 0) { - $Collector = Get-Command -Name 'Set-CIPPDBCacheExoCASMailboxSmtpAuth' -ErrorAction SilentlyContinue - if ($Collector) { - try { $null = & $Collector -TenantFilter $TenantFilter } catch { - Write-Information "Baselines: SMTP AUTH override cache collection on $TenantFilter failed: $($_.Exception.Message)" - } - $Overrides = @(New-CIPPDbRequest -TenantFilter $TenantFilter -Type 'ExoCASMailboxSmtpAuth' | Where-Object { $_ }) - } - } + $Overrides = @(Get-CIPPBaselineCacheRows -TenantFilter $TenantFilter -Type 'ExoCASMailboxSmtpAuth') $EnabledUsers = @($Overrides | ForEach-Object { "$($_.PrimarySmtpAddress ?? $_.Identity)" } | Where-Object { $_ } | Sort-Object) $Expected = [PSCustomObject]@{ SmtpClientAuthenticationDisabled = $ExpectedDisabled } diff --git a/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineDisableResourceMailboxState.ps1 b/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineDisableResourceMailboxState.ps1 index 0a6ea0fd80..a1fad85cb0 100644 --- a/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineDisableResourceMailboxState.ps1 +++ b/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineDisableResourceMailboxState.ps1 @@ -20,7 +20,9 @@ function Get-CIPPBaselineDisableResourceMailboxState { $TenantFilter ) - $Users = @(New-CIPPDbRequest -TenantFilter $TenantFilter -Type 'Users' | Where-Object { $_ }) + # Users is the SECOND cache - see Get-CIPPBaselineCacheRows for why reading it directly + # parks the standard at No Data forever on a tenant that never collected it. + $Users = @(Get-CIPPBaselineCacheRows -TenantFilter $TenantFilter -Type 'Users') $Mailboxes = @(New-CIPPDbRequest -TenantFilter $TenantFilter -Type 'Mailboxes' | Where-Object { $_ }) if ($Users.Count -eq 0 -or $Mailboxes.Count -eq 0) { return @{ Current = $null } } diff --git a/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineDisableSharedMailboxState.ps1 b/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineDisableSharedMailboxState.ps1 index 8004d23a2e..0dfea4da2f 100644 --- a/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineDisableSharedMailboxState.ps1 +++ b/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineDisableSharedMailboxState.ps1 @@ -23,7 +23,10 @@ function Get-CIPPBaselineDisableSharedMailboxState { $TenantFilter ) - $Users = @(New-CIPPDbRequest -TenantFilter $TenantFilter -Type 'Users' | Where-Object { $_ }) + # Users is the SECOND cache: the definition declares Mailboxes, so the engine only + # collect-on-misses that one. Reading Users directly meant a tenant that had never + # collected it returned No Data on every run, permanently, blaming Mailboxes. + $Users = @(Get-CIPPBaselineCacheRows -TenantFilter $TenantFilter -Type 'Users') $Mailboxes = @(New-CIPPDbRequest -TenantFilter $TenantFilter -Type 'Mailboxes' | Where-Object { $_ }) if ($Users.Count -eq 0 -or $Mailboxes.Count -eq 0) { return @{ Current = $null } } diff --git a/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineIntuneTemplateState.ps1 b/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineIntuneTemplateState.ps1 index 51fe78aac0..c09008b121 100644 --- a/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineIntuneTemplateState.ps1 +++ b/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineIntuneTemplateState.ps1 @@ -68,7 +68,11 @@ function Get-CIPPBaselineIntuneTemplateState { $ReusableGuid = "$($Reusable.GUID ?? $Reusable.guid ?? $Reusable.id)" $ReusableName = "$($Reusable.DisplayName ?? $Reusable.displayName ?? $Reusable.name ?? $Reusable.Setting.displayName)" if (-not $ReusableGuid -or -not $ReusableName) { continue } - $TenantReusable = @($(try { New-CIPPDbRequest -TenantFilter $TenantFilter -Type 'IntuneReusableSettings' } catch { $null }) | Where-Object { $_.displayName -eq $ReusableName }) | Select-Object -First 1 + # Collect-on-miss: IntuneReusableSettings is not in this definition's requiredCaches, + # so a tenant that never collected it used to yield no match, leave the TEMPLATE's + # foreign GUID in the payload, and report permanent false drift - with remediation + # deploying a policy that references a reusable setting the tenant does not have. + $TenantReusable = @($(try { Get-CIPPBaselineCacheRows -TenantFilter $TenantFilter -Type 'IntuneReusableSettings' } catch { $null }) | Where-Object { $_.displayName -eq $ReusableName }) | Select-Object -First 1 if ($TenantReusable.id) { $RawJson = $RawJson.Replace($ReusableGuid, "$($TenantReusable.id)") } } $RawJson = Get-CIPPTextReplacement -TenantFilter $TenantFilter -Text $RawJson -EscapeForJson diff --git a/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineTeamsDisableResourceAccountsState.ps1 b/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineTeamsDisableResourceAccountsState.ps1 index 95e70b258d..5b581fdd43 100644 --- a/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineTeamsDisableResourceAccountsState.ps1 +++ b/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineTeamsDisableResourceAccountsState.ps1 @@ -23,7 +23,8 @@ function Get-CIPPBaselineTeamsDisableResourceAccountsState { $Accounts = @(New-CIPPDbRequest -TenantFilter $TenantFilter -Type 'TeamsResourceAccounts' | Where-Object { $_ }) if ($Accounts.Count -eq 0) { return @{ Current = $null } } - $Users = @(New-CIPPDbRequest -TenantFilter $TenantFilter -Type 'Users' | Where-Object { $_ }) + # Users is the SECOND cache - see Get-CIPPBaselineCacheRows. + $Users = @(Get-CIPPBaselineCacheRows -TenantFilter $TenantFilter -Type 'Users') $EnabledIds = @{} foreach ($User in $Users) { if ($User.accountEnabled -eq $true -and $User.onPremisesSyncEnabled -ne $true) { $EnabledIds["$($User.id)"] = $true } diff --git a/backend/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineStandard.ps1 b/backend/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineStandard.ps1 index 0a50a6bdbd..0cca8e7dcc 100644 --- a/backend/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineStandard.ps1 +++ b/backend/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineStandard.ps1 @@ -305,6 +305,10 @@ function Invoke-CIPPBaselineStandard { # true for every definition that simply omits the property. $JustRefreshed = $false $CacheCollector = Get-Command -Name "Set-CIPPDBCache$($Definition.read.cacheType)" -ErrorAction SilentlyContinue + $CollectorArgs = @{ TenantFilter = $TenantFilter } + foreach ($Argument in ($Definition.read.collectorArgs ?? [PSCustomObject]@{}).PSObject.Properties) { + $CollectorArgs[$Argument.Name] = $Argument.Value + } if ($CacheCollector -and @($Definition.read.requiredCaches | Where-Object { $_ }).Count -gt 0) { $JustRefreshed = Wait-CIPPBaselineCacheReady -TenantFilter $TenantFilter -Definition $Definition -RunId $RunId } @@ -316,7 +320,7 @@ function Invoke-CIPPBaselineStandard { $Prepared = & $Definition.prepare -Item $Item -TenantFilter $TenantFilter if ($null -eq $Prepared.Current -and $CacheCollector -and -not $JustRefreshed) { try { - $null = & $CacheCollector -TenantFilter $TenantFilter + $null = & $CacheCollector @CollectorArgs $Prepared = & $Definition.prepare -Item $Item -TenantFilter $TenantFilter } catch { Write-Information "Baselines: cache collection for $($Definition.read.cacheType) on $TenantFilter failed: $($_.Exception.Message)" @@ -350,7 +354,7 @@ function Invoke-CIPPBaselineStandard { $Current = & $ReadCurrent if ($null -eq $Current -and $CacheCollector -and -not $JustRefreshed) { try { - $null = & $CacheCollector -TenantFilter $TenantFilter + $null = & $CacheCollector @CollectorArgs $Current = & $ReadCurrent } catch { Write-Information "Baselines: cache collection for $($Definition.read.cacheType) on $TenantFilter failed: $($_.Exception.Message)" diff --git a/backend/Tests/Baselines/BaselineDefinitions.Catalog.Tests.ps1 b/backend/Tests/Baselines/BaselineDefinitions.Catalog.Tests.ps1 index e0cc2e5904..7bba5a1286 100644 --- a/backend/Tests/Baselines/BaselineDefinitions.Catalog.Tests.ps1 +++ b/backend/Tests/Baselines/BaselineDefinitions.Catalog.Tests.ps1 @@ -86,6 +86,52 @@ Describe 'Baseline definition catalog' { $Broken | Should -BeNullOrEmpty } + It 'never lets a cache miss trigger an umbrella collector at its full fan-out' { + # Collect-on-miss invokes Set-CIPPDBCache with whatever the definition declares. + # These collectors take their heaviest option by DEFAULT when handed a bare + # -TenantFilter: Set-CIPPDBCacheMailboxes defaults to Types 'All', which queues mailbox + # permission, calendar permission and rules batches across every mailbox in the tenant. + # A standard reading two fields off a mailbox row must never set that off, and nothing + # in the definition hints at it - hence this test. + $Umbrella = @{ Mailboxes = 'Types' } + $Broken = @($script:Definitions | Where-Object { $Umbrella.ContainsKey("$($_.Definition.read.cacheType)") + } | Where-Object { + $Argument = $Umbrella["$($_.Definition.read.cacheType)"] + [string]::IsNullOrWhiteSpace("$($_.Definition.read.collectorArgs.$Argument)") + } | ForEach-Object { "$($_.Name) reads $($_.Definition.read.cacheType) without read.collectorArgs" }) + $Broken | Should -BeNullOrEmpty + } + + It 'never reads a second cache type without collect-on-miss' { + # The engine collects on a miss for read.cacheType and nothing else. A prepare hook + # that reads a SECOND type with a bare New-CIPPDbRequest returns a null Current on any + # tenant that never collected it, the engine collects the primary type instead, the + # hook returns null again, and the row parks at No Data permanently - logged against + # the wrong cache name. Second types must go through Get-CIPPBaselineCacheRows. + $Broken = @($script:Definitions | Where-Object { $_.Definition.prepare } | ForEach-Object { + $Name = $_.Name + # requiredCaches is the other guarantee: Wait-CIPPBaselineCacheReady refuses to + # run a template standard until every entry has been collected at least once, + # so those types need no collect-on-miss of their own. + $Declared = @("$($_.Definition.read.cacheType)") + @($_.Definition.read.requiredCaches | Where-Object { $_ }) + $Path = Join-Path $script:RepoRoot "Modules/CIPPCore/Public/Baselines/$($_.Definition.prepare).ps1" + if (-not (Test-Path $Path)) { return } + $Ast = [System.Management.Automation.Language.Parser]::ParseFile($Path, [ref]$null, [ref]$null) + $Ast.FindAll({ param($n) $n -is [System.Management.Automation.Language.CommandAst] }, $true) | ForEach-Object { + if ("$($_.GetCommandName())" -ne 'New-CIPPDbRequest') { return } + $Elements = @($_.CommandElements) + for ($i = 0; $i -lt $Elements.Count - 1; $i++) { + if ($Elements[$i] -is [System.Management.Automation.Language.CommandParameterAst] -and + $Elements[$i].ParameterName -eq 'Type') { + $Read = "$($Elements[$i + 1].Value)" + if ($Read -and $Declared -notcontains $Read) { "$Name reads '$Read' directly but declares $($Declared -join ', ')" } + } + } + } + }) + $Broken | Should -BeNullOrEmpty + } + It 'gives every non-package, non-manual definition something to compare' { $Broken = @($script:Definitions | Where-Object { -not $_.Definition.package -and -not $_.Definition.manual -and diff --git a/backend/Tests/Baselines/BaselinePrepareHooks.Tests.ps1 b/backend/Tests/Baselines/BaselinePrepareHooks.Tests.ps1 index 2f3aa6b43d..84d37ec570 100644 --- a/backend/Tests/Baselines/BaselinePrepareHooks.Tests.ps1 +++ b/backend/Tests/Baselines/BaselinePrepareHooks.Tests.ps1 @@ -26,6 +26,7 @@ BeforeAll { . (Join-Path $script:RepoRoot 'Modules/CIPPCore/Public/Get-CIPPIntuneCompareExclusions.ps1') . (Join-Path $script:RepoRoot 'Modules/CIPPCore/Public/Compare-CIPPIntuneObject.ps1') + . (Join-Path $Baselines 'Get-CIPPBaselineCacheRows.ps1') . (Join-Path $Baselines 'Get-CIPPBaselineDeviceRegistrationPolicyState.ps1') . (Join-Path $Baselines 'Get-CIPPBaselineDisableBasicAuthSMTPState.ps1') . (Join-Path $Baselines 'Get-CIPPBaselineActivityBasedTimeoutState.ps1') @@ -164,3 +165,63 @@ Describe 'Get-CIPPBaselineActivityBasedTimeoutState' { (Get-CIPPBaselineActivityBasedTimeoutState -Item $null -TenantFilter $script:Tenant).Current | Should -BeNullOrEmpty } } + +Describe 'Get-CIPPBaselineCacheRows' { + # The fix for DisableSharedMailbox always reporting No Data. The engine collects on a miss + # for read.cacheType only, so a hook joining a second type has to collect that one itself + # or the standard never recovers on a tenant that has not collected it. + BeforeAll { + function Set-CIPPDBCacheProbeType { param($TenantFilter, $Extra) } + } + BeforeEach { $script:Collected = 0 } + + It 'returns rows without collecting when the cache is already populated' { + Mock New-CIPPDbRequest { @([PSCustomObject]@{ id = 'x' }) } + Mock Set-CIPPDBCacheProbeType { $script:Collected++ } + $Rows = @(Get-CIPPBaselineCacheRows -TenantFilter $script:Tenant -Type 'ProbeType') + $Rows.Count | Should -Be 1 + Should -Invoke Set-CIPPDBCacheProbeType -Times 0 + } + + It 'collects once and re-reads when the cache is empty' { + $script:Populated = $false + Mock New-CIPPDbRequest { if ($script:Populated) { @([PSCustomObject]@{ id = 'x' }) } else { @() } } + Mock Set-CIPPDBCacheProbeType { $script:Populated = $true } + $Rows = @(Get-CIPPBaselineCacheRows -TenantFilter $script:Tenant -Type 'ProbeType') + Should -Invoke Set-CIPPDBCacheProbeType -Times 1 + $Rows.Count | Should -Be 1 + } + + It 'passes collector arguments through, so an umbrella collector is not run at full fan-out' { + Mock New-CIPPDbRequest { @() } + Mock Set-CIPPDBCacheProbeType {} + $null = Get-CIPPBaselineCacheRows -TenantFilter $script:Tenant -Type 'ProbeType' -CollectorArgs @{ Extra = 'None' } + Should -Invoke Set-CIPPDBCacheProbeType -Times 1 -ParameterFilter { $Extra -eq 'None' } + } + + It 'returns empty rather than throwing when the type has no collector' { + Mock New-CIPPDbRequest { @() } + $Rows = @(Get-CIPPBaselineCacheRows -TenantFilter $script:Tenant -Type 'TypeWithNoCollector') + $Rows.Count | Should -Be 0 + } + + It 'returns empty rather than throwing when collection fails' { + Mock New-CIPPDbRequest { @() } + Mock Set-CIPPDBCacheProbeType { throw 'Graph said no' } + $Rows = @(Get-CIPPBaselineCacheRows -TenantFilter $script:Tenant -Type 'ProbeType') + $Rows.Count | Should -Be 0 + } +} + +Describe 'Get-CIPPBaselineCacheRows row fidelity' { + # Returning the array with a unary comma made every populated cache read as ONE row: a + # tenant with three users produced a single array object, the join found no candidates, + # and the standard scored Compliant with an empty offender list. Silently wrong, which is + # worse than the No Data it replaced. + It 'returns every row, not a single wrapped array' { + Mock New-CIPPDbRequest { @([PSCustomObject]@{ id = 'a' }, [PSCustomObject]@{ id = 'b' }, [PSCustomObject]@{ id = 'c' }) } + $Rows = @(Get-CIPPBaselineCacheRows -TenantFilter $script:Tenant -Type 'ProbeType') + $Rows.Count | Should -Be 3 + $Rows[1].id | Should -Be 'b' + } +} From 31eef9f6577a807370f312743dec67138176ea3b Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Sat, 15 Aug 2026 23:11:23 +0200 Subject: [PATCH 074/226] test the cache collection before implementing --- ...PPBaselineLegacyEmailReportAddinsState.ps1 | 9 ++++- ...Get-CIPPBaselineStaleEntraDevicesState.ps1 | 9 ++++- ...elineTeamsDisableResourceAccountsState.ps1 | 10 ++++- .../Test-CIPPBaselineCacheCollected.ps1 | 38 ++++++++++++++++++ .../Baselines/BaselinePrepareHooks.Tests.ps1 | 40 +++++++++++++++++++ 5 files changed, 103 insertions(+), 3 deletions(-) create mode 100644 backend/Modules/CIPPCore/Public/Baselines/Test-CIPPBaselineCacheCollected.ps1 diff --git a/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineLegacyEmailReportAddinsState.ps1 b/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineLegacyEmailReportAddinsState.ps1 index 29e6953cd7..a350998b0a 100644 --- a/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineLegacyEmailReportAddinsState.ps1 +++ b/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineLegacyEmailReportAddinsState.ps1 @@ -18,7 +18,14 @@ function Get-CIPPBaselineLegacyEmailReportAddinsState { ) $Apps = @(New-CIPPDbRequest -TenantFilter $TenantFilter -Type 'Apps' | Where-Object { $_ }) - if ($Apps.Count -eq 0) { return @{ Current = $null } } + if ($Apps.Count -eq 0) { + # No app registrations at all means neither legacy add-in is installed, which is the + # compliant state - but only once the type has actually been collected. + if (Test-CIPPBaselineCacheCollected -TenantFilter $TenantFilter -Type 'Apps') { + return @{ Current = [PSCustomObject]@{ offenders = @(); targets = @() } } + } + return @{ Current = $null } + } $Legacy = @{ '3f32746a-0586-4c54-b8ce-d3b611c5b6c8' = 'Report Phishing' diff --git a/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineStaleEntraDevicesState.ps1 b/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineStaleEntraDevicesState.ps1 index e458062bdb..746dab947a 100644 --- a/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineStaleEntraDevicesState.ps1 +++ b/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineStaleEntraDevicesState.ps1 @@ -31,7 +31,14 @@ function Get-CIPPBaselineStaleEntraDevicesState { if ($DeleteDelta -lt 0) { $DeleteDelta = 0 } $Devices = @(New-CIPPDbRequest -TenantFilter $TenantFilter -Type 'Devices' | Where-Object { $_ -and $_.approximateLastSignInDateTime }) - if ($Devices.Count -eq 0) { return @{ Current = $null } } + if ($Devices.Count -eq 0) { + # A tenant with no registered devices - or none that ever signed in - has nothing + # stale to clean up. Once the type has been collected that is compliant, not unknown. + if (Test-CIPPBaselineCacheCollected -TenantFilter $TenantFilter -Type 'Devices') { + return @{ Current = [PSCustomObject]@{ offenders = @(); devicesToDisable = @(); devicesToDelete = @() } } + } + return @{ Current = $null } + } $DisableDate = (Get-Date).AddDays(-$DisableThreshold) $DeleteDate = (Get-Date).AddDays(-($DisableThreshold + $DeleteDelta)) diff --git a/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineTeamsDisableResourceAccountsState.ps1 b/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineTeamsDisableResourceAccountsState.ps1 index 5b581fdd43..517d006606 100644 --- a/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineTeamsDisableResourceAccountsState.ps1 +++ b/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineTeamsDisableResourceAccountsState.ps1 @@ -21,7 +21,15 @@ function Get-CIPPBaselineTeamsDisableResourceAccountsState { ) $Accounts = @(New-CIPPDbRequest -TenantFilter $TenantFilter -Type 'TeamsResourceAccounts' | Where-Object { $_ }) - if ($Accounts.Count -eq 0) { return @{ Current = $null } } + if ($Accounts.Count -eq 0) { + # Plenty of tenants run no auto attendants or call queues at all. Once the type has + # been collected, empty is the answer - nothing to disable, so compliant. Before that + # it is unknown, and the engine collects and retries. + if (Test-CIPPBaselineCacheCollected -TenantFilter $TenantFilter -Type 'TeamsResourceAccounts') { + return @{ Current = [PSCustomObject]@{ offenders = @(); targets = @() } } + } + return @{ Current = $null } + } # Users is the SECOND cache - see Get-CIPPBaselineCacheRows. $Users = @(Get-CIPPBaselineCacheRows -TenantFilter $TenantFilter -Type 'Users') diff --git a/backend/Modules/CIPPCore/Public/Baselines/Test-CIPPBaselineCacheCollected.ps1 b/backend/Modules/CIPPCore/Public/Baselines/Test-CIPPBaselineCacheCollected.ps1 new file mode 100644 index 0000000000..ce5505ffce --- /dev/null +++ b/backend/Modules/CIPPCore/Public/Baselines/Test-CIPPBaselineCacheCollected.ps1 @@ -0,0 +1,38 @@ +function Test-CIPPBaselineCacheCollected { + <# + .SYNOPSIS + Tells a prepare hook whether a cache type has ever been collected for a tenant, + independently of whether it holds any rows. + .DESCRIPTION + Zero rows means two completely different things and a hook cannot tell them apart by + counting: the type has never been collected, or it was collected and the tenant + genuinely has nothing. Add-CIPPDbItem writes a '-Count' metadata row either way, + so its presence is the signal - DetectedApps-Count = 0 and ManagedDevices-Count = 0 + both exist on tenants that really have none. + + Use this ONLY where empty is a legitimate answer: a tenant with no Teams resource + accounts or no registered devices is compliant, not unknown. Do NOT use it for a type + whose emptiness implies a broken collection - an Exchange tenant with zero cached + mailboxes is a collection failure, and reporting it compliant would be a lie. + + A lookup that fails is reported as NOT collected, so the caller falls back to No Data + rather than claiming compliance it cannot prove. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter, + [Parameter(Mandatory = $true)] + [string]$Type + ) + + try { + $Meta = Get-CIPPDbItem -TenantFilter $TenantFilter -Type $Type -CountsOnly + return ($null -ne ($Meta | Select-Object -First 1)) + } catch { + Write-Information "Baselines: could not read collection metadata for $Type on $TenantFilter : $($_.Exception.Message)" + return $false + } +} diff --git a/backend/Tests/Baselines/BaselinePrepareHooks.Tests.ps1 b/backend/Tests/Baselines/BaselinePrepareHooks.Tests.ps1 index 84d37ec570..75a193c505 100644 --- a/backend/Tests/Baselines/BaselinePrepareHooks.Tests.ps1 +++ b/backend/Tests/Baselines/BaselinePrepareHooks.Tests.ps1 @@ -225,3 +225,43 @@ Describe 'Get-CIPPBaselineCacheRows row fidelity' { $Rows[1].id | Should -Be 'b' } } + +Describe 'Empty-but-collected caches' { + # Zero rows means two different things. A hook that cannot tell them apart parks the row + # at No Data forever on a tenant that legitimately has nothing - the same permanent-No-Data + # failure as the missing second cache, just triggered by an empty one. + BeforeAll { + . (Join-Path $Baselines 'Test-CIPPBaselineCacheCollected.ps1') + . (Join-Path $Baselines 'Get-CIPPBaselineTeamsDisableResourceAccountsState.ps1') + . (Join-Path $Baselines 'Get-CIPPBaselineStaleEntraDevicesState.ps1') + function Get-CIPPDbItem { param($TenantFilter, $Type, [switch]$CountsOnly) } + } + BeforeEach { Mock New-CIPPDbRequest { @() } } + + It 'scores a tenant with no Teams resource accounts compliant once the type is collected' { + Mock Get-CIPPDbItem { [PSCustomObject]@{ RowKey = 'TeamsResourceAccounts-Count'; DataCount = 0 } } + $Current = (Get-CIPPBaselineTeamsDisableResourceAccountsState -Item $null -TenantFilter $script:Tenant).Current + $Current | Should -Not -BeNullOrEmpty + @($Current.offenders).Count | Should -Be 0 + } + + It 'still reports unknown when the type has never been collected' { + Mock Get-CIPPDbItem { $null } + (Get-CIPPBaselineTeamsDisableResourceAccountsState -Item $null -TenantFilter $script:Tenant).Current | Should -BeNullOrEmpty + } + + It 'scores a tenant with no devices compliant once the type is collected' { + Mock Get-CIPPDbItem { [PSCustomObject]@{ RowKey = 'Devices-Count'; DataCount = 0 } } + $Item = [PSCustomObject]@{ Variables = [PSCustomObject]@{ deviceAgeThreshold = 90; deviceDeleteThreshold = 0 } } + $Current = (Get-CIPPBaselineStaleEntraDevicesState -Item $Item -TenantFilter $script:Tenant).Current + $Current | Should -Not -BeNullOrEmpty + @($Current.devicesToDisable).Count | Should -Be 0 + @($Current.devicesToDelete).Count | Should -Be 0 + } + + It 'treats a failed metadata lookup as not collected, never as compliant' { + # Claiming compliance we cannot prove is the one outcome worse than No Data. + Mock Get-CIPPDbItem { throw 'storage unavailable' } + (Get-CIPPBaselineTeamsDisableResourceAccountsState -Item $null -TenantFilter $script:Tenant).Current | Should -BeNullOrEmpty + } +} From 46b9bc25540280d15e87739c73563803a4776ca0 Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Sat, 15 Aug 2026 23:22:06 +0200 Subject: [PATCH 075/226] fixes storing of standards --- .../Baselines/Invoke-CIPPBaselineStandard.ps1 | 14 +++++- .../Baselines/BaselineExecutors.Tests.ps1 | 48 +++++++++++++++++++ 2 files changed, 60 insertions(+), 2 deletions(-) diff --git a/backend/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineStandard.ps1 b/backend/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineStandard.ps1 index 0cca8e7dcc..d51473b4a5 100644 --- a/backend/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineStandard.ps1 +++ b/backend/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineStandard.ps1 @@ -50,9 +50,19 @@ function Invoke-CIPPBaselineStandard { } foreach ($Variable in (($Variables ?? [PSCustomObject]@{}).PSObject.Properties)) { $Token = '%{0}%' -f $Variable.Name - $EncodedValue = ConvertTo-Json -Compress -Depth 100 -InputObject $Variable.Value + $Value = $Variable.Value + # A number field is saved as a STRING ("30", not 30) - the frontend posts what the + # input holds. Splicing that into an exact "%var%" token yields a JSON string, and + # the compare is type-strict: expected "50" never equals a cached 50, so the + # standard reports drift forever and remediation writes the string back. Coerce on + # the DECLARED type so already-saved baselines are fixed too, not just new ones. + if ("$(($Definition.variables ?? [PSCustomObject]@{}).($Variable.Name).type)" -eq 'number' -and + $Value -is [string] -and "$Value" -match '^-?\d+(\.\d+)?$') { + $Value = if ("$Value" -match '^-?\d+$') { [int64]"$Value" } else { [double]"$Value" } + } + $EncodedValue = ConvertTo-Json -Compress -Depth 100 -InputObject $Value $Json = $Json.Replace(('"{0}"' -f $Token), $EncodedValue) - $Json = $Json.Replace($Token, "$($Variable.Value)") + $Json = $Json.Replace($Token, "$Value") } $Json = Get-CIPPTextReplacement -TenantFilter $TenantFilter -Text $Json -EscapeForJson $Json | ConvertFrom-Json diff --git a/backend/Tests/Baselines/BaselineExecutors.Tests.ps1 b/backend/Tests/Baselines/BaselineExecutors.Tests.ps1 index 942dd3c915..5815bf1938 100644 --- a/backend/Tests/Baselines/BaselineExecutors.Tests.ps1 +++ b/backend/Tests/Baselines/BaselineExecutors.Tests.ps1 @@ -293,3 +293,51 @@ Describe 'Invoke-CIPPBaselineGraphBulkSweep' { Should -Invoke Set-CIPPDBCacheUsers -Times 1 -ParameterFilter { $TenantFilter -eq $script:Tenant } } } + +Describe 'Number variable rendering' { + # Live evidence: a saved baseline stores number fields as strings - + # {"deviceAgeThreshold":"30","deviceDeleteThreshold":"7"} - while switches store real + # booleans. Spliced raw, "50" never equals a cached 50 under the type-strict compare, so + # the standard drifts forever and remediation writes a string into a numeric property. + BeforeAll { + . (Join-Path $script:RepoRoot 'Modules/CIPPCore/Public/Get-CIPPIntuneCompareExclusions.ps1') + . (Join-Path $script:RepoRoot 'Modules/CIPPCore/Public/Compare-CIPPIntuneObject.ps1') + + # The engine's render, reduced to the substitution it performs. + function Invoke-EngineRender { + param($Definition, $Template, $Variables) + $Json = ConvertTo-Json -Compress -Depth 100 -InputObject $Template + foreach ($Variable in $Variables.PSObject.Properties) { + $Token = '%{0}%' -f $Variable.Name + $Value = $Variable.Value + if ("$(($Definition.variables ?? [PSCustomObject]@{}).($Variable.Name).type)" -eq 'number' -and + $Value -is [string] -and "$Value" -match '^-?\d+(\.\d+)?$') { + $Value = if ("$Value" -match '^-?\d+$') { [int64]"$Value" } else { [double]"$Value" } + } + $Json = $Json.Replace(('"{0}"' -f $Token), (ConvertTo-Json -Compress -Depth 100 -InputObject $Value)) + $Json = $Json.Replace($Token, "$Value") + } + $Json | ConvertFrom-Json + } + $script:Definition = @{ variables = @{ max = @{ type = 'number' }; label = @{ type = 'textField' } } } | ConvertTo-Spec + } + + It 'renders a string-saved number as a number, so it matches the cached value' { + $Expected = Invoke-EngineRender -Definition $script:Definition -Template (@{ userDeviceQuota = '%max%' } | ConvertTo-Spec) -Variables ([PSCustomObject]@{ max = '50' }) + $Current = '{"userDeviceQuota":50}' | ConvertFrom-Json + @(Compare-CIPPIntuneObject -ReferenceObject $Expected -DifferenceObject $Current | Where-Object { $_ }).Count | Should -Be 0 + } + + It 'still reports real drift on a different number' { + $Expected = Invoke-EngineRender -Definition $script:Definition -Template (@{ userDeviceQuota = '%max%' } | ConvertTo-Spec) -Variables ([PSCustomObject]@{ max = '20' }) + $Current = '{"userDeviceQuota":50}' | ConvertFrom-Json + @(Compare-CIPPIntuneObject -ReferenceObject $Expected -DifferenceObject $Current | Where-Object { $_ }).Count | Should -Be 1 + } + + It 'leaves a non-number variable as the string it is' { + # Coercing on value shape rather than declared type would turn a textField holding + # "30" into a number and break string compares. + $Expected = Invoke-EngineRender -Definition $script:Definition -Template (@{ label = '%label%' } | ConvertTo-Spec) -Variables ([PSCustomObject]@{ label = '30' }) + $Expected.label | Should -BeOfType ([string]) + } +} From eb15fb672cb51f2bd87787e58bedf7c7bea7883d Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Sat, 15 Aug 2026 23:30:34 +0200 Subject: [PATCH 076/226] baseline tests --- .../TeamsDisableResourceAccounts.json | 8 +++++++- .../DBCache/Set-CIPPDBCacheTeamsResourceAccounts.ps1 | 12 ++++++------ 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/backend/Config/BaselineStandards/Teams Standards/TeamsDisableResourceAccounts.json b/backend/Config/BaselineStandards/Teams Standards/TeamsDisableResourceAccounts.json index 59d65789ed..aee7dd06e7 100644 --- a/backend/Config/BaselineStandards/Teams Standards/TeamsDisableResourceAccounts.json +++ b/backend/Config/BaselineStandards/Teams Standards/TeamsDisableResourceAccounts.json @@ -16,7 +16,13 @@ "Microsoft", "CIPP" ], - "requiredCapabilities": [], + "requiredCapabilities": [ + "MCOSTANDARD", + "MCOEV", + "MCOIMP", + "TEAMS1", + "Teams_Room_Standard" + ], "secureScoreImpact": 0, "compare": "subset", "variables": {}, diff --git a/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheTeamsResourceAccounts.ps1 b/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheTeamsResourceAccounts.ps1 index e94d58d2ce..6ae50e8ecf 100644 --- a/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheTeamsResourceAccounts.ps1 +++ b/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheTeamsResourceAccounts.ps1 @@ -51,12 +51,12 @@ function Set-CIPPDBCacheTeamsResourceAccounts { $SkipToken = $Page.skipToken } while ($SkipToken) - if ($ResourceAccounts.Count -gt 0) { - Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'TeamsResourceAccounts' -Data @($ResourceAccounts) -AddCount - Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Cached $($ResourceAccounts.Count) Teams resource accounts" -sev Debug - } else { - Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'No Teams resource accounts found' -sev Debug - } + # Written even when the tenant has none: the '-Count' row is the only signal + # that separates 'collected, genuinely empty' from 'never collected'. Guarding this + # on Count -gt 0 left a tenant with no auto attendants or call queues permanently + # indistinguishable from an uncollected one, so the standard could never resolve. + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'TeamsResourceAccounts' -Data @($ResourceAccounts) -AddCount + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Cached $($ResourceAccounts.Count) Teams resource accounts" -sev Debug } catch { $ErrorMessage = Get-CippException -Exception $_ From b77817b316807cf7861a97248f9aff16da36d293 Mon Sep 17 00:00:00 2001 From: Bobby <31723128+kris6673@users.noreply.github.com> Date: Sat, 15 Aug 2026 23:38:44 +0200 Subject: [PATCH 077/226] docs: update project structure and contributing to the code docs to new monorepo structure Also prettify a bit --- .../cipp-dev-guide/project-structure.md | 92 ++++++++++------- .../contributing-to-the-code.md | 98 +++++++++---------- 2 files changed, 101 insertions(+), 89 deletions(-) diff --git a/docs/dev-documentation/cipp-dev-guide/project-structure.md b/docs/dev-documentation/cipp-dev-guide/project-structure.md index 4ba9d78ecc..4ae8fa4b81 100644 --- a/docs/dev-documentation/cipp-dev-guide/project-structure.md +++ b/docs/dev-documentation/cipp-dev-guide/project-structure.md @@ -1,39 +1,57 @@ # Project Structure -this page looks at what's in the `frontend` folder so you know where to look when you start coding. - -### The Root - -In the `frontend` directory itself there are a number of files and folders, the table below highlights the important ones: - -| Item | Description | -| -------------------- | -------------------------------------------------------------------------------------------------------------- | -| `public` | Holds static files used when compiling CIPP (building) for use. Mostly images and a little `HTML` scaffolding. | -| `src` | Holds the code that powers CIPP, this is where most CIPP development takes place. | -| `tests` | Holds storybook tests and jsdom test files. | -| `package.json` | An npm package file - this tells npm what other libraries/resources to use when building CIPP. | -| `package-lock.json` | An npm package file - this tells npm exact version numbers/packages to use for repeatable builds. | -| `version_latest.txt` | Our version file. This gets incremented just before `dev` gets merged into `main` for a new release. | - -### The Source - -The table below goes into detail on the contents of the `src` directory: - -| Item | Description | -| --------------- | ---------------------------------------------------------------------------------------------------------- | -| `assets/images` | Holds image files used when building the app. | -| `components` | Holds custom [React components](https://reactjs.org/docs/components-and-props.html) used throughout CIPP. | -| `data` | Holds static data files used throughout CIPP. At the time of writing the only one is `countryList.json`. | -| `hooks` | Holds custom [React hooks](https://reactjs.org/docs/hooks-reference.html) used throughout CIPP. | -| `layout` | Holds the main layout file which handles setting up the overall layout of the CIPP user interface. | -| `scss` | Holds the [SCSS](https://sass-lang.com/) files which control the look and feel of the CIPP user interface. | -| `store` | Holds the various API interfaces, app feature functional code and middle-ware to drive CIPP functionality. | -| `views` | Holds the pages which make up the CIPP user interface. | - -of the remaining files in the `src` directory the following are noteworthy: - -| Item | Description | -| ---------------- | --------------------------------------------------------------------- | -| `_nav.js` | Holds the navigation items displayed in the left hand navigation bar. | -| `adminRoutes.js` | Holds information on admin-privileged routes. | -| `routes.js` | Holds information on routes. | +CIPP is a mono-repository with three top-level directories: `frontend/` for the web interface, `backend/` for the API, and `build/` for Docker and dev tooling. + +## Frontend + +### Root files + +| Item | Description | +| ------------------- | ----------------------------------------------------------------- | +| `src/` | Application source code, where most frontend development happens. | +| `public/` | Static assets served as-is (images, favicons). | +| `tests/` | Vitest unit tests and Storybook interaction tests. | +| `package.json` | Dependencies and scripts. | +| `yarn.lock` | Locked dependency versions for repeatable installs. | +| `next.config.js` | Next.js configuration. | +| `eslint.config.mjs` | ESLint flat config (extends `eslint-config-next` + Prettier). | +| `vitest.config.mjs` | Vitest test runner configuration. | + +### The `src/` directory + +| Item | Description | +| ------------- | ---------------------------------------------------------------------------------- | +| `pages/` | Next.js pages router. Each file or directory maps to a URL route. | +| `components/` | Reusable React components, with CIPP-specific components under `components/Cipp*`. | +| `sections/` | Page-specific sections and layouts used by pages. | +| `layouts/` | Application layout wrappers (sidebar, header, navigation). | +| `api/` | API call layer (`ApiCall.jsx`), wrapping React Query around axios. | +| `store/` | Redux Toolkit slices for cross-cutting application state. | +| `contexts/` | React context providers. | +| `hooks/` | Custom React hooks. | +| `theme/` | MUI theme configuration (palette, typography, component overrides). | +| `styles/` | Global and utility styles. | +| `data/` | Static data files (for example, `countryList.json`). | +| `icons/` | Custom icon components. | +| `libs/` | Third-party library wrappers and configuration. | +| `utils/` | Shared utility functions. | + +## Backend + +Source code lives under `backend/Modules/`, split into purpose-specific modules: + +| Module | What it holds | +| ---------------------- | ------------------------------------------------------------------- | +| `CIPPCore` | Shared helpers, Graph/Exchange wrappers, auth, and the HTTP router. | +| `CIPPHTTP` | Every `Invoke-*` HTTP endpoint handler, organised by area. | +| `CIPPStandards` | Tenant standards (`Invoke-CIPPStandard*.ps1`). | +| `CIPPAlerts` | Alert definitions (`Get-CIPPAlert*.ps1`). | +| `CIPPDB` | Reporting database cache refresh jobs (`Set-CIPPDBCache*.ps1`). | +| `CIPPActivityTriggers` | Durable activity, queue, and timer entrypoints. | +| `CippExtensions` | Third-party integrations (Hudu, NinjaOne, and others). | + +Tests live under `backend/Tests/`, organised by area (Alerts, Endpoint, Standards, Private, Security, Build). + +## Build + +The `build/` directory contains Docker Compose files, Dockerfiles, the vendored ModuleBuilder, and the dev tooling scripts that compile backend modules and watch for changes. See [setting-up-for-local-development.md](setting-up-for-local-development.md "mention") for how to use them. diff --git a/docs/dev-documentation/contributing-to-the-code.md b/docs/dev-documentation/contributing-to-the-code.md index f36af9766a..fe2bdca6cd 100644 --- a/docs/dev-documentation/contributing-to-the-code.md +++ b/docs/dev-documentation/contributing-to-the-code.md @@ -1,63 +1,57 @@ # Contributing to the Code -Contributions to CIPP are welcome by everyone. There's a couple of things to keep in mind: +Contributions to CIPP are welcome. The entire project, frontend and backend, lives in a single mono-repository: [CyberDrain/CIPP](https://github.com/CyberDrain/CIPP). The old separate CIPP and CIPP-API repositories are deprecated. -* Speed and Security are two of the fundamental pillars of CIPP, if it isn't fast, it isn't good and, if it isn't secure, it's not getting merged. -* We try to use native APIs over PowerShell Modules. PowerShell modules tend to slow the entire processing. We currently only have `Az.Keyvault` and `Az.Accounts` loaded and prefer to keep it that way. -* You should understand the structure and technologies used in the CIPP and CIPP-API repositories. -* Avoid adding your deploy workflow file to your development branch. They cause annoyance when they appear in PRs. If you want to both deploy and develop it's probably better to create two instances of the repository. +Before writing any code, set up a local development environment by following the guide in [setting-up-for-local-development.md](cipp-dev-guide/setting-up-for-local-development.md "mention"). -When contributing, or planning to contribute, please create an issue [on GitHub](https://github.com/CyberDrain/CIPP/issues). +## Before You Start -* If you are fixing a bug, file a complete bug report and assign it to yourself. You can do this by commenting "I would like to work on this please!" on the issue. -* If you are adding a feature, please add "Feature Request" to the title and assign it to yourself. You can do this by commenting "I would like to work on this please!" on the issue. +- **File an issue.** If you are fixing a bug, file a complete bug report [on GitHub](https://github.com/CyberDrain/CIPP/issues) and assign it to yourself. If you are adding a feature, create an issue with "Feature Request" in the title and assign it to yourself. +- **Understand the repo layout.** Read the [project-structure.md](cipp-dev-guide/project-structure.md "mention") page so you know where frontend pages, backend modules, and tests live. +- **Speed and security** are fundamental pillars of CIPP. If it is not fast, it is not good, and if it is not secure, it is not getting merged. +- **Use native APIs over PowerShell modules.** PowerShell modules slow the entire runtime. The backend currently loads only `Az.Keyvault` and `Az.Accounts` and we prefer to keep it that way. {% hint style="info" %} -Assigning Yourself an Issue: You can assign yourself an issue on GitHub by creating a comment that says `I would like to work on this please!`. You must enter that text verbatim! +You can assign yourself an issue on GitHub by commenting `I would like to work on this please!` on the issue. You must enter that text verbatim. {% endhint %} -### Pull Requests - -We don't accept PRs or commits to `main`. The branch `main` is always the current release version. Both CIPP and CIPP-API have at least two branches `dev` and `main` or `master`. Please make any PR to `dev`, when `dev` gets promoted to a release the maintainers PR changes in `dev` into `main`. - -### Function Naming Standards - -We follow a naming standard, as based on the name a user might get access to an API or not. The current naming standard is as follows: - -* ListBla - Everything that generates a list (users) -* EditBla - Anything that edits an existing object (edit user) -* AddBla - Anything that adds an object (add user) -* RemoveBla - Anything that deletes or removes an object (remove user) -* ExecBla - Anything that executes an action (send MFA request to user) - -### Creating two instances - -* Make a clone of your forked repository. -* Optional: mark this repository as private. -* Add the following GitHub action, this synchronises the repositories every hour: - -```yaml -name: Pull from master schedule -on: - schedule: - - cron: '0 * * * *' -jobs: - repo-sync: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - with: - persist-credentials: false - - name: repo-sync - uses: repo-sync/github-sync@v2 - with: - source_repo: "KelvinTegelaar/CIPP" - source_branch: "master" - destination_branch: "master" - github_token: ${{ secrets.PAT }} +## Pull Requests + +- All pull requests target the **`dev`** branch. The `main` branch is the current release and does not accept direct PRs. +- Use a [Conventional Commits](https://www.conventionalcommits.org/) title, for example `feat(identity): add bulk user offboarding endpoint` or `fix(graph): handle expired token on retry`. +- Keep pull requests focused. A bug fix and a new feature belong in separate PRs. +- When your change alters what a user sees or can do (new fields, columns, buttons, renamed labels, new behaviour), update the matching documentation page in the same PR. See [contributing-to-the-documentation.md](contributing-to-the-documentation.md "mention") for the style guide. + +## Function Naming + +Every HTTP endpoint handler in `backend/Modules/CIPPHTTP/` must use one of these prefixes: + +| Prefix | Purpose | Example | +| ---------------- | ----------------------------------------------- | --------------------- | +| `Invoke-List*` | Returns a list or read-only data (GET) | `Invoke-ListUsers` | +| `Invoke-Add*` | Creates a new object | `Invoke-AddUser` | +| `Invoke-Edit*` | Modifies an existing object | `Invoke-EditUser` | +| `Invoke-Remove*` | Deletes or removes an object | `Invoke-RemoveUser` | +| `Invoke-Exec*` | Executes an action (for example, send MFA push) | `Invoke-ExecSendPush` | + +The HTTP router in CIPPCore maps the `CIPPEndpoint` route parameter to `Invoke-{CIPPEndpoint}`, so the function name is exactly what appears in the URL. + +## Backend Guidelines + +- **Always pass `-tenantid`** to `New-GraphGetRequest`, `New-GraphPOSTRequest`, `New-GraphBulkRequest`, and `New-ExoRequest`. Omitting it hits the partner tenant instead of the customer. +- Backend modules under `backend/Modules/` are **ModuleBuilder-compiled**. Editing a source file does nothing until it is recompiled. The module watcher handles this automatically during local development; see the [setting-up-for-local-development.md](cipp-dev-guide/setting-up-for-local-development.md "mention") page for details. +- Run the relevant **Pester tests** before submitting: + +```powershell +pwsh -File backend/Tests/Invoke-CippTests.ps1 # all tests +pwsh -File backend/Tests/Invoke-CippTests.ps1 -Path backend/Tests/Standards # one area ``` -* Go to settings of the repository. -* Select add secret. -* Name the secret "PAT" -* Enter the value: a self-created [personal access token](https://github.com/settings/tokens). +## Frontend Guidelines + +- See [frontend-testing.md](cipp-dev-guide/frontend-testing.md "mention") for test conventions and how to run the test suites. +- Remember to lint your code with prettier, as to not cause another war of formatters. + +## Documentation + +If your change adds, removes, or renames anything a user can see in the interface, update the documentation in the same pull request. User-facing docs live under `docs/` and mirror the frontend route path. The full style guide and submission process are in [contributing-to-the-documentation.md](contributing-to-the-documentation.md "mention"). From 5d55821909f002e0232b8cd420d01dc0900b3ea1 Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Sat, 15 Aug 2026 23:47:48 +0200 Subject: [PATCH 078/226] new standards --- .../Exchange Standards/DelegateSentItems.json | 62 ++++++++++ .../DisableExchangeOnlinePowerShell.json | 60 ++++++++++ .../EnableExchangeCloudManagement.json | 74 ++++++++++++ .../EnableLitigationHold.json | 68 +++++++++++ .../EnableOnlineArchiving.json | 61 ++++++++++ ...Get-CIPPBaselineDelegateSentItemsState.ps1 | 30 +++++ ...neDisableExchangeOnlinePowerShellState.ps1 | 58 +++++++++ ...lineEnableExchangeCloudManagementState.ps1 | 30 +++++ ...-CIPPBaselineEnableLitigationHoldState.ps1 | 29 +++++ ...CIPPBaselineEnableOnlineArchivingState.ps1 | 40 +++++++ .../Invoke-CIPPBaselineExoBulkSweep.ps1 | 113 ++++++++++++++++++ .../Baselines/BaselineExecutors.Tests.ps1 | 77 ++++++++++++ 12 files changed, 702 insertions(+) create mode 100644 backend/Config/BaselineStandards/Exchange Standards/DelegateSentItems.json create mode 100644 backend/Config/BaselineStandards/Exchange Standards/DisableExchangeOnlinePowerShell.json create mode 100644 backend/Config/BaselineStandards/Exchange Standards/EnableExchangeCloudManagement.json create mode 100644 backend/Config/BaselineStandards/Exchange Standards/EnableLitigationHold.json create mode 100644 backend/Config/BaselineStandards/Exchange Standards/EnableOnlineArchiving.json create mode 100644 backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineDelegateSentItemsState.ps1 create mode 100644 backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineDisableExchangeOnlinePowerShellState.ps1 create mode 100644 backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineEnableExchangeCloudManagementState.ps1 create mode 100644 backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineEnableLitigationHoldState.ps1 create mode 100644 backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineEnableOnlineArchivingState.ps1 create mode 100644 backend/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineExoBulkSweep.ps1 diff --git a/backend/Config/BaselineStandards/Exchange Standards/DelegateSentItems.json b/backend/Config/BaselineStandards/Exchange Standards/DelegateSentItems.json new file mode 100644 index 0000000000..80f7c35375 --- /dev/null +++ b/backend/Config/BaselineStandards/Exchange Standards/DelegateSentItems.json @@ -0,0 +1,62 @@ +{ + "name": "DelegateSentItems", + "label": "Set mailbox Sent Items delegation (Sent items for shared mailboxes)", + "cat": "Exchange Standards", + "tag": [], + "impact": "Medium Impact", + "helpText": "Sets emails sent as and on behalf of shared mailboxes to also be stored in the shared mailbox sent items folder", + "executiveText": "Ensures emails sent from shared mailboxes (like info@company.com) are stored in the shared mailbox rather than the individual sender's mailbox. This maintains complete email threads in one location, improving collaboration and ensuring all team members can see the full conversation history.", + "docsDescription": "This makes sure that e-mails sent from shared mailboxes or delegate mailboxes, end up in the mailbox of the shared/delegate mailbox instead of the sender, allowing you to keep replies in the same mailbox as the original e-mail.", + "impactColour": "warning", + "addedDate": "2021-11-16", + "powershellEquivalent": "Set-Mailbox", + "recommendedBy": [], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ], + "secureScoreImpact": 0, + "compare": "subset", + "variables": { + "IncludeUserMailboxes": { + "type": "switch", + "label": "Include user mailboxes", + "default": true, + "recommended": true + } + }, + "expected": { + "offenders": [] + }, + "read": { + "cacheType": "Mailboxes", + "collectorArgs": { + "Types": "None" + } + }, + "prepare": "Get-CIPPBaselineDelegateSentItemsState", + "remediate": { + "executor": "ExoBulkSweep", + "refreshCache": [ + "Mailboxes" + ], + "refreshCacheArgs": { + "Mailboxes": { + "Types": "None" + } + }, + "writes": [ + { + "cmdlet": "Set-Mailbox", + "params": { + "Identity": "%id%", + "MessageCopyForSendOnBehalfEnabled": true, + "MessageCopyForSentAsEnabled": true + } + } + ] + } +} diff --git a/backend/Config/BaselineStandards/Exchange Standards/DisableExchangeOnlinePowerShell.json b/backend/Config/BaselineStandards/Exchange Standards/DisableExchangeOnlinePowerShell.json new file mode 100644 index 0000000000..7f397bc213 --- /dev/null +++ b/backend/Config/BaselineStandards/Exchange Standards/DisableExchangeOnlinePowerShell.json @@ -0,0 +1,60 @@ +{ + "name": "DisableExchangeOnlinePowerShell", + "label": "Disable Exchange Online PowerShell for non-admin users", + "cat": "Exchange Standards", + "tag": [ + "Security", + "NIST CSF 2.0 (PR.AA-05)" + ], + "impact": "Medium Impact", + "helpText": "Disables Exchange Online PowerShell access for non-admin users by setting the RemotePowerShellEnabled property to false for each user. Users holding a directory role, directly or through a group, are automatically excluded.", + "executiveText": "Restricts PowerShell access to Exchange Online for regular employees while maintaining access for administrators, significantly reducing security risks from compromised accounts. This prevents attackers from using PowerShell to execute malicious commands or distribute ransomware while preserving necessary administrative capabilities.", + "docsDescription": "Disables Exchange Online PowerShell access for non-admin users by setting the RemotePowerShellEnabled property to false for each user. This security measure follows a least privileged access approach. Users holding a directory role - directly or through a group - are automatically excluded so administrators retain PowerShell access.", + "impactColour": "warning", + "addedDate": "2025-06-19", + "powershellEquivalent": "Set-User -Identity $user -RemotePowerShellEnabled $false", + "recommendedBy": [ + "CIS", + "CIPP" + ], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ], + "secureScoreImpact": 0, + "compare": "subset", + "variables": {}, + "expected": { + "offenders": [] + }, + "read": { + "cacheType": "Mailboxes", + "collectorArgs": { + "Types": "None" + } + }, + "prepare": "Get-CIPPBaselineDisableExchangeOnlinePowerShellState", + "remediate": { + "executor": "ExoBulkSweep", + "refreshCache": [ + "Mailboxes" + ], + "refreshCacheArgs": { + "Mailboxes": { + "Types": "None" + } + }, + "writes": [ + { + "cmdlet": "Set-User", + "params": { + "Identity": "%id%", + "RemotePowerShellEnabled": false + } + } + ] + } +} diff --git a/backend/Config/BaselineStandards/Exchange Standards/EnableExchangeCloudManagement.json b/backend/Config/BaselineStandards/Exchange Standards/EnableExchangeCloudManagement.json new file mode 100644 index 0000000000..64bda7d3aa --- /dev/null +++ b/backend/Config/BaselineStandards/Exchange Standards/EnableExchangeCloudManagement.json @@ -0,0 +1,74 @@ +{ + "name": "EnableExchangeCloudManagement", + "label": "Configure Exchange Cloud Management for Remote/On-Premises Mailboxes", + "cat": "Exchange Standards", + "tag": [], + "impact": "Low Impact", + "helpText": "Configures cloud-based management of Exchange attributes for directory-synced users with remote mailboxes in Exchange Online. This allows you to enable or disable management of Exchange attributes directly in the cloud without requiring an on-premises Exchange server.", + "executiveText": "Configures cloud-based management of Exchange mailbox attributes for hybrid organizations. When enabled, eliminates the dependency on on-premises Exchange servers for attribute management. This modernizes email administration, reduces infrastructure complexity, and allows direct management of mailbox properties through cloud portals and PowerShell. When disabled, returns management to on-premises Exchange servers.", + "docsDescription": "Configures the IsExchangeCloudManaged property for directory-synced mailboxes, allowing Exchange attributes to be managed directly in Exchange Online or reverted to on-premises management. Identity attributes remain managed on-premises via Active Directory.", + "impactColour": "info", + "addedDate": "2026-03-28", + "powershellEquivalent": "Set-Mailbox -Identity user@domain.com -IsExchangeCloudManaged $true or $false", + "recommendedBy": [ + "Microsoft", + "CIPP" + ], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV" + ], + "secureScoreImpact": 0, + "compare": "subset", + "variables": { + "state": { + "type": "autoComplete", + "multiple": false, + "label": "Cloud Management State", + "required": true, + "options": [ + { + "label": "Cloud Management", + "value": true + }, + { + "label": "On-Premises Management", + "value": false + } + ], + "default": true + } + }, + "expected": { + "offenders": [] + }, + "read": { + "cacheType": "Mailboxes", + "collectorArgs": { + "Types": "None" + } + }, + "prepare": "Get-CIPPBaselineEnableExchangeCloudManagementState", + "remediate": { + "executor": "ExoBulkSweep", + "refreshCache": [ + "Mailboxes" + ], + "refreshCacheArgs": { + "Mailboxes": { + "Types": "None" + } + }, + "writes": [ + { + "cmdlet": "Set-Mailbox", + "params": { + "Identity": "%id%", + "IsExchangeCloudManaged": "%state%" + } + } + ] + } +} diff --git a/backend/Config/BaselineStandards/Exchange Standards/EnableLitigationHold.json b/backend/Config/BaselineStandards/Exchange Standards/EnableLitigationHold.json new file mode 100644 index 0000000000..32d5a6a647 --- /dev/null +++ b/backend/Config/BaselineStandards/Exchange Standards/EnableLitigationHold.json @@ -0,0 +1,68 @@ +{ + "name": "EnableLitigationHold", + "label": "Enable Litigation Hold for all users", + "cat": "Exchange Standards", + "tag": [ + "SMB1001 (3.1)" + ], + "impact": "Low Impact", + "helpText": "Enables litigation hold for all UserMailboxes with a valid license.", + "executiveText": "Preserves all email content for legal and compliance purposes by preventing permanent deletion of emails, even when users attempt to delete them. This is essential for organizations subject to legal discovery requirements or regulatory compliance mandates.", + "docsDescription": "Enables litigation hold for all mailboxes carrying an archiving or enterprise entitlement. Mailboxes without one are skipped, because the write fails on them.", + "impactColour": "info", + "addedDate": "2024-06-25", + "powershellEquivalent": "Set-Mailbox -LitigationHoldEnabled $true", + "appliesToTest": [ + "SMB1001_3_1" + ], + "recommendedBy": [], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ], + "secureScoreImpact": 0, + "compare": "subset", + "variables": { + "days": { + "type": "textField", + "label": "Days to apply for litigation hold", + "helperText": "Number of days to apply litigation hold for. If left blank or set to Unlimited, litigation hold will be applied indefinitely.", + "omitWhenBlank": true, + "default": "" + } + }, + "expected": { + "offenders": [] + }, + "read": { + "cacheType": "Mailboxes", + "collectorArgs": { + "Types": "None" + } + }, + "prepare": "Get-CIPPBaselineEnableLitigationHoldState", + "remediate": { + "executor": "ExoBulkSweep", + "refreshCache": [ + "Mailboxes" + ], + "refreshCacheArgs": { + "Mailboxes": { + "Types": "None" + } + }, + "writes": [ + { + "cmdlet": "Set-Mailbox", + "params": { + "Identity": "%id%", + "LitigationHoldEnabled": true, + "LitigationHoldDuration": "%days%" + } + } + ] + } +} diff --git a/backend/Config/BaselineStandards/Exchange Standards/EnableOnlineArchiving.json b/backend/Config/BaselineStandards/Exchange Standards/EnableOnlineArchiving.json new file mode 100644 index 0000000000..daead3303b --- /dev/null +++ b/backend/Config/BaselineStandards/Exchange Standards/EnableOnlineArchiving.json @@ -0,0 +1,61 @@ +{ + "name": "EnableOnlineArchiving", + "label": "Enable Online Archive for all users", + "cat": "Exchange Standards", + "tag": [ + "Essential 8 (1511)", + "NIST CSF 2.0 (PR.DS-11)", + "SMB1001 (3.1)" + ], + "impact": "Low Impact", + "helpText": "Enables the In-Place Online Archive for all UserMailboxes with a valid license.", + "executiveText": "Automatically enables online email archiving for all licensed employees, providing additional storage for older emails while maintaining easy access. This helps manage mailbox sizes, improves email performance, and supports compliance with data retention requirements.", + "docsDescription": "Enables the In-Place Online Archive for user mailboxes on a mailbox plan that carries an archive entitlement.", + "impactColour": "info", + "addedDate": "2024-01-20", + "powershellEquivalent": "Enable-Mailbox -Archive $true", + "appliesToTest": [ + "SMB1001_3_1" + ], + "recommendedBy": [], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ], + "secureScoreImpact": 0, + "compare": "subset", + "variables": {}, + "expected": { + "offenders": [] + }, + "read": { + "cacheType": "Mailboxes", + "collectorArgs": { + "Types": "None" + } + }, + "prepare": "Get-CIPPBaselineEnableOnlineArchivingState", + "remediate": { + "executor": "ExoBulkSweep", + "refreshCache": [ + "Mailboxes" + ], + "refreshCacheArgs": { + "Mailboxes": { + "Types": "None" + } + }, + "writes": [ + { + "cmdlet": "Enable-Mailbox", + "params": { + "Identity": "%id%", + "Archive": true + } + } + ] + } +} diff --git a/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineDelegateSentItemsState.ps1 b/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineDelegateSentItemsState.ps1 new file mode 100644 index 0000000000..b9ff7e5a57 --- /dev/null +++ b/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineDelegateSentItemsState.ps1 @@ -0,0 +1,30 @@ +function Get-CIPPBaselineDelegateSentItemsState { + <# + .SYNOPSIS + Prepare hook for DelegateSentItems: mailboxes not copying sent-as / send-on-behalf + mail into the shared mailbox. + .DESCRIPTION + Either flag being false is enough to offend - the classic standard sets both in one + write, so a mailbox with one set and one clear is still wrong. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param($Item, $TenantFilter) + + $Mailboxes = @(New-CIPPDbRequest -TenantFilter $TenantFilter -Type 'Mailboxes' | Where-Object { $_ }) + if ($Mailboxes.Count -eq 0) { return @{ Current = $null } } + + $Types = if ("$($Item.Variables.IncludeUserMailboxes)" -in @('False', 'false', '0')) { @('SharedMailbox') } else { @('UserMailbox', 'SharedMailbox') } + $Offending = @($Mailboxes | Where-Object { + $_.recipientTypeDetails -in $Types -and + ($_.MessageCopyForSendOnBehalfEnabled -eq $false -or $_.MessageCopyForSentAsEnabled -eq $false) + }) + + @{ + Current = [PSCustomObject]@{ + offenders = @($Offending.UPN | Sort-Object) + targets = @($Offending | ForEach-Object { [PSCustomObject]@{ id = "$($_.UPN)" } }) + } + } +} diff --git a/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineDisableExchangeOnlinePowerShellState.ps1 b/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineDisableExchangeOnlinePowerShellState.ps1 new file mode 100644 index 0000000000..cffec7d060 --- /dev/null +++ b/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineDisableExchangeOnlinePowerShellState.ps1 @@ -0,0 +1,58 @@ +function Get-CIPPBaselineDisableExchangeOnlinePowerShellState { + <# + .SYNOPSIS + Prepare hook for DisableExchangeOnlinePowerShell: non-admin mailboxes that still have + Exchange Online PowerShell. + .DESCRIPTION + The offender set is the Mailboxes cache minus every admin, and the admin set is the + part that cannot come from cache: directory role assignments, plus the TRANSITIVE + members of any group holding a role. A group-derived admin is still an admin, and + stripping their PowerShell access is exactly the outage this standard must not cause, + so the expansion is read live like the classic standard did. + + If the admin lookup fails the hook returns a null Current rather than an offender set: + an empty admin list would sweep every administrator in the tenant. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Item, + $TenantFilter + ) + + $Mailboxes = @(New-CIPPDbRequest -TenantFilter $TenantFilter -Type 'Mailboxes' | Where-Object { $_ }) + if ($Mailboxes.Count -eq 0) { return @{ Current = $null } } + + try { + $RoleAssignments = New-GraphGetRequest -uri 'https://graph.microsoft.com/v1.0/roleManagement/directory/roleAssignments?$select=id,principalId,roleDefinitionId,directoryScopeId&$expand=principal($select=id,userPrincipalName)' -tenantid $TenantFilter + $AdminUPNs = @(($RoleAssignments | Where-Object { $_.principal.'@odata.type' -eq '#microsoft.graph.user' }).principal.userPrincipalName) + $AdminGroupIds = @(($RoleAssignments | Where-Object { $_.principal.'@odata.type' -eq '#microsoft.graph.group' }).principal.id | Select-Object -Unique) + if ($AdminGroupIds.Count -gt 0) { + $BulkRequests = foreach ($GroupId in $AdminGroupIds) { + @{ id = $GroupId; method = 'GET'; url = "groups/$GroupId/transitiveMembers/microsoft.graph.user?`$select=userPrincipalName" } + } + $BulkResults = New-GraphBulkRequest -tenantid $TenantFilter -Requests @($BulkRequests) -Version 'v1.0' + $AdminUPNs += @($BulkResults.body.value.userPrincipalName) + } + } catch { + Write-Information "Baselines: admin-role lookup on $TenantFilter failed, refusing to sweep: $($_.Exception.Message)" + return @{ Current = $null } + } + + $Admins = @{} + foreach ($UPN in ($AdminUPNs | Where-Object { $_ })) { $Admins["$UPN"] = $true } + + $Offending = @($Mailboxes | Where-Object { + $_.RemotePowerShellEnabled -eq $true -and -not $Admins.ContainsKey("$($_.UPN)") + }) + + @{ + Current = [PSCustomObject]@{ + offenders = @($Offending.UPN | Sort-Object) + # Identity prefers the immutable Guid: a UPN can change between the read and the + # write, and Set-User would then target nobody. + targets = @($Offending | ForEach-Object { [PSCustomObject]@{ id = "$($_.Guid ?? $_.UPN)" } }) + } + } +} diff --git a/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineEnableExchangeCloudManagementState.ps1 b/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineEnableExchangeCloudManagementState.ps1 new file mode 100644 index 0000000000..7cda7d00b4 --- /dev/null +++ b/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineEnableExchangeCloudManagementState.ps1 @@ -0,0 +1,30 @@ +function Get-CIPPBaselineEnableExchangeCloudManagementState { + <# + .SYNOPSIS + Prepare hook for EnableExchangeCloudManagement: directory-synced mailboxes whose + Exchange attributes are not managed where the baseline wants them. + .DESCRIPTION + Only dir-synced mailboxes are in scope: a cloud-only mailbox has no on-premises + Exchange to manage it, so the property is meaningless there. The write targets + ExternalDirectoryObjectId, matching the classic standard. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param($Item, $TenantFilter) + + $Mailboxes = @(New-CIPPDbRequest -TenantFilter $TenantFilter -Type 'Mailboxes' | Where-Object { $_ }) + if ($Mailboxes.Count -eq 0) { return @{ Current = $null } } + + $Desired = "$($Item.Variables.state)" -in @('True', 'true', '1') + $Offending = @($Mailboxes | Where-Object { + $_.IsDirSynced -eq $true -and [bool]$_.IsExchangeCloudManaged -ne $Desired + }) + + @{ + Current = [PSCustomObject]@{ + offenders = @($Offending.UPN | Sort-Object) + targets = @($Offending | ForEach-Object { [PSCustomObject]@{ id = "$($_.ExternalDirectoryObjectId)" } }) + } + } +} diff --git a/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineEnableLitigationHoldState.ps1 b/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineEnableLitigationHoldState.ps1 new file mode 100644 index 0000000000..546ad28c3f --- /dev/null +++ b/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineEnableLitigationHoldState.ps1 @@ -0,0 +1,29 @@ +function Get-CIPPBaselineEnableLitigationHoldState { + <# + .SYNOPSIS + Prepare hook for EnableLitigationHold: licensed mailboxes without litigation hold. + .DESCRIPTION + Licensing is the whole difficulty here - litigation hold needs an archiving or + enterprise plan, and enabling it without one fails per mailbox. Set-CIPPDBCacheMailboxes + precomputes LicensedForLitigationHold from the same PersistedCapabilities the classic + standard tested by hand, so the predicate is a single flag rather than five -contains. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param($Item, $TenantFilter) + + $Mailboxes = @(New-CIPPDbRequest -TenantFilter $TenantFilter -Type 'Mailboxes' | Where-Object { $_ }) + if ($Mailboxes.Count -eq 0) { return @{ Current = $null } } + + $Offending = @($Mailboxes | Where-Object { + $_.LicensedForLitigationHold -eq $true -and $_.LitigationHoldEnabled -ne $true + }) + + @{ + Current = [PSCustomObject]@{ + offenders = @($Offending.UPN | Sort-Object) + targets = @($Offending | ForEach-Object { [PSCustomObject]@{ id = "$($_.UPN)" } }) + } + } +} diff --git a/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineEnableOnlineArchivingState.ps1 b/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineEnableOnlineArchivingState.ps1 new file mode 100644 index 0000000000..4066755686 --- /dev/null +++ b/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineEnableOnlineArchivingState.ps1 @@ -0,0 +1,40 @@ +function Get-CIPPBaselineEnableOnlineArchivingState { + <# + .SYNOPSIS + Prepare hook for EnableOnlineArchiving: licensed user mailboxes with no archive. + .DESCRIPTION + Scoped to the two mailbox plans that carry an archive entitlement, exactly as the + classic standard queried Get-Mailbox once per plan. A mailbox on any other plan cannot + have an archive enabled, so grading it would report drift no remediation can clear. + + The cached MailboxPlan name carries a tenant-specific suffix + (ExchangeOnlineEnterprise-a1b2c3...), so it is matched by prefix. The mailbox is + captured into a named variable first because $_ is rebound inside the inner + Where-Object. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Item, + $TenantFilter + ) + + $Mailboxes = @(New-CIPPDbRequest -TenantFilter $TenantFilter -Type 'Mailboxes' | Where-Object { $_ }) + if ($Mailboxes.Count -eq 0) { return @{ Current = $null } } + + $ArchivePlans = @('ExchangeOnline', 'ExchangeOnlineEnterprise') + $Offending = @($Mailboxes | Where-Object { + $Mailbox = $_ + $Mailbox.recipientTypeDetails -eq 'UserMailbox' -and + $Mailbox.ArchiveEnabled -ne $true -and + @($ArchivePlans | Where-Object { "$($Mailbox.MailboxPlan)".StartsWith($_) }).Count -gt 0 + }) + + @{ + Current = [PSCustomObject]@{ + offenders = @($Offending.UPN | Sort-Object) + targets = @($Offending | ForEach-Object { [PSCustomObject]@{ id = "$($_.UPN)" } }) + } + } +} diff --git a/backend/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineExoBulkSweep.ps1 b/backend/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineExoBulkSweep.ps1 new file mode 100644 index 0000000000..7d2e2a086c --- /dev/null +++ b/backend/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineExoBulkSweep.ps1 @@ -0,0 +1,113 @@ +function Invoke-CIPPBaselineExoBulkSweep { + <# + .SYNOPSIS + ExoBulkSweep executor: runs one Exchange cmdlet per object in a prepare hook's + offender set. + .DESCRIPTION + The Exchange counterpart to GraphBulkSweep, and it follows the same contract: the + prepare hook decides WHICH mailboxes are wrong (bespoke - joins, licence predicates, + plan caps), this applies the SAME cmdlet to each of them in one batched request rather + than one round trip per mailbox. + + A hook returns two lists, because the compare and the write want different shapes: + offenders - display strings (a UPN), graded against [] so drift reads as names. + targets - one object per offender carrying what the cmdlet needs. Not graded: the + engine projects Current down to the expected keys before comparing. + + Spec (fully rendered): + writes[] - ordered groups, each { from, cmdlet, params, compliance }. 'from' + names the property on -Current holding the objects (default + 'targets'); a group naming a property that does not exist is an + authoring error and throws, one that exists and is empty is nothing + to do. params values may carry %property% tokens resolved against + each object, with the engine's token semantics - an exact "%prop%" + keeps the property's type. + refreshCache - cache types to re-collect after a successful sweep, so the mailboxes + just fixed do not read back as drift next run. + + Each cmdlet carries an OperationGuid set to the object it targets, which is what makes + per-object failures attributable - New-ExoBulkRequest echoes it back on both the + success and the error shape. Partial failure does NOT throw: the mailboxes that were + fixed stay fixed and the next run re-derives the remainder. A sweep where EVERY cmdlet + failed does throw, because that is a permission or connectivity problem and swallowing + it would report Remediated forever while nothing changed. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Remediate, + $TenantFilter, + $Current + ) + + $Expand = { + param($Template, $Object) + $Json = ConvertTo-Json -Compress -Depth 100 -InputObject $Template + foreach ($Property in $Object.PSObject.Properties) { + $Token = '%{0}%' -f $Property.Name + $Json = $Json.Replace(('"{0}"' -f $Token), (ConvertTo-Json -Compress -Depth 100 -InputObject $Property.Value)) + $Json = $Json.Replace($Token, "$($Property.Value)") + } + $Json | ConvertFrom-Json + } + + $Attempted = 0 + $Failed = 0 + $FailureDetail = [System.Collections.Generic.List[string]]::new() + + foreach ($Write in @($Remediate.writes)) { + if (-not $Write) { continue } + $From = "$($Write.from)" + if ([string]::IsNullOrWhiteSpace($From)) { $From = 'targets' } + if (-not ($Current -and $Current.PSObject.Properties.Name -contains $From)) { + throw "ExoBulkSweep: the prepare hook produced no '$From' set to sweep." + } + $Objects = @($Current.$From | Where-Object { $_ }) + if ($Objects.Count -eq 0) { continue } + if (-not $Write.cmdlet) { throw 'ExoBulkSweep: a write group declares no cmdlet.' } + + $Requests = foreach ($Object in $Objects) { + $Parameters = @{} + foreach ($Property in (& $Expand ($Write.params ?? [PSCustomObject]@{}) $Object).PSObject.Properties) { + $Parameters[$Property.Name] = $Property.Value + } + @{ + CmdletInput = @{ CmdletName = "$($Write.cmdlet)"; Parameters = $Parameters } + OperationGuid = "$($Object.id ?? $Object.Identity ?? $Object.UPN)" + } + } + + $Attempted += $Objects.Count + $Results = @(New-ExoBulkRequest -tenantid $TenantFilter -cmdletArray @($Requests) -useSystemMailbox $true -Compliance:([bool]($Write.compliance ?? $false))) + foreach ($Result in $Results) { + if ($Result.error) { + $Failed++ + $FailureDetail.Add("$($Result.OperationGuid ?? $Result.target) -> $($Result.error)") + } + } + } + + if ($Attempted -eq 0) { return } + + if ($FailureDetail.Count -gt 0) { + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "Sweep: $Failed of $Attempted mailbox writes failed. $(($FailureDetail | Select-Object -First 10) -join ' | ')" -Sev 'Warning' + } + if ($Failed -ge $Attempted) { + throw "ExoBulkSweep: all $Attempted writes failed. $($FailureDetail | Select-Object -First 1)" + } + + foreach ($CacheType in @($Remediate.refreshCache | Where-Object { $_ })) { + $Collector = Get-Command -Name "Set-CIPPDBCache$CacheType" -ErrorAction SilentlyContinue + if (-not $Collector) { continue } + try { + $CollectParams = @{ TenantFilter = $TenantFilter } + foreach ($Argument in ($Remediate.refreshCacheArgs.$CacheType ?? [PSCustomObject]@{}).PSObject.Properties) { + $CollectParams[$Argument.Name] = $Argument.Value + } + $null = & $Collector @CollectParams + } catch { + Write-Information "Baselines: cache refresh for $CacheType on $TenantFilter after a sweep failed: $($_.Exception.Message)" + } + } +} diff --git a/backend/Tests/Baselines/BaselineExecutors.Tests.ps1 b/backend/Tests/Baselines/BaselineExecutors.Tests.ps1 index 5815bf1938..083c94bb97 100644 --- a/backend/Tests/Baselines/BaselineExecutors.Tests.ps1 +++ b/backend/Tests/Baselines/BaselineExecutors.Tests.ps1 @@ -341,3 +341,80 @@ Describe 'Number variable rendering' { $Expected.label | Should -BeOfType ([string]) } } + +Describe 'Invoke-CIPPBaselineExoBulkSweep' { + BeforeAll { + . (Join-Path $Baselines 'Invoke-CIPPBaselineExoBulkSweep.ps1') + function New-ExoBulkRequest { param($tenantid, $cmdletArray, $useSystemMailbox, $Anchor, $NoAuthCheck, $Select, $ReturnWithCommand, [switch]$Compliance, [switch]$AsApp) } + function Set-CIPPDBCacheMailboxes { param($TenantFilter, $Types) } + } + BeforeEach { + Mock New-ExoBulkRequest { @($cmdletArray | ForEach-Object { [PSCustomObject]@{ Success = $true; OperationGuid = $_.OperationGuid } }) } + Mock Set-CIPPDBCacheMailboxes {} + Mock Write-LogMessage {} + } + + It 'builds one cmdlet per offender with the identity spliced in' { + $Current = @{ targets = @(@{ id = 'a@contoso.com' }, @{ id = 'b@contoso.com' }) } | ConvertTo-Spec + $Spec = @{ writes = @(@{ cmdlet = 'Set-Mailbox'; params = @{ Identity = '%id%'; MessageCopyForSentAsEnabled = $true } }) } | ConvertTo-Spec + Invoke-CIPPBaselineExoBulkSweep -Remediate $Spec -TenantFilter $script:Tenant -Current $Current + Should -Invoke New-ExoBulkRequest -Times 1 -ParameterFilter { + @($cmdletArray).Count -eq 2 -and + @($cmdletArray)[0].CmdletInput.CmdletName -eq 'Set-Mailbox' -and + @($cmdletArray)[0].CmdletInput.Parameters['Identity'] -eq 'a@contoso.com' -and + @($cmdletArray)[0].CmdletInput.Parameters['MessageCopyForSentAsEnabled'] -eq $true + } + } + + It 'stamps each cmdlet with an OperationGuid so failures are attributable' { + # Without it New-ExoBulkRequest returns errors with no way to say WHICH mailbox failed. + $Current = @{ targets = @(@{ id = 'a@contoso.com' }) } | ConvertTo-Spec + $Spec = @{ writes = @(@{ cmdlet = 'Set-Mailbox'; params = @{ Identity = '%id%' } }) } | ConvertTo-Spec + Invoke-CIPPBaselineExoBulkSweep -Remediate $Spec -TenantFilter $script:Tenant -Current $Current + Should -Invoke New-ExoBulkRequest -Times 1 -ParameterFilter { @($cmdletArray)[0].OperationGuid -eq 'a@contoso.com' } + } + + It 'does nothing when there is nothing to sweep' { + $Current = @{ targets = @() } | ConvertTo-Spec + $Spec = @{ writes = @(@{ cmdlet = 'Set-Mailbox'; params = @{ Identity = '%id%' } }) } | ConvertTo-Spec + Invoke-CIPPBaselineExoBulkSweep -Remediate $Spec -TenantFilter $script:Tenant -Current $Current + Should -Invoke New-ExoBulkRequest -Times 0 + } + + It 'throws when the prepare hook never produced the named set' { + $Current = @{ targets = @(@{ id = 'a' }) } | ConvertTo-Spec + $Spec = @{ writes = @(@{ from = 'typo'; cmdlet = 'Set-Mailbox'; params = @{ Identity = '%id%' } }) } | ConvertTo-Spec + { Invoke-CIPPBaselineExoBulkSweep -Remediate $Spec -TenantFilter $script:Tenant -Current $Current } | Should -Throw '*no *typo* set*' + } + + It 'survives a partial failure and still refreshes the cache' { + Mock New-ExoBulkRequest { + @( + [PSCustomObject]@{ Success = $true; OperationGuid = 'a@contoso.com' } + [PSCustomObject]@{ error = 'Mailbox not found'; target = 'b@contoso.com'; OperationGuid = 'b@contoso.com' } + ) + } + $Current = @{ targets = @(@{ id = 'a@contoso.com' }, @{ id = 'b@contoso.com' }) } | ConvertTo-Spec + $Spec = @{ refreshCache = @('Mailboxes'); writes = @(@{ cmdlet = 'Set-Mailbox'; params = @{ Identity = '%id%' } }) } | ConvertTo-Spec + { Invoke-CIPPBaselineExoBulkSweep -Remediate $Spec -TenantFilter $script:Tenant -Current $Current } | Should -Not -Throw + Should -Invoke Write-LogMessage -Times 1 -ParameterFilter { $Sev -eq 'Warning' -and $message -like '*1 of 2 mailbox writes failed*' } + Should -Invoke Set-CIPPDBCacheMailboxes -Times 1 + } + + It 'throws when every write failed' { + Mock New-ExoBulkRequest { @($cmdletArray | ForEach-Object { [PSCustomObject]@{ error = 'Access denied'; OperationGuid = $_.OperationGuid } }) } + $Current = @{ targets = @(@{ id = 'a' }, @{ id = 'b' }) } | ConvertTo-Spec + $Spec = @{ writes = @(@{ cmdlet = 'Set-Mailbox'; params = @{ Identity = '%id%' } }) } | ConvertTo-Spec + { Invoke-CIPPBaselineExoBulkSweep -Remediate $Spec -TenantFilter $script:Tenant -Current $Current } | Should -Throw '*all 2 writes failed*' + } + + It 'passes collector arguments on the refresh, so the umbrella collector stays cheap' { + # Mailboxes defaults to Types 'All', which fans out permission and calendar batches + # across every mailbox - never acceptable as a post-sweep refresh. + $Current = @{ targets = @(@{ id = 'a' }) } | ConvertTo-Spec + $Spec = @{ refreshCache = @('Mailboxes'); refreshCacheArgs = @{ Mailboxes = @{ Types = 'None' } } + writes = @(@{ cmdlet = 'Set-Mailbox'; params = @{ Identity = '%id%' } }) } | ConvertTo-Spec + Invoke-CIPPBaselineExoBulkSweep -Remediate $Spec -TenantFilter $script:Tenant -Current $Current + Should -Invoke Set-CIPPDBCacheMailboxes -Times 1 -ParameterFilter { $Types -eq 'None' } + } +} From 4b195fe30878d45a2331a89c61ad13f4d909bbbc Mon Sep 17 00:00:00 2001 From: Roel van der Wegen Date: Sat, 15 Aug 2026 23:48:59 +0200 Subject: [PATCH 079/226] feat(cippcore): add Get-CIPPSPOAdminListData and New-CIPPSPOAdminListViewXml functions Introduce Get-CIPPSPOAdminListData to retrieve aggregated site catalog data from SharePoint's undocumented RenderAdminListData endpoint, supporting structured parameters and raw ViewXml. Add New-CIPPSPOAdminListViewXml to construct CAML ViewXml for filtering and sorting site data, ensuring compatibility with the admin UI's requirements. Both functions enhance the capabilities for managing SharePoint site information. --- .../Public/Get-CIPPSPOAdminListData.ps1 | 188 ++++++++++++++++++ .../Public/New-CIPPSPOAdminListViewXml.ps1 | 179 +++++++++++++++++ 2 files changed, 367 insertions(+) create mode 100644 backend/Modules/CIPPCore/Public/Get-CIPPSPOAdminListData.ps1 create mode 100644 backend/Modules/CIPPCore/Public/New-CIPPSPOAdminListViewXml.ps1 diff --git a/backend/Modules/CIPPCore/Public/Get-CIPPSPOAdminListData.ps1 b/backend/Modules/CIPPCore/Public/Get-CIPPSPOAdminListData.ps1 new file mode 100644 index 0000000000..cdaed149a9 --- /dev/null +++ b/backend/Modules/CIPPCore/Public/Get-CIPPSPOAdminListData.ps1 @@ -0,0 +1,188 @@ +function Get-CIPPSPOAdminListData { + <# + .SYNOPSIS + Page SPO.Tenant/RenderAdminListData (admin aggregated site catalog). + + .DESCRIPTION + Calls the undocumented SharePoint admin RenderAdminListData endpoint used by Active sites. + Builds ViewXml from structured parameters by default, or accepts a raw -ViewXml escape hatch. + Returns flat admin list Row objects (all pages). Does not join Graph or map browser DTOs. + + Dotted numeric props (e.g. StorageUsed.) are an RLD quirk; -NormalizeRows copies them to + undotted names when present. + + .PARAMETER TenantFilter + Tenant to query. + + .PARAMETER Type + Catalog kind: SharePoint (Active sites filters; default) or OneDrive (personal sites). + Structured -Type OneDrive is not implemented yet and throws; use -ViewXml to probe. + + .PARAMETER ViewXml + Raw ViewXml. When set, structured ViewXml parameters are ignored. + + .PARAMETER AdminUrl + Optional SharePoint admin URL; resolved via Get-SharePointAdminLink when omitted. + + .PARAMETER DatesInUtc + Passed to RenderAdminListData parameters. + + .PARAMETER MaxPages + Abort if paging exceeds this many pages. + + .PARAMETER NormalizeRows + Copy StorageUsed. / NumOfFiles. / etc. onto undotted property names. + + .FUNCTIONALITY + Internal + #> + [CmdletBinding(DefaultParameterSetName = 'Structured')] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter, + + [ValidateSet('SharePoint', 'OneDrive')] + [string]$Type = 'SharePoint', + + [Parameter(ParameterSetName = 'ViewXml', Mandatory = $true)] + [string]$ViewXml, + + [Parameter(ParameterSetName = 'Structured')] + [string[]]$ViewFields, + + [Parameter(ParameterSetName = 'Structured')] + [int[]]$SiteFlags, + + [Parameter(ParameterSetName = 'Structured')] + [bool]$ExcludeDeleted = $true, + + [Parameter(ParameterSetName = 'Structured')] + [AllowNull()] + [object]$ExcludeState = 0, + + [Parameter(ParameterSetName = 'Structured')] + [string[]]$ExcludeTemplates, + + [Parameter(ParameterSetName = 'Structured')] + [string[]]$IncludeTemplates, + + [Parameter(ParameterSetName = 'Structured')] + [string]$OrderBy = 'Title', + + [Parameter(ParameterSetName = 'Structured')] + [bool]$OrderAscending = $true, + + [Parameter(ParameterSetName = 'Structured')] + [ValidateRange(1, 5000)] + [int]$RowLimit = 200, + + [Parameter(ParameterSetName = 'Structured')] + [string]$ExtraWhereXml, + + [string]$AdminUrl, + + [bool]$DatesInUtc = $true, + + [ValidateRange(1, 5000)] + [int]$MaxPages = 500, + + [bool]$NormalizeRows = $true + ) + + # OneDrive catalog ViewXml is not locked yet. Raw -ViewXml still works for discovery. + if ($Type -eq 'OneDrive' -and $PSCmdlet.ParameterSetName -ne 'ViewXml') { + throw 'Get-CIPPSPOAdminListData -Type OneDrive is not implemented yet. Pass -ViewXml to query personal sites, or use -Type SharePoint.' + } + + if ([string]::IsNullOrWhiteSpace($AdminUrl)) { + $SharePointInfo = Get-SharePointAdminLink -Public $false -tenantFilter $TenantFilter + $AdminUrl = $SharePointInfo.AdminUrl + } + $AdminUrl = $AdminUrl.TrimEnd('/') + + if ($PSCmdlet.ParameterSetName -eq 'Structured') { + $BuildParams = @{ + ExcludeDeleted = $ExcludeDeleted + ExcludeState = $ExcludeState + OrderBy = $OrderBy + OrderAscending = $OrderAscending + RowLimit = $RowLimit + } + if ($PSBoundParameters.ContainsKey('ViewFields')) { $BuildParams['ViewFields'] = $ViewFields } + if ($PSBoundParameters.ContainsKey('SiteFlags')) { $BuildParams['SiteFlags'] = $SiteFlags } + if ($PSBoundParameters.ContainsKey('ExcludeTemplates')) { $BuildParams['ExcludeTemplates'] = $ExcludeTemplates } + if ($PSBoundParameters.ContainsKey('IncludeTemplates')) { $BuildParams['IncludeTemplates'] = $IncludeTemplates } + if ($PSBoundParameters.ContainsKey('ExtraWhereXml')) { $BuildParams['ExtraWhereXml'] = $ExtraWhereXml } + $ViewXml = New-CIPPSPOAdminListViewXml @BuildParams + } + + if ([string]::IsNullOrWhiteSpace($ViewXml)) { + throw 'ViewXml is required (pass -ViewXml or use structured ViewFields/filter parameters).' + } + + $AllRows = [System.Collections.Generic.List[object]]::new() + $Paging = $null + $PageGuard = 0 + + do { + $PageGuard++ + if ($PageGuard -gt $MaxPages) { + throw "RenderAdminListData exceeded $MaxPages pages; aborting." + } + + $Parameters = @{ + ViewXml = $ViewXml + DatesInUtc = $DatesInUtc + } + if (-not [string]::IsNullOrWhiteSpace($Paging)) { + $Parameters['Paging'] = $Paging + } + $BodyObj = @{ parameters = $Parameters } + + $Page = New-GraphPOSTRequest -scope "$AdminUrl/.default" -tenantid $TenantFilter -uri "$AdminUrl/_api/SPO.Tenant/RenderAdminListData" -type 'POST' -body (ConvertTo-Json -Depth 8 -Compress -InputObject $BodyObj) -contentType 'application/json' -AddedHeaders @{ Accept = 'application/json;odata=verbose' } -AsApp $true -UseCertificate + + if ($Page -is [string]) { + $Page = $Page | ConvertFrom-Json + } + if ($Page.d) { + if ($Page.d.RenderAdminListData -is [string]) { + $Page = $Page.d.RenderAdminListData | ConvertFrom-Json + } elseif ($Page.d.RenderAdminListData) { + $Page = $Page.d.RenderAdminListData + } elseif ($Page.d.Row -or $Page.d.NextHref) { + $Page = $Page.d + } + } elseif ($Page.RenderAdminListData -is [string]) { + $Page = $Page.RenderAdminListData | ConvertFrom-Json + } elseif ($Page.RenderAdminListData) { + $Page = $Page.RenderAdminListData + } + + foreach ($Row in @($Page.Row)) { + if ($null -eq $Row) { continue } + if ($NormalizeRows) { + foreach ($Prop in @($Row.PSObject.Properties)) { + $Name = [string]$Prop.Name + if ($Name.EndsWith('.') -and $Name.Length -gt 1) { + $Plain = $Name.TrimEnd('.') + if (-not ($Row.PSObject.Properties.Name -contains $Plain)) { + $Row | Add-Member -NotePropertyName $Plain -NotePropertyValue $Prop.Value -Force + } + } + } + } + [void]$AllRows.Add($Row) + } + + $NextHref = [string]$Page.NextHref + if ([string]::IsNullOrWhiteSpace($NextHref)) { + $Paging = $null + } elseif ($NextHref.Contains('?')) { + $Paging = $NextHref.Split('?', 2)[1] + } else { + $Paging = $NextHref.TrimStart('?') + } + } while (-not [string]::IsNullOrWhiteSpace($Paging)) + + return @($AllRows) +} diff --git a/backend/Modules/CIPPCore/Public/New-CIPPSPOAdminListViewXml.ps1 b/backend/Modules/CIPPCore/Public/New-CIPPSPOAdminListViewXml.ps1 new file mode 100644 index 0000000000..d74f7c8820 --- /dev/null +++ b/backend/Modules/CIPPCore/Public/New-CIPPSPOAdminListViewXml.ps1 @@ -0,0 +1,179 @@ +function New-CIPPSPOAdminListViewXml { + <# + .SYNOPSIS + Build ViewXml for SPO.Tenant/RenderAdminListData (Active sites catalog). + + .DESCRIPTION + Constructs a CAML View for the undocumented SharePoint admin aggregated site list + (DO_NOT_DELETE_SPLIST_TENANTADMIN_AGGREGATED_SITECO / RenderAdminListData). + Filters and ViewFields are reverse-engineered from the Active sites admin UI; + there is no public support SLA. + + .PARAMETER ViewFields + FieldRef names to select. Validated against a known allowlist. + + .PARAMETER SiteFlags + Integer SiteFlags values for an In filter (Active sites defaults). + + .PARAMETER ExcludeDeleted + When true, requires TimeDeleted to be null. + + .PARAMETER ExcludeState + When set, adds Neq State. Pass $null to omit. + + .PARAMETER ExcludeTemplates + TemplateName values to exclude via Neq. Mutually exclusive with IncludeTemplates. + + .PARAMETER IncludeTemplates + TemplateName values to include via In. Mutually exclusive with ExcludeTemplates. + + .PARAMETER OrderBy + Field to sort by (must be on the allowlist). + + .PARAMETER OrderAscending + Sort direction. + + .PARAMETER RowLimit + Paged RowLimit (default 200; admin UI uses 30). + + .PARAMETER ExtraWhereXml + Optional raw CAML fragment AND-ed into Where for advanced filters without a full ViewXml. + + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + [string[]]$ViewFields = @( + 'Title' + 'SiteUrl' + 'SiteId' + 'StorageUsed' + 'StorageQuota' + 'NumOfFiles' + 'TemplateName' + 'TimeCreated' + 'GroupId' + 'SiteOwnerName' + 'SiteOwnerEmail' + 'ExternalSharing' + 'LastActivityOn' + 'SiteFlags' + ), + + [int[]]$SiteFlags = @(0, 1, 4, 5, 8, 9, 12, 13), + + [bool]$ExcludeDeleted = $true, + + [AllowNull()] + [object]$ExcludeState = 0, + + [string[]]$ExcludeTemplates = @('TEAMCHANNEL#0', 'TEAMCHANNEL#1'), + + [string[]]$IncludeTemplates = @(), + + [string]$OrderBy = 'Title', + + [bool]$OrderAscending = $true, + + [ValidateRange(1, 5000)] + [int]$RowLimit = 200, + + [string]$ExtraWhereXml + ) + + # Conservative allowlist — expand when a caller needs a field the aggregated list exposes. + $AllowedFields = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) + foreach ($Name in @( + 'Title', 'SiteUrl', 'SiteId', 'StorageUsed', 'StorageQuota', 'NumOfFiles', + 'TemplateName', 'TimeCreated', 'GroupId', 'SiteOwnerName', 'SiteOwnerEmail', + 'ExternalSharing', 'LastActivityOn', 'SiteFlags', 'CreatedBy', 'HubSiteId', + 'IsHubSite', 'SensitivityLabel', 'State', 'TimeDeleted', 'RelatedGroupId' + )) { + [void]$AllowedFields.Add($Name) + } + + if (-not $ViewFields -or $ViewFields.Count -eq 0) { + throw 'ViewFields must contain at least one field.' + } + foreach ($Field in $ViewFields) { + if (-not $AllowedFields.Contains($Field)) { + throw "ViewFields value '$Field' is not on the RenderAdminListData allowlist." + } + } + if (-not $AllowedFields.Contains($OrderBy)) { + throw "OrderBy value '$OrderBy' is not on the RenderAdminListData allowlist." + } + + $HasExcludeTemplates = @($ExcludeTemplates | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }).Count -gt 0 + $HasIncludeTemplates = @($IncludeTemplates | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }).Count -gt 0 + if ($HasExcludeTemplates -and $HasIncludeTemplates) { + throw 'Specify ExcludeTemplates or IncludeTemplates, not both.' + } + + function Join-CamlAnd { + param([string[]]$Parts) + $Parts = @($Parts | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) + if ($Parts.Count -eq 0) { return $null } + if ($Parts.Count -eq 1) { return $Parts[0] } + $Acc = $Parts[0] + for ($i = 1; $i -lt $Parts.Count; $i++) { + $Acc = "$Acc$($Parts[$i])" + } + return $Acc + } + + $WhereParts = [System.Collections.Generic.List[string]]::new() + + if ($ExcludeDeleted) { + [void]$WhereParts.Add('') + } + + if ($null -ne $SiteFlags -and $SiteFlags.Count -gt 0) { + $FlagValues = ($SiteFlags | ForEach-Object { + "$([int]$_)" + }) -join '' + [void]$WhereParts.Add("$FlagValues") + } + + if ($null -ne $ExcludeState -and "$ExcludeState" -ne '') { + $StateInt = [int]$ExcludeState + [void]$WhereParts.Add("$StateInt") + } + + if ($HasExcludeTemplates) { + $TemplateNeqs = foreach ($Template in $ExcludeTemplates) { + if ([string]::IsNullOrWhiteSpace($Template)) { continue } + $Escaped = [System.Security.SecurityElement]::Escape($Template) + "$Escaped" + } + $TemplateBlock = Join-CamlAnd -Parts @($TemplateNeqs) + if ($TemplateBlock) { + [void]$WhereParts.Add($TemplateBlock) + } + } elseif ($HasIncludeTemplates) { + $TemplateValues = ($IncludeTemplates | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | ForEach-Object { + $Escaped = [System.Security.SecurityElement]::Escape($_) + "$Escaped" + }) -join '' + [void]$WhereParts.Add("$TemplateValues") + } + + if (-not [string]::IsNullOrWhiteSpace($ExtraWhereXml)) { + [void]$WhereParts.Add($ExtraWhereXml.Trim()) + } + + $WhereInner = Join-CamlAnd -Parts @($WhereParts) + $WhereXml = if ($WhereInner) { "$WhereInner" } else { '' } + + $AscendingAttr = if ($OrderAscending) { 'true' } else { 'false' } + $OrderByEscaped = [System.Security.SecurityElement]::Escape($OrderBy) + $OrderByXml = "" + + $FieldRefs = ($ViewFields | ForEach-Object { + $Name = [System.Security.SecurityElement]::Escape($_) + "" + }) -join '' + + return "$WhereXml$OrderByXml$FieldRefs$RowLimit" +} From e5ee94bb5361ec2e71852bd946888544fb213bd0 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Sat, 15 Aug 2026 18:33:11 -0400 Subject: [PATCH 080/226] fix(mobile): improve layout on small screens - Add CippExpandableAlert component that clamps long alert messages on mobile with a Show more/less toggle - Fix duplicate page title on mobile when the tab picker already shows the same label - Replace fixed percent-width Box/Stack splits with responsive widths in table-maintenance and CippRoleAddEdit - Convert CippRoleAddEdit's side-by-side panes from Stack+Box to a responsive Grid - Add lint rule detecting fixed percent-width flex splits that break on phones - Update and expand CippPageCard tests to cover the title-suppression behavior --- .../src/components/CippCards/CippPageCard.jsx | 12 +- .../CippComponents/CippExpandableAlert.jsx | 60 +++++++++ .../CippSettings/CippRoleAddEdit.jsx | 24 ++-- .../src/layouts/tab-navigation-context.js | 17 +++ .../authentication/cipp-roles/index.js | 7 +- .../pages/cipp/advanced/table-maintenance.js | 15 ++- .../CippCards/CippPageCard.test.jsx | 123 +++++++++++++----- .../CippExpandableAlert.stories.jsx | 80 ++++++++++++ .../tests/lint/mobile-layout-patterns.test.js | 45 +++++++ 9 files changed, 328 insertions(+), 55 deletions(-) create mode 100644 frontend/src/components/CippComponents/CippExpandableAlert.jsx create mode 100644 frontend/tests/components/CippComponents/CippExpandableAlert.stories.jsx diff --git a/frontend/src/components/CippCards/CippPageCard.jsx b/frontend/src/components/CippCards/CippPageCard.jsx index 0766b0b9e3..4a35c2b8a0 100644 --- a/frontend/src/components/CippCards/CippPageCard.jsx +++ b/frontend/src/components/CippCards/CippPageCard.jsx @@ -2,6 +2,7 @@ import { useRouter } from "next/router"; import { Box, Container, Stack, Button, SvgIcon, Typography, Card } from "@mui/material"; import ArrowLeftIcon from "@mui/icons-material/ArrowLeft"; import { CippHead } from "../CippComponents/CippHead"; +import { useTitleClaimedByTabPicker } from "../../layouts/tab-navigation-context"; const CippPageCard = (props) => { const { title, @@ -14,6 +15,9 @@ const CippPageCard = (props) => { infoBar, } = props; const router = useRouter(); + // On mobile the tab picker directly above already reads as this page's heading whenever + // its current tab label is the same string — printing the h4 too said "CIPP Roles" twice. + const titleClaimed = useTitleClaimedByTabPicker(title); const handleBackClick = () => { router.back(); // Navigate to the previous page when the button is clicked @@ -32,13 +36,13 @@ const CippPageCard = (props) => { md, so a 600-900px viewport got 24px here and 16px everywhere else. */} - - {hideTitleText !== true && ( + {hideTitleText !== true && !titleClaimed && ( +
    {title}
    - )} -
    +
    + )} {infoBar} {children}
    diff --git a/frontend/src/components/CippComponents/CippExpandableAlert.jsx b/frontend/src/components/CippComponents/CippExpandableAlert.jsx new file mode 100644 index 0000000000..b48afb0bbd --- /dev/null +++ b/frontend/src/components/CippComponents/CippExpandableAlert.jsx @@ -0,0 +1,60 @@ +import { useEffect, useRef, useState } from "react"; +import { Alert, Box, Link } from "@mui/material"; +import { useIsMobileLayout } from "../../hooks/use-breakpoint"; + +/** + * An Alert that earns its screen space on a phone: below the mobile breakpoint the message + * clamps to a few lines with a Show more toggle, instead of pushing the page's actual + * content under the fold (the CIPP Roles intro alert filled most of the first screen). + * Desktop always shows the full message — the width absorbs it. + * + * Whether the toggle appears is measured, not assumed: a message short enough to fit its + * clamp renders exactly like a plain Alert. + */ +export const CippExpandableAlert = ({ children, collapsedLines = 3, ...alertProps }) => { + const isMobile = useIsMobileLayout(); + const [expanded, setExpanded] = useState(false); + const [clipped, setClipped] = useState(false); + const messageRef = useRef(null); + + useEffect(() => { + // Measure only while clamped: expanding removes the overflow, and remeasuring then + // would drop the Show less control with no way back. + if (!isMobile || expanded) return; + const el = messageRef.current; + if (el) setClipped(el.scrollHeight > el.clientHeight + 1); + }, [isMobile, expanded, children]); + + const clamped = isMobile && !expanded; + + return ( + + + {children} + + {isMobile && clipped && ( + setExpanded((prev) => !prev)} + sx={{ mt: 0.5, fontWeight: 600 }} + > + {expanded ? "Show less" : "Show more"} + + )} + + ); +}; diff --git a/frontend/src/components/CippSettings/CippRoleAddEdit.jsx b/frontend/src/components/CippSettings/CippRoleAddEdit.jsx index 46bcddf447..364dd38a70 100644 --- a/frontend/src/components/CippSettings/CippRoleAddEdit.jsx +++ b/frontend/src/components/CippSettings/CippRoleAddEdit.jsx @@ -501,14 +501,15 @@ export const CippRoleAddEdit = ({ selectedRole }) => { return ( {obj} - +
    }> -
    Child content
    +import React from "react"; +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { screen } from "@testing-library/react"; +import { renderWithProviders } from "../../test-utils"; + +// jsdom has no width-based matchMedia, so the mobile branch is driven by mocking the hook +const layoutState = vi.hoisted(() => ({ isMobile: false })); +vi.mock("../../../src/hooks/use-breakpoint", async (importOriginal) => ({ + ...(await importOriginal()), + useIsMobileLayout: () => layoutState.isMobile, + useIsTabletLayout: () => false, +})); + +const routerState = vi.hoisted(() => ({ push: vi.fn(), pathname: "/cipp/roles" })); +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: routerState.push }), + usePathname: () => routerState.pathname, + useSearchParams: () => new URLSearchParams(""), +})); +vi.mock("next/router", () => ({ + useRouter: () => ({ push: routerState.push, back: vi.fn() }), +})); + +// Stable identities: a fresh object per call re-renders forever (tests/mocks/api-call.js) +const idle = vi.hoisted(() => ({ + isSuccess: false, + isFetching: false, + isPending: false, + isError: false, + data: undefined, + mutate: () => {}, + reset: () => {}, + refetch: () => {}, +})); +vi.mock("../../../src/api/ApiCall", () => ({ + ApiGetCall: () => idle, + ApiPostCall: () => idle, + ApiGetCallWithPagination: () => ({ ...idle, fetchNextPage: () => {} }), +})); + +import { TabbedLayout } from "../../../src/layouts/TabbedLayout"; +import CippPageCard from "../../../src/components/CippCards/CippPageCard"; + +const tabOptions = [ + { label: "CIPP Roles", path: "/cipp/roles" }, + { label: "CIPP Users", path: "/cipp/users" }, +]; + +const renderPage = (title) => + renderWithProviders( + + +
    page content
    - ) - expect(screen.getByText('Info bar content')).toBeInTheDocument() - }) -}) +
    + ); + +describe("CippPageCard title vs the mobile tab picker", () => { + beforeEach(() => { + layoutState.isMobile = false; + routerState.pathname = "/cipp/roles"; + }); + + // The picker trigger wears the current tab's label in heading clothes right above the + // page header — a page titled the same printed "CIPP Roles" twice in a row on a phone. + it("stands its title down when the picker already says it", () => { + layoutState.isMobile = true; + renderPage("CIPP Roles"); + + // once: the picker trigger (whose label is itself an h6 — query the page h4 by level) + expect(screen.getAllByText("CIPP Roles")).toHaveLength(1); + expect(screen.getByRole("button", { name: /CIPP Roles switch view/i })).toBeInTheDocument(); + expect(screen.queryByRole("heading", { level: 4, name: "CIPP Roles" })).not.toBeInTheDocument(); + }); + + it("keeps a title the picker does not carry", () => { + layoutState.isMobile = true; + renderPage("Edit Role: limited"); + + expect( + screen.getByRole("heading", { level: 4, name: "Edit Role: limited" }) + ).toBeInTheDocument(); + }); + + it("keeps its title on desktop, where tabs look like navigation", () => { + renderPage("CIPP Roles"); + + expect(screen.getByRole("heading", { level: 4, name: "CIPP Roles" })).toBeInTheDocument(); + }); +}); diff --git a/frontend/tests/components/CippComponents/CippExpandableAlert.stories.jsx b/frontend/tests/components/CippComponents/CippExpandableAlert.stories.jsx new file mode 100644 index 0000000000..19ebf14039 --- /dev/null +++ b/frontend/tests/components/CippComponents/CippExpandableAlert.stories.jsx @@ -0,0 +1,80 @@ +import React from 'react' +import { within, waitFor, expect } from 'storybook/test' +import { userEvent } from 'storybook/test' +import { CippExpandableAlert } from '../../../src/components/CippComponents/CippExpandableAlert' +import { shrinkToPhoneViewport, growToDesktopViewport } from '../../viewport' + +export default { + title: 'Components/CippComponents/CippExpandableAlert', + component: CippExpandableAlert, + tags: ['autodocs'], +} + +const LONG_TEXT = + "Custom roles can be used to restrict permissions for users with the 'editor' or " + + "'readonly' roles in CIPP. They can be limited to a subset of tenants and API permissions. " + + 'Built-in and custom roles can be assigned to Entra security groups for granular access ' + + 'control. This sentence pads the message past any phone clamp so the toggle must appear.' + +const SHORT_TEXT = 'Nothing here needs a second look.' + +// A page-intro alert used to fill most of the first phone screen; the clamp keeps it to a +// few lines and hands the rest to a toggle. +export const ClampsLongMessagesOnAPhone = { + render: () => {LONG_TEXT}, + play: async ({ canvasElement, step }) => { + const onAPhone = await shrinkToPhoneViewport() + if (!onAPhone) return + const canvas = within(canvasElement) + + await step('the message is clipped and offers Show more', async () => { + const toggle = await canvas.findByRole('button', { name: /show more/i }) + const message = canvas.getByText(/Custom roles/, { exact: false }) + expect(message.scrollHeight).toBeGreaterThan(message.clientHeight) + expect(toggle).toBeInTheDocument() + }) + + await step('expanding shows everything and offers Show less', async () => { + await userEvent.click(canvas.getByRole('button', { name: /show more/i })) + const message = canvas.getByText(/Custom roles/, { exact: false }) + await waitFor(() => { + expect(message.scrollHeight).toBeLessThanOrEqual(message.clientHeight + 1) + expect(canvas.getByRole('button', { name: /show less/i })).toBeInTheDocument() + }) + }) + }, +} + +// Measured, not assumed: a message that fits its clamp renders as a plain alert. +export const LeavesShortMessagesAlone = { + render: () => {SHORT_TEXT}, + play: async ({ canvasElement, step }) => { + const onAPhone = await shrinkToPhoneViewport() + if (!onAPhone) return + const canvas = within(canvasElement) + + await step('no toggle for a message that already fits', async () => { + await canvas.findByText(SHORT_TEXT) + await waitFor(() => { + expect(canvas.queryByRole('button', { name: /show more/i })).not.toBeInTheDocument() + }) + }) + }, +} + +export const NeverClampsOnDesktop = { + render: () => {LONG_TEXT}, + play: async ({ canvasElement, step }) => { + const onDesktop = await growToDesktopViewport() + if (!onDesktop) return + const canvas = within(canvasElement) + + await step('full message, no toggle', async () => { + const message = await canvas.findByText(/Custom roles/, { exact: false }) + await waitFor(() => { + expect(message.scrollHeight).toBeLessThanOrEqual(message.clientHeight + 1) + expect(canvas.queryByRole('button', { name: /show more/i })).not.toBeInTheDocument() + }) + }) + }, +} diff --git a/frontend/tests/lint/mobile-layout-patterns.test.js b/frontend/tests/lint/mobile-layout-patterns.test.js index 1e405d604a..30fe14b26f 100644 --- a/frontend/tests/lint/mobile-layout-patterns.test.js +++ b/frontend/tests/lint/mobile-layout-patterns.test.js @@ -100,6 +100,30 @@ export const gridOffenders = (rawSource) => { return offenders; }; +/** + * Percent-width column splits on Box/Stack, as `line reason` strings. The flexbox sibling + * of the Grid rule above: `` beside `` holds a desktop + * split at 390px too — the role editor's summary pane sat off the right edge of a phone + * this way. A responsive object (`width={{ xs: "100%", xl: "30%" }}`) passes. + */ +export const percentSplitOffenders = (rawSource) => { + const source = stripComments(rawSource); + const marked = new Set(); + rawSource.split("\n").forEach((text, index) => { + if (text.includes(MARKER)) marked.add(index + 1); + }); + + const offenders = []; + for (const name of ["Box", "Stack"]) { + for (const tag of openingTags(source, name)) { + if (isExempt(marked, tag)) continue; + const percent = tag.text.match(/\bwidth=\{?"(\d{1,2})%"\}?/); + if (percent) offenders.push(`${tag.line} width="${percent[1]}%"`); + } + } + return offenders; +}; + /** Dashboard card wrappers pinned to a pixel height, as `line reason` strings. */ export const pinnedHeightOffenders = (rawSource) => { const source = stripComments(rawSource); @@ -149,6 +173,27 @@ describe("mobile layout patterns", () => { expect(gridOffenders(` // ${MARKER}\n\n\n\n\n${split}`)).toEqual(["6 xs: 6"]); }); + it("declares no percent-width flex split that survives a phone", () => { + const offenders = files.flatMap((file) => + percentSplitOffenders(fs.readFileSync(file, "utf8")).map( + (offender) => `${rel(file)}:${offender}` + ) + ); + expect( + offenders, + `A percent width on Box/Stack holds a desktop split at 390px. Use width={{ xs: "100%", md|xl: "N%" }} or a Grid:\n${offenders.join("\n")}` + ).toEqual([]); + }); + + it("reads a percent split only as a fixed string width", () => { + expect(percentSplitOffenders(` \n`)).toEqual(['1 width="30%"']); + expect(percentSplitOffenders(` \n`)).toEqual(['1 width="80%"']); + expect(percentSplitOffenders(` \n`)).toEqual([]); + expect(percentSplitOffenders(` \n`)).toEqual([]); + expect(percentSplitOffenders(` \n`)).toEqual([]); + expect(percentSplitOffenders(` // ${MARKER}\n \n`)).toEqual([]); + }); + it("pins no dashboard card to a pixel height", () => { expect(dashboardFiles.length).toBeGreaterThan(0); const offenders = dashboardFiles.flatMap((file) => From 45f9d5de0361283ee74cfce64462fa3d6acf0bcb Mon Sep 17 00:00:00 2001 From: Roel van der Wegen Date: Sun, 16 Aug 2026 00:35:12 +0200 Subject: [PATCH 081/226] feat(sharepoint): add sharepoint site browser (WIP) Explorer UI for sites and root libraries with admin-list-backed inventory, permissions, and storage sheets. Prototype route only; more actions and depth to follow. --- .../Invoke-ExecSiteBrowserActions.ps1 | 219 +++ .../Invoke-ExecSiteBrowserPermissions.ps1 | 406 ++++ .../Invoke-ListSiteBrowser.ps1 | 283 +++ .../Invoke-ListSiteBrowserPermissions.ps1 | 385 ++++ .../CippSharePointBrowserBanner.jsx | 123 ++ .../CippSharePointBrowserPermissions.jsx | 1637 +++++++++++++++++ .../CippSharePointBrowserProperties.jsx | 184 ++ .../CippSharePointBrowserStorage.jsx | 747 ++++++++ .../CippSharePointFolderView.jsx | 918 +++++++++ frontend/src/components/actions-menu.js | 2 +- .../pages/teams-share/sharepoint2/index.js | 322 ++++ 11 files changed, 5225 insertions(+), 1 deletion(-) create mode 100644 backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecSiteBrowserActions.ps1 create mode 100644 backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecSiteBrowserPermissions.ps1 create mode 100644 backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSiteBrowser.ps1 create mode 100644 backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSiteBrowserPermissions.ps1 create mode 100644 frontend/src/components/CippComponents/CippSharePointBrowserBanner.jsx create mode 100644 frontend/src/components/CippComponents/CippSharePointBrowserPermissions.jsx create mode 100644 frontend/src/components/CippComponents/CippSharePointBrowserProperties.jsx create mode 100644 frontend/src/components/CippComponents/CippSharePointBrowserStorage.jsx create mode 100644 frontend/src/components/CippComponents/CippSharePointFolderView.jsx create mode 100644 frontend/src/pages/teams-share/sharepoint2/index.js diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecSiteBrowserActions.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecSiteBrowserActions.ps1 new file mode 100644 index 0000000000..950ab99041 --- /dev/null +++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecSiteBrowserActions.ps1 @@ -0,0 +1,219 @@ +function Invoke-ExecSiteBrowserActions { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Sharepoint.Site.ReadWrite + .DESCRIPTION + Mutating / operational actions for the SharePoint site browser (non-permissions). + Body.Action selects the operation. SiteUrl + tenantFilter are always required. + Version cleanup: StartVersionCleanup, GetVersionCleanupStatus. + Site admin properties (incl. version policy): GetSiteProperties. + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers + $TenantFilter = $Request.Body.tenantFilter ?? $Request.Body.TenantFilter + $SiteUrl = $Request.Body.SiteUrl + $SiteId = $Request.Body.SiteId + $Action = $Request.Body.Action + + try { + if ([string]::IsNullOrWhiteSpace($TenantFilter)) { throw 'tenantFilter is required.' } + if ([string]::IsNullOrWhiteSpace($Action)) { throw 'Action is required.' } + if ([string]::IsNullOrWhiteSpace($SiteUrl) -and [string]::IsNullOrWhiteSpace($SiteId)) { + throw 'SiteUrl or SiteId is required.' + } + + if (-not [string]::IsNullOrWhiteSpace($SiteUrl)) { + $ResolvedUrl = $SiteUrl.TrimEnd('/') + } else { + $SiteMeta = New-GraphGetRequest -uri "https://graph.microsoft.com/v1.0/sites/$SiteId`?`$select=webUrl" -tenantid $TenantFilter -asapp $true + if ([string]::IsNullOrWhiteSpace($SiteMeta.webUrl)) { + throw "Could not resolve webUrl for site id $SiteId." + } + $ResolvedUrl = $SiteMeta.webUrl.TrimEnd('/') + } + + $Result = switch ([string]$Action) { + 'StartVersionCleanup' { + $BatchDeleteMode = [int]($Request.Body.BatchDeleteMode ?? 2) + if ($Request.Body.BatchDeleteMode -is [PSCustomObject] -and $Request.Body.BatchDeleteMode.value) { + $BatchDeleteMode = [int]$Request.Body.BatchDeleteMode.value + } + + $DeleteOlderThanDays = [int]($Request.Body.DeleteOlderThanDays ?? -1) + $MajorVersionLimit = [int]($Request.Body.MajorVersionLimit ?? -1) + $MajorWithMinorVersionsLimit = [int]($Request.Body.MajorWithMinorVersionsLimit ?? -1) + $SyncListPolicy = $Request.Body.SyncListPolicy -eq $true + + switch ($BatchDeleteMode) { + 0 { + if ($DeleteOlderThanDays -lt 30) { + throw 'DeleteOlderThanDays must be at least 30 when using Delete Older Than Days mode.' + } + $MajorVersionLimit = -1 + $MajorWithMinorVersionsLimit = -1 + } + 1 { + if ($MajorVersionLimit -lt 1) { + throw 'MajorVersionLimit is required when using Count Limits mode.' + } + if ($MajorWithMinorVersionsLimit -lt 0) { + throw 'MajorWithMinorVersionsLimit is required when using Count Limits mode.' + } + $DeleteOlderThanDays = -1 + } + 2 { + $DeleteOlderThanDays = -1 + $MajorVersionLimit = -1 + $MajorWithMinorVersionsLimit = -1 + } + default { + throw "Unsupported BatchDeleteMode '$BatchDeleteMode'. Use 0 (DeleteOlderThanDays), 1 (CountLimits), or 2 (SyncPolicy)." + } + } + + $SharePointInfo = Get-SharePointAdminLink -Public $false -tenantFilter $TenantFilter + $AdminUrl = $SharePointInfo.AdminUrl + $EscapedSiteUrl = [System.Security.SecurityElement]::Escape($ResolvedUrl) + $SyncListPolicyValue = $SyncListPolicy.ToString().ToLower() + + $XML = @" +$EscapedSiteUrl$BatchDeleteMode$DeleteOlderThanDays$MajorVersionLimit$MajorWithMinorVersionsLimit$SyncListPolicyValue +"@ + + $AdditionalHeaders = @{ + 'Accept' = 'application/json;odata=verbose' + } + $Response = New-GraphPostRequest -scope "$AdminUrl/.default" -tenantid $TenantFilter -Uri "$AdminUrl/_vti_bin/client.svc/ProcessQuery" -Type POST -Body $XML -ContentType 'text/xml' -AddedHeaders $AdditionalHeaders + + if ($Response -is [string]) { + $Response = $Response | ConvertFrom-Json + } + $ErrorInfo = $Response | Where-Object { $_.PSObject.Properties.Name -contains 'ErrorInfo' } | Select-Object -First 1 + if ($ErrorInfo.ErrorInfo) { + throw "SharePoint rejected the version cleanup job for $ResolvedUrl : $($ErrorInfo.ErrorInfo.ErrorMessage)" + } + + "Successfully started version cleanup job for $ResolvedUrl." + } + 'GetVersionCleanupStatus' { + $SharePointInfo = Get-SharePointAdminLink -Public $false -tenantFilter $TenantFilter + $AdminUrl = $SharePointInfo.AdminUrl + $EscapedSiteUrl = [System.Security.SecurityElement]::Escape($ResolvedUrl) + + $XML = @" +$EscapedSiteUrl +"@ + + $AdditionalHeaders = @{ + 'Accept' = 'application/json;odata=verbose' + } + $Response = New-GraphPostRequest -scope "$AdminUrl/.default" -tenantid $TenantFilter -Uri "$AdminUrl/_vti_bin/client.svc/ProcessQuery" -Type POST -Body $XML -ContentType 'text/xml' -AddedHeaders $AdditionalHeaders + + if ($Response -is [string]) { + $Response = $Response | ConvertFrom-Json + } + + $ErrorInfo = $Response | Where-Object { $_.PSObject.Properties.Name -contains 'ErrorInfo' } | Select-Object -First 1 + if ($ErrorInfo.ErrorInfo) { + throw "SharePoint returned an error querying version cleanup status for $ResolvedUrl : $($ErrorInfo.ErrorInfo.ErrorMessage)" + } + + $ProgressJson = $Response | Where-Object { $_ -is [string] } | Select-Object -First 1 + if ([string]::IsNullOrWhiteSpace($ProgressJson)) { + [PSCustomObject]@{ + SiteUrl = $ResolvedUrl + Status = 'NoRequestFound' + Message = 'No file version batch delete job found for this site.' + } + } else { + $Progress = $ProgressJson | ConvertFrom-Json + if ($Progress -isnot [PSCustomObject]) { + $Progress = [PSCustomObject]$Progress + } + $Progress | Add-Member -NotePropertyName SiteUrl -NotePropertyValue $ResolvedUrl -Force + $Progress + } + } + 'GetSiteProperties' { + $SharePointInfo = Get-SharePointAdminLink -Public $false -tenantFilter $TenantFilter + $AdminUrl = $SharePointInfo.AdminUrl + $EscapedSiteUrl = [System.Security.SecurityElement]::Escape($ResolvedUrl) + + $XML = @" +$EscapedSiteUrltrue +"@ + + $AdditionalHeaders = @{ 'Accept' = 'application/json;odata=verbose' } + $Response = New-GraphPostRequest -scope "$AdminUrl/.default" -tenantid $TenantFilter -Uri "$AdminUrl/_vti_bin/client.svc/ProcessQuery" -Type POST -Body $XML -ContentType 'text/xml' -AddedHeaders $AdditionalHeaders + + if ($Response -is [string]) { + $Response = $Response | ConvertFrom-Json + } + + $ErrorInfo = $Response | Where-Object { $_.PSObject.Properties.Name -contains 'ErrorInfo' } | Select-Object -First 1 + if ($ErrorInfo.ErrorInfo) { + throw "SharePoint returned an error reading site properties for $ResolvedUrl : $($ErrorInfo.ErrorInfo.ErrorMessage)" + } + + $Site = $Response | Where-Object { $_._ObjectType_ -match 'SiteProperties' } | Select-Object -First 1 + if (-not $Site) { + throw "Could not retrieve site properties for $ResolvedUrl" + } + + $SharingCapabilityNames = @{ 0 = 'Disabled'; 1 = 'ExternalUserSharingOnly'; 2 = 'ExternalUserAndGuestSharing'; 3 = 'ExistingExternalUserSharingOnly' } + $LinkTypeNames = @{ 0 = 'None'; 1 = 'Direct'; 2 = 'Internal'; 3 = 'AnonymousAccess' } + $LinkPermissionNames = @{ 0 = 'None'; 1 = 'View'; 2 = 'Edit' } + $DomainRestrictionNames = @{ 0 = 'None'; 1 = 'AllowList'; 2 = 'BlockList' } + + [PSCustomObject]@{ + Url = $Site.Url ?? $ResolvedUrl + Title = $Site.Title + Template = $Site.Template + SharingCapability = $SharingCapabilityNames[[int]$Site.SharingCapability] ?? $Site.SharingCapability + DefaultSharingLinkType = $LinkTypeNames[[int]$Site.DefaultSharingLinkType] ?? $Site.DefaultSharingLinkType + DefaultLinkPermission = $LinkPermissionNames[[int]$Site.DefaultLinkPermission] ?? $Site.DefaultLinkPermission + SharingDomainRestrictionMode = $DomainRestrictionNames[[int]$Site.SharingDomainRestrictionMode] ?? $Site.SharingDomainRestrictionMode + SharingAllowedDomainList = $Site.SharingAllowedDomainList + SharingBlockedDomainList = $Site.SharingBlockedDomainList + OverrideTenantAnonymousLinkExpirationPolicy = [bool]$Site.OverrideTenantAnonymousLinkExpirationPolicy + AnonymousLinkExpirationInDays = $Site.AnonymousLinkExpirationInDays + LockState = $Site.LockState + StorageMaximumLevel = $Site.StorageMaximumLevel + StorageWarningLevel = $Site.StorageWarningLevel + StorageUsage = $Site.StorageUsage + InheritVersionPolicyFromTenant = [bool]$Site.InheritVersionPolicyFromTenant + EnableAutoExpirationVersionTrim = [bool]$Site.EnableAutoExpirationVersionTrim + MajorVersionLimit = $Site.MajorVersionLimit + ExpireVersionsAfterDays = $Site.ExpireVersionsAfterDays + } + } + default { + throw "Unknown Action '$Action'. Supported: StartVersionCleanup, GetVersionCleanupStatus, GetSiteProperties." + } + } + + if ($Result -is [string]) { + Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter -message $Result -sev Info + } elseif ($Action -eq 'GetSiteProperties') { + Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter -message "Retrieved site properties for $($Result.Url ?? $ResolvedUrl)" -sev Info + } else { + Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter -message "Retrieved version cleanup status for $($Result.SiteUrl ?? $ResolvedUrl)" -sev Info + } + $StatusCode = [HttpStatusCode]::OK + } catch { + $ErrorMessage = Get-CippException -Exception $_ + $Result = "Failed to run Action '$Action'. Error: $($ErrorMessage.NormalizedError)" + Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter -message $Result -sev Error -LogData $ErrorMessage + $StatusCode = [HttpStatusCode]::BadRequest + } + + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = @{'Results' = $Result } + }) +} diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecSiteBrowserPermissions.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecSiteBrowserPermissions.ps1 new file mode 100644 index 0000000000..b6834377d5 --- /dev/null +++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecSiteBrowserPermissions.ps1 @@ -0,0 +1,406 @@ +function Invoke-ExecSiteBrowserPermissions { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Sharepoint.Site.ReadWrite + .DESCRIPTION + Mutating actions for the SharePoint site browser permissions dialog. + Body.Action selects the operation. SiteUrl + tenantFilter are always required. + ListId scopes library actions; omit it for the site root web. + Sharing links / Graph drive permissions are out of scope. + Graph site permissions (Sites.Selected app grants): RemoveGraphSitePermission. + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers + $TenantFilter = $Request.Body.tenantFilter ?? $Request.Body.TenantFilter + $SiteUrl = $Request.Body.SiteUrl + $SiteId = $Request.Body.SiteId + $ListId = $Request.Body.ListId + $LibraryName = $Request.Body.LibraryName + $Action = $Request.Body.Action + + $BuiltInRoleDefinitionIds = @{ + 'read' = 1073741826 + 'contribute' = 1073741827 + 'design' = 1073741828 + 'fullControl' = 1073741829 + 'edit' = 1073741830 + } + + function Resolve-BrowserPermissionRoleDefId { + param($PermissionLevel, $RoleDefinitionId) + if (-not [string]::IsNullOrWhiteSpace($RoleDefinitionId)) { + if ($RoleDefinitionId -is [PSCustomObject] -and $RoleDefinitionId.value) { + return [string]$RoleDefinitionId.value + } + return [string]$RoleDefinitionId + } + $Key = [string]$PermissionLevel + if ($PermissionLevel -is [PSCustomObject] -and $PermissionLevel.value) { + $Key = [string]$PermissionLevel.value + } + return $BuiltInRoleDefinitionIds[$Key] + } + + function ConvertTo-BrowserPermissionPrincipals { + param( + $PrincipalId, + $PrincipalName, + $Users, + $Groups + ) + $Principals = [System.Collections.Generic.List[object]]::new() + if (-not [string]::IsNullOrWhiteSpace($PrincipalId)) { + $Principals.Add([PSCustomObject]@{ + Id = $PrincipalId + LogonName = $null + Label = "$($PrincipalName ?? $PrincipalId)" + IsGroup = $false + }) + } + foreach ($User in @($Users)) { + if ($null -eq $User -or -not $User.value) { continue } + $Principals.Add([PSCustomObject]@{ + Id = $null + LogonName = "i:0#.f|membership|$($User.value)" + Label = "$($User.value)" + IsGroup = $false + }) + } + foreach ($Group in @($Groups)) { + if ($null -eq $Group -or -not $Group.value) { continue } + $IsUnified = @($Group.addedFields.groupTypes) -contains 'Unified' + $LogonName = if ($IsUnified) { + "c:0o.c|federateddirectoryclaimprovider|$($Group.value)" + } else { + "c:0t.c|tenant|$($Group.value)" + } + $Principals.Add([PSCustomObject]@{ + Id = $null + LogonName = $LogonName + Label = "$($Group.label ?? $Group.value)" + IsGroup = $true + }) + } + return $Principals + } + + function Invoke-BrowserGrantAccess { + param($Mode = 'Add') + + $RoleDefId = Resolve-BrowserPermissionRoleDefId -PermissionLevel $Request.Body.PermissionLevel -RoleDefinitionId $Request.Body.RoleDefinitionId + if (-not $RoleDefId) { throw 'No permission level was selected.' } + + $Principals = ConvertTo-BrowserPermissionPrincipals ` + -PrincipalId $Request.Body.PrincipalId ` + -PrincipalName $Request.Body.PrincipalName ` + -Users $Request.Body.Users ` + -Groups $Request.Body.Groups + if ($Principals.Count -eq 0) { throw 'No users or groups selected.' } + + $SPScope = Resolve-CIPPSharePointPermissionScope -SiteUrl $SiteUrl -ListId $ListId -TenantFilter $TenantFilter -EnsureUniqueRoleAssignments + + $ExistingAssignments = @() + if ($Mode -eq 'Replace') { + $ExistingAssignments = @(New-GraphGetRequest -uri "$($SPScope.AssignmentUri)?`$expand=Member,RoleDefinitionBindings" -tenantid $TenantFilter -scope $SPScope.Scope -extraHeaders $SPScope.Headers -UseCertificate -AsApp $true) + } + + $Granted = [System.Collections.Generic.List[string]]::new() + $Failed = [System.Collections.Generic.List[string]]::new() + foreach ($Principal in $Principals) { + try { + $ResolvedId = $Principal.Id + if (-not $ResolvedId) { + $EnsureBody = ConvertTo-Json -Compress -InputObject @{ logonName = $Principal.LogonName } + $Ensured = New-GraphPostRequest -uri "$($SPScope.BaseUri)/web/ensureuser" -tenantid $TenantFilter -scope $SPScope.Scope -type POST -body $EnsureBody -AddedHeaders $SPScope.Headers -UseCertificate -AsApp $true + if (-not $Ensured.Id) { throw 'Could not resolve principal on the site.' } + $ResolvedId = $Ensured.Id + } + + if ($Mode -eq 'Replace') { + $Current = @($ExistingAssignments | Where-Object { [string]$_.Member.Id -eq [string]$ResolvedId }) + foreach ($Assignment in $Current) { + foreach ($Binding in @($Assignment.RoleDefinitionBindings)) { + if ($Binding.RoleTypeKind -eq 1) { continue } + if ([string]$Binding.Id -eq [string]$RoleDefId) { continue } + $null = New-GraphPostRequest -uri "$($SPScope.AssignmentUri)/removeroleassignment(principalid=$ResolvedId,roledefid=$($Binding.Id))" -tenantid $TenantFilter -scope $SPScope.Scope -type POST -body '{}' -AddedHeaders $SPScope.Headers -UseCertificate -AsApp $true + } + } + } + + $null = New-GraphPostRequest -uri "$($SPScope.AssignmentUri)/addroleassignment(principalid=$ResolvedId,roledefid=$RoleDefId)" -tenantid $TenantFilter -scope $SPScope.Scope -type POST -body '{}' -AddedHeaders $SPScope.Headers -UseCertificate -AsApp $true + $Granted.Add($Principal.Label) + } catch { + $Failed.Add("$($Principal.Label) - $(Get-CIPPSharePointErrorMessage -ErrorMessage $_.Exception.Message -IsGroup:$Principal.IsGroup)") + } + } + + $LevelLabel = if ($Request.Body.PermissionLevel) { + switch ([string]$Request.Body.PermissionLevel) { + 'fullControl' { 'Full Control' } + default { (Get-Culture).TextInfo.ToTitleCase([string]$Request.Body.PermissionLevel) } + } + } else { + try { + (New-GraphGetRequest -uri "$($SPScope.BaseUri)/web/roledefinitions/getbyid($RoleDefId)?`$select=Name" -tenantid $TenantFilter -scope $SPScope.Scope -extraHeaders $SPScope.Headers -UseCertificate -AsApp $true).Name + } catch { "role definition $RoleDefId" } + } + $TargetLabel = if ($LibraryName) { "library $LibraryName" } else { $SPScope.TargetLabel } + $Verb = if ($Mode -eq 'Replace') { 'set' } else { 'granted' } + + $Messages = [System.Collections.Generic.List[string]]::new() + if ($Granted.Count -gt 0) { + $Messages.Add("Successfully $Verb $LevelLabel on $TargetLabel for $($Granted -join ', ').") + } + if ($SPScope.BrokeInheritance) { + $Messages.Add('Permission inheritance was broken so the change applies to this library only; the permissions it inherited were copied across.') + } + if ($Failed.Count -gt 0) { + $Messages.Add("Failed for $(($Failed -join '; ').TrimEnd('.')).") + } + $Result = $Messages -join ' ' + if ($Granted.Count -eq 0) { throw $Result } + return $Result + } + + function Invoke-BrowserRemoveAccess { + $PrincipalId = $Request.Body.PrincipalId + $RoleDefinitionId = $Request.Body.RoleDefinitionId + $Label = $Request.Body.PrincipalName ?? $Request.Body.Title ?? $PrincipalId + if ([string]::IsNullOrWhiteSpace($PrincipalId)) { throw 'PrincipalId is required.' } + + $SPScope = Resolve-CIPPSharePointPermissionScope -SiteUrl $SiteUrl -ListId $ListId -TenantFilter $TenantFilter -EnsureUniqueRoleAssignments + $Assignments = @(New-GraphGetRequest -uri "$($SPScope.AssignmentUri)?`$expand=Member,RoleDefinitionBindings" -tenantid $TenantFilter -scope $SPScope.Scope -extraHeaders $SPScope.Headers -UseCertificate -AsApp $true) + $Current = @($Assignments | Where-Object { [string]$_.Member.Id -eq [string]$PrincipalId }) + if ($Current.Count -eq 0) { + throw "$Label holds no permissions on $($SPScope.TargetLabel)." + } + if (-not $Label -or $Label -eq $PrincipalId) { $Label = $Current[0].Member.Title ?? $PrincipalId } + + $Targets = [System.Collections.Generic.List[object]]::new() + $SkippedSystem = [System.Collections.Generic.List[string]]::new() + foreach ($Assignment in $Current) { + foreach ($Binding in @($Assignment.RoleDefinitionBindings)) { + if (-not [string]::IsNullOrWhiteSpace($RoleDefinitionId) -and [string]$Binding.Id -ne [string]$RoleDefinitionId) { continue } + if ($Binding.RoleTypeKind -eq 1) { + $SkippedSystem.Add($Binding.Name) + continue + } + $Targets.Add($Binding) + } + } + + if ($Targets.Count -eq 0) { + if ($SkippedSystem.Count -gt 0) { + throw "$Label only holds $($SkippedSystem -join ', ') on $($SPScope.TargetLabel). SharePoint manages that level itself and it cannot be removed here." + } + throw "No matching permission found for $Label on $($SPScope.TargetLabel)." + } + + $Removed = [System.Collections.Generic.List[string]]::new() + $Failed = [System.Collections.Generic.List[string]]::new() + foreach ($Binding in $Targets) { + try { + $null = New-GraphPostRequest -uri "$($SPScope.AssignmentUri)/removeroleassignment(principalid=$PrincipalId,roledefid=$($Binding.Id))" -tenantid $TenantFilter -scope $SPScope.Scope -type POST -body '{}' -AddedHeaders $SPScope.Headers -UseCertificate -AsApp $true + $Removed.Add($Binding.Name) + } catch { + $Failed.Add("$($Binding.Name) - $(Get-CIPPSharePointErrorMessage -ErrorMessage $_.Exception.Message)") + } + } + + $TargetLabel = if ($LibraryName) { "library $LibraryName" } else { $SPScope.TargetLabel } + $Messages = [System.Collections.Generic.List[string]]::new() + if ($Removed.Count -gt 0) { + $Messages.Add("Successfully removed $($Removed -join ', ') from $Label on $TargetLabel.") + } + if ($SPScope.BrokeInheritance) { + $Messages.Add('Permission inheritance was broken so the change applies to this library only; the permissions it inherited were copied across.') + } + if ($Failed.Count -gt 0) { + $Messages.Add("Failed for $(($Failed -join '; ').TrimEnd('.')).") + } + $Result = $Messages -join ' ' + if ($Removed.Count -eq 0) { throw $Result } + return $Result + } + + function Invoke-BrowserGroupMembership { + param([bool]$Add) + + $GroupId = $Request.Body.GroupId + if ([string]::IsNullOrWhiteSpace($GroupId)) { throw 'GroupId is required.' } + + $SharePointInfo = Get-SharePointAdminLink -Public $false -tenantFilter $TenantFilter + $Scope = "$($SharePointInfo.SharePointUrl)/.default" + $JsonAccept = @{ Accept = 'application/json;odata=nometadata' } + $BaseUri = "$($SiteUrl.TrimEnd('/'))/_api" + + $Principals = ConvertTo-BrowserPermissionPrincipals ` + -PrincipalId $Request.Body.PrincipalId ` + -PrincipalName $Request.Body.PrincipalName ` + -Users $Request.Body.Users ` + -Groups $Request.Body.Groups + if ($Principals.Count -eq 0) { throw 'No users or groups selected.' } + + $Done = [System.Collections.Generic.List[string]]::new() + $Failed = [System.Collections.Generic.List[string]]::new() + foreach ($Principal in $Principals) { + try { + $ResolvedId = $Principal.Id + $LoginName = $null + if (-not $ResolvedId) { + $EnsureBody = ConvertTo-Json -Compress -InputObject @{ logonName = $Principal.LogonName } + $Ensured = New-GraphPostRequest -uri "$BaseUri/web/ensureuser" -tenantid $TenantFilter -scope $Scope -type POST -body $EnsureBody -contentType 'application/json;odata=nometadata' -AddedHeaders $JsonAccept -UseCertificate -AsApp $true + if (-not $Ensured.Id) { throw 'Could not resolve principal on the site.' } + $ResolvedId = $Ensured.Id + $LoginName = $Ensured.LoginName + } elseif ($Principal.LogonName) { + $LoginName = $Principal.LogonName + } else { + $Existing = New-GraphGetRequest -uri "$BaseUri/web/getuserbyid($ResolvedId)?`$select=Id,LoginName,Title" -tenantid $TenantFilter -scope $Scope -extraHeaders $JsonAccept -UseCertificate -AsApp $true + $LoginName = $Existing.LoginName + } + + if ($Add) { + $AddBody = ConvertTo-Json -Compress -Depth 5 -InputObject @{ + '__metadata' = @{ 'type' = 'SP.User' } + 'LoginName' = $LoginName + } + $null = New-GraphPostRequest -uri "$BaseUri/web/sitegroups($GroupId)/users" -tenantid $TenantFilter -scope $Scope -type POST -body $AddBody -contentType 'application/json;odata=verbose' -AddedHeaders $JsonAccept -UseCertificate -AsApp $true + } else { + $null = New-GraphPostRequest -uri "$BaseUri/web/sitegroups($GroupId)/users/removebyid($ResolvedId)" -tenantid $TenantFilter -scope $Scope -type POST -body '{}' -contentType 'application/json;odata=nometadata' -AddedHeaders $JsonAccept -UseCertificate -AsApp $true + } + $Done.Add($Principal.Label) + } catch { + $Failed.Add("$($Principal.Label) - $(Get-CIPPSharePointErrorMessage -ErrorMessage $_.Exception.Message -IsGroup:$Principal.IsGroup)") + } + } + + $Verb = if ($Add) { 'added to' } else { 'removed from' } + $GroupLabel = $Request.Body.GroupName ?? "group $GroupId" + $Messages = [System.Collections.Generic.List[string]]::new() + if ($Done.Count -gt 0) { + $Messages.Add("Successfully $Verb $GroupLabel`: $($Done -join ', ').") + } + if ($Failed.Count -gt 0) { + $Messages.Add("Failed for $(($Failed -join '; ').TrimEnd('.')).") + } + $Result = $Messages -join ' ' + if ($Done.Count -eq 0) { throw $Result } + return $Result + } + + function Invoke-BrowserSiteAdmin { + param([bool]$Add) + + $Users = @($Request.Body.Users) + $UPNs = foreach ($User in $Users) { + if ($User -is [string] -and $User) { $User } + elseif ($User.value) { $User.value } + } + $UPNs = @($UPNs | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) + if ($UPNs.Count -eq 0 -and $Request.Body.PrincipalName) { + # Allow a single login/UPN from a selected admin row. + $Candidate = $Request.Body.userPrincipalName ?? $Request.Body.PrincipalName + if ($Candidate -match '@') { $UPNs = @($Candidate) } + } + if ($UPNs.Count -eq 0) { throw 'No users selected.' } + + $Results = Set-CIPPSharePointPerms -tenantFilter $TenantFilter -OnedriveAccessUser $UPNs -URL $SiteUrl -Headers $Headers -APIName $APIName -RemovePermission:(-not $Add) + return (@($Results) -join ' ') + } + + function Invoke-BrowserInheritance { + param([ValidateSet('Break', 'Reset')][string]$Mode) + + if ([string]::IsNullOrWhiteSpace($ListId)) { + throw 'ListId is required: a site root web always holds its own permissions.' + } + $CopyRoleAssignments = ($Request.Body.CopyRoleAssignments ?? $true) -eq $true + $ClearSubscopes = $Request.Body.ClearSubscopes -eq $true + + $SPScope = Resolve-CIPPSharePointPermissionScope -SiteUrl $SiteUrl -ListId $ListId -TenantFilter $TenantFilter + $TargetLabel = if ($LibraryName) { "library $LibraryName" } else { $SPScope.TargetLabel } + + if ($Mode -eq 'Break') { + if ($SPScope.HasUniqueRoleAssignments) { + return "$TargetLabel already has its own permissions; nothing to change." + } + $null = New-GraphPostRequest -uri "$($SPScope.ScopeUri)/breakroleinheritance(copyRoleAssignments=$($CopyRoleAssignments.ToString().ToLower()),clearSubscopes=$($ClearSubscopes.ToString().ToLower()))" -tenantid $TenantFilter -scope $SPScope.Scope -type POST -body '{}' -AddedHeaders $SPScope.Headers -UseCertificate -AsApp $true + $Detail = if ($CopyRoleAssignments) { + 'The permissions it inherited were copied across, so current access is unchanged.' + } else { + 'It started with an empty permission set, so only site collection admins can reach it until permissions are granted.' + } + if ($ClearSubscopes) { $Detail += ' Unique permissions on folders and items inside it were reset.' } + return "Successfully stopped $TargetLabel inheriting permissions from the site. $Detail" + } + + if (-not $SPScope.HasUniqueRoleAssignments) { + return "$TargetLabel already inherits its permissions from the site; nothing to change." + } + $null = New-GraphPostRequest -uri "$($SPScope.ScopeUri)/resetroleinheritance" -tenantid $TenantFilter -scope $SPScope.Scope -type POST -body '{}' -AddedHeaders $SPScope.Headers -UseCertificate -AsApp $true + return "Successfully restored permission inheritance on $TargetLabel. The permissions that were unique to it have been discarded and it now follows the site." + } + + function Invoke-BrowserRemoveGraphSitePermission { + $PermissionId = $Request.Body.PermissionId + if ([string]::IsNullOrWhiteSpace($PermissionId)) { throw 'PermissionId is required.' } + + $ResolvedSiteId = $SiteId + if ([string]::IsNullOrWhiteSpace($ResolvedSiteId)) { + $ParsedUrl = [System.Uri]$SiteUrl + $SiteSegment = if ($ParsedUrl.AbsolutePath -in @('', '/')) { + $ParsedUrl.Host + } else { + "$($ParsedUrl.Host):$($ParsedUrl.AbsolutePath):" + } + $SiteMeta = New-GraphGetRequest -uri "https://graph.microsoft.com/v1.0/sites/$SiteSegment`?`$select=id" -tenantid $TenantFilter -asapp $true + $ResolvedSiteId = $SiteMeta.id + } + if ([string]::IsNullOrWhiteSpace($ResolvedSiteId)) { throw 'Could not resolve Graph site id.' } + + $null = New-GraphPostRequest -uri "https://graph.microsoft.com/v1.0/sites/$ResolvedSiteId/permissions/$PermissionId" -tenantid $TenantFilter -type DELETE -asapp $true + $Label = $Request.Body.PrincipalName ?? $PermissionId + return "Successfully removed Graph site permission for $Label." + } + + try { + if ([string]::IsNullOrWhiteSpace($TenantFilter)) { throw 'tenantFilter is required.' } + if ([string]::IsNullOrWhiteSpace($SiteUrl)) { throw 'SiteUrl is required.' } + if ([string]::IsNullOrWhiteSpace($Action)) { throw 'Action is required.' } + + $Result = switch ([string]$Action) { + 'GrantAccess' { Invoke-BrowserGrantAccess -Mode 'Add' } + 'ReplaceAccess' { Invoke-BrowserGrantAccess -Mode 'Replace' } + 'RemoveAccess' { Invoke-BrowserRemoveAccess } + 'AddGroupMember' { Invoke-BrowserGroupMembership -Add $true } + 'RemoveGroupMember' { Invoke-BrowserGroupMembership -Add $false } + 'AddSiteAdmin' { Invoke-BrowserSiteAdmin -Add $true } + 'RemoveSiteAdmin' { Invoke-BrowserSiteAdmin -Add $false } + 'BreakInheritance' { Invoke-BrowserInheritance -Mode 'Break' } + 'RestoreInheritance' { Invoke-BrowserInheritance -Mode 'Reset' } + 'RemoveGraphSitePermission' { Invoke-BrowserRemoveGraphSitePermission } + default { + throw "Unknown Action '$Action'. Supported: GrantAccess, ReplaceAccess, RemoveAccess, AddGroupMember, RemoveGroupMember, AddSiteAdmin, RemoveSiteAdmin, BreakInheritance, RestoreInheritance, RemoveGraphSitePermission." + } + } + + Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter -message $Result -sev Info + $StatusCode = [HttpStatusCode]::OK + } catch { + $ErrorMessage = Get-CippException -Exception $_ + $Result = "Failed to run Action '$Action'. Error: $($ErrorMessage.NormalizedError)" + Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter -message $Result -sev Error -LogData $ErrorMessage + $StatusCode = [HttpStatusCode]::BadRequest + } + + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = @{'Results' = $Result } + }) +} diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSiteBrowser.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSiteBrowser.ps1 new file mode 100644 index 0000000000..29538fe021 --- /dev/null +++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSiteBrowser.ps1 @@ -0,0 +1,283 @@ +function Invoke-ListSiteBrowser { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Sharepoint.Site.Read + .DESCRIPTION + SharePoint site browser listing (sites only — not OneDrive). + Root: Get-CIPPSPOAdminListData (SPO.Tenant/RenderAdminListData, Active sites catalog) — + StorageUsed / NumOfFiles / TemplateName in one paged call. + Graph getAllSites joins only for Graph site.id (drill-in). + With SiteId/SiteUrl: root document/page libraries (Graph lists + SPO StorageMetrics). + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $APIName = $Request.Params.CIPPEndpoint + # Match other SharePoint endpoints: query keys may arrive as TenantFilter or tenantFilter. + $TenantFilter = $Request.Query.TenantFilter ?? $Request.Query.tenantFilter ?? $Request.Body.TenantFilter ?? $Request.Body.tenantFilter + $SiteId = $Request.Query.SiteId ?? $Request.Body.SiteId + $SiteUrl = $Request.Query.SiteUrl ?? $Request.Body.SiteUrl + + if ([string]::IsNullOrWhiteSpace($TenantFilter)) { + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::BadRequest + Body = @{'Results' = 'tenantFilter is required.' } + }) + } + + function ConvertTo-StorageUsedBytes { + param($Raw) + if ($null -eq $Raw -or $Raw -eq '') { return $null } + $Clean = ([string]$Raw).Replace(',', '').Trim() + if ($Clean -eq '') { return $null } + try { return [int64][double]$Clean } catch { return $null } + } + + function ConvertTo-NullableInt64 { + param($Raw) + if ($null -eq $Raw -or $Raw -eq '') { return $null } + $Clean = ([string]$Raw).Replace(',', '').Trim() + if ($Clean -eq '') { return $null } + try { return [int64][double]$Clean } catch { return $null } + } + + function ConvertTo-SiteTypeLabel { + param( + [string]$Template, + [string]$ItemType, + [string]$LibraryTemplate + ) + if ($ItemType -eq 'library') { + if ($LibraryTemplate -eq 'webPageLibrary') { return 'Site pages' } + if ($LibraryTemplate -eq 'documentLibrary') { return 'Document library' } + return $LibraryTemplate ? $LibraryTemplate : 'Library' + } + if ([string]::IsNullOrWhiteSpace($Template)) { return 'Site' } + + # Admin/usage templates are usually "GROUP#0", "STS#3", etc. — strip the config id. + $Normalized = ($Template -split '#')[0].Trim() + + switch -Regex ($Normalized) { + '^(?i)Group$' { return 'Team site' } + '^(?i)Team\s*Site$' { return 'Team site' } + '(?i)SitePagePublishing|Site Page Publishing' { return 'Communication site' } + '^(?i)STS' { return 'Team site (classic)' } + '(?i)Redirect' { return 'Redirect site' } + '^(?i)APPCATALOG$' { return 'App catalog' } + default { return $Normalized } + } + } + + function Test-CIPPSiteBrowserLeaveOut { + param( + [string]$Name, + [string]$WebUrl, + [string[]]$SitesToLeaveOut + ) + $SitePath = $null + $SitePathLeaf = $null + if (-not [string]::IsNullOrWhiteSpace($WebUrl)) { + try { + $SitePath = ([System.Uri]$WebUrl).AbsolutePath.Trim('/') + if (-not [string]::IsNullOrWhiteSpace($SitePath)) { + $SitePathLeaf = $SitePath.Split('/')[-1] + } + } catch { + $SitePath = $null + $SitePathLeaf = $null + } + } + foreach ($LeaveOutName in $SitesToLeaveOut) { + if ( + ([string]::Equals($Name, $LeaveOutName, [System.StringComparison]::OrdinalIgnoreCase)) -or + ([string]::Equals($SitePath, $LeaveOutName, [System.StringComparison]::OrdinalIgnoreCase)) -or + ([string]::Equals($SitePathLeaf, $LeaveOutName, [System.StringComparison]::OrdinalIgnoreCase)) + ) { + return $true + } + } + return $false + } + + try { + $SiteInfo = $null + $StorageStatus = $null + $HasSite = -not [string]::IsNullOrWhiteSpace($SiteId) -or -not [string]::IsNullOrWhiteSpace($SiteUrl) + + $SharePointInfo = Get-SharePointAdminLink -Public $false -tenantFilter $TenantFilter + $SpoScope = "$($SharePointInfo.SharePointUrl)/.default" + $AdminUrl = $SharePointInfo.AdminUrl + $JsonAccept = @{ Accept = 'application/json;odata=nometadata' } + + $SitesToLeaveOut = @( + 'search' + 'contentTypeHub' + 'appcatalog' + 'portals/hub' + 'portals/community' + ) + + if (-not $HasSite) { + $Results = [System.Collections.Generic.List[object]]::new() + # Active sites catalog via RenderAdminListData (defaults match admin UI filters). + $AdminRows = @(Get-CIPPSPOAdminListData -TenantFilter $TenantFilter -AdminUrl $AdminUrl -Type SharePoint) + $StorageStatus = 'admin' + + # Graph join for site.id only (drill-in). + $GraphBulk = New-GraphBulkRequest -tenantid $TenantFilter -Requests @( + @{ + id = 'listAllSites' + method = 'GET' + url = "sites/getAllSites?`$filter=isPersonalSite eq false&`$select=id,createdDateTime,description,name,displayName,isPersonalSite,webUrl,siteCollection,sharepointIds&`$top=999" + } + ) -asapp $true + $SitesResponse = @($GraphBulk | Where-Object { $_.id -eq 'listAllSites' }) | Select-Object -First 1 + if ($null -eq $SitesResponse) { + throw 'getAllSites response missing from Graph bulk batch' + } + if ($SitesResponse.status -and $SitesResponse.status -ne 200) { + throw ($SitesResponse.body.error.message ?? "getAllSites failed with status $($SitesResponse.status)") + } + $GraphSites = @($SitesResponse.body.value) + $GraphBySiteId = @{} + $GraphByWebUrl = @{} + foreach ($GraphSite in $GraphSites) { + if ($null -eq $GraphSite) { continue } + $Guid = [string]$GraphSite.sharepointIds.siteId + if (-not [string]::IsNullOrWhiteSpace($Guid)) { + $GraphBySiteId[$Guid.Trim('{}').ToLowerInvariant()] = $GraphSite + } + if (-not [string]::IsNullOrWhiteSpace($GraphSite.webUrl)) { + $GraphByWebUrl[$GraphSite.webUrl.TrimEnd('/').ToLowerInvariant()] = $GraphSite + } + } + + foreach ($Row in $AdminRows) { + $RowUrl = [string]$Row.SiteUrl + $RowTitle = [string]$Row.Title + $RowSiteIdRaw = [string]$Row.SiteId + $RowSiteId = $RowSiteIdRaw.Trim('{}') + $NameLeaf = $null + if (-not [string]::IsNullOrWhiteSpace($RowUrl)) { + try { + $NameLeaf = ([System.Uri]$RowUrl).AbsolutePath.Trim('/').Split('/')[-1] + } catch { $NameLeaf = $null } + } + if (Test-CIPPSiteBrowserLeaveOut -Name $NameLeaf -WebUrl $RowUrl -SitesToLeaveOut $SitesToLeaveOut) { + continue + } + + $GraphSite = $null + if (-not [string]::IsNullOrWhiteSpace($RowSiteId)) { + $GraphSite = $GraphBySiteId[$RowSiteId.ToLowerInvariant()] + } + if (-not $GraphSite -and -not [string]::IsNullOrWhiteSpace($RowUrl)) { + $GraphSite = $GraphByWebUrl[$RowUrl.TrimEnd('/').ToLowerInvariant()] + } + + $RootWebTemplate = [string]$Row.TemplateName + $StorageRaw = if ($null -ne $Row.'StorageUsed.') { $Row.'StorageUsed.' } else { $Row.StorageUsed } + $FilesRaw = if ($null -ne $Row.'NumOfFiles.') { $Row.'NumOfFiles.' } else { $Row.NumOfFiles } + + $Results.Add([PSCustomObject]@{ + type = 'site' + id = $(if ($GraphSite.id) { $GraphSite.id } else { $RowSiteId }) + siteId = $(if ($GraphSite.sharepointIds.siteId) { $GraphSite.sharepointIds.siteId } else { $RowSiteId }) + webId = $GraphSite.sharepointIds.webId + displayName = $(if ($RowTitle) { $RowTitle } else { $GraphSite.displayName }) + name = $(if ($GraphSite.name) { $GraphSite.name } else { $NameLeaf }) + description = $GraphSite.description + webUrl = $(if ($RowUrl) { $RowUrl } else { $GraphSite.webUrl }) + createdDateTime = $(if ($Row.TimeCreated) { $Row.TimeCreated } else { $GraphSite.createdDateTime }) + storageUsedInBytes = ConvertTo-StorageUsedBytes -Raw $StorageRaw + siteType = ConvertTo-SiteTypeLabel -Template $RootWebTemplate -ItemType 'site' + rootWebTemplate = $RootWebTemplate + fileCount = ConvertTo-NullableInt64 -Raw $FilesRaw + }) + } + + } else { + # Library drill-in. + if (-not [string]::IsNullOrWhiteSpace($SiteId)) { + $SiteSegment = $SiteId + } else { + $ParsedUrl = [System.Uri]$SiteUrl + $SiteSegment = if ($ParsedUrl.AbsolutePath -in @('', '/')) { + $ParsedUrl.Host + } else { + "$($ParsedUrl.Host):$($ParsedUrl.AbsolutePath):" + } + } + + $SiteMeta = New-GraphGetRequest -uri "https://graph.microsoft.com/v1.0/sites/$SiteSegment`?`$select=id,webUrl,displayName,isPersonalSite" -tenantid $TenantFilter -asapp $true + if ($SiteMeta.isPersonalSite -eq $true) { + throw 'OneDrive sites are not supported in the SharePoint site browser.' + } + if ([string]::IsNullOrWhiteSpace($SiteUrl)) { + $SiteUrl = $SiteMeta.webUrl + } + if ([string]::IsNullOrWhiteSpace($SiteId)) { + $SiteId = $SiteMeta.id + } + $BaseUri = "$($SiteUrl.TrimEnd('/'))/_api" + $SiteInfo = [PSCustomObject]@{ + id = $SiteId + webUrl = $SiteUrl + displayName = $SiteMeta.displayName + type = 'site' + } + + $Lists = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/sites/$SiteSegment/lists?`$select=id,displayName,name,webUrl,list,createdDateTime" -tenantid $TenantFilter -asapp $true + $Results = [System.Collections.Generic.List[object]]::new() + foreach ($List in @($Lists | Where-Object { $_.list.hidden -ne $true -and $_.list.template -in @('documentLibrary', 'webPageLibrary') })) { + $StorageUsed = $null + $FileCount = $null + try { + $Metrics = New-GraphGetRequest -uri "$BaseUri/web/lists(guid'$($List.id)')/RootFolder?`$select=StorageMetrics&`$expand=StorageMetrics" -tenantid $TenantFilter -scope $SpoScope -extraHeaders $JsonAccept -UseCertificate -AsApp $true + $StorageUsed = ConvertTo-StorageUsedBytes -Raw $Metrics.StorageMetrics.TotalSize + $FileCount = ConvertTo-NullableInt64 -Raw $Metrics.StorageMetrics.TotalFileCount + } catch { + $StorageUsed = $null + $FileCount = $null + } + + $Results.Add([PSCustomObject]@{ + type = 'library' + id = $List.id + siteId = $SiteId + displayName = $List.displayName + name = $List.name + template = $List.list.template + siteType = ConvertTo-SiteTypeLabel -ItemType 'library' -LibraryTemplate $List.list.template + webUrl = $List.webUrl + createdDateTime = $List.createdDateTime + storageUsedInBytes = $StorageUsed + fileCount = $FileCount + }) + } + } + + $StatusCode = [HttpStatusCode]::OK + } catch { + $ErrorMessage = Get-CippException -Exception $_ + $Results = "Failed to list SharePoint browser items: $($ErrorMessage.NormalizedError)" + Write-LogMessage -Headers $Request.Headers -API $APIName -tenant $TenantFilter -message $Results -sev Error -LogData $ErrorMessage + $StatusCode = [HttpStatusCode]::BadRequest + $StorageStatus = $null + } + + $Body = @{'Results' = $Results } + if ($SiteInfo) { + $Body['Site'] = $SiteInfo + } + if ($StorageStatus) { + $Body['StorageStatus'] = $StorageStatus + } + + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = $Body + }) +} diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSiteBrowserPermissions.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSiteBrowserPermissions.ps1 new file mode 100644 index 0000000000..94eef3575a --- /dev/null +++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSiteBrowserPermissions.ps1 @@ -0,0 +1,385 @@ +function Invoke-ListSiteBrowserPermissions { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Sharepoint.Site.Read + .DESCRIPTION + Extensive permission inventory for a SharePoint site or library for the site browser. + Collects SPO site admins, associated Owners/Members/Visitors (with members), all site + groups (with members), web/library role assignments, and Graph site permissions + (Sites.Selected / app-only grants). Partial failures are returned in Errors so the UI + can still show what was collected. SiteUrl is required; ListId targets a library. + Sharing links / Graph drive permissions are intentionally out of scope (handled elsewhere). + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $APIName = $Request.Params.CIPPEndpoint + $TenantFilter = $Request.Query.TenantFilter ?? $Request.Query.tenantFilter ?? $Request.Body.TenantFilter ?? $Request.Body.tenantFilter + $SiteUrl = $Request.Query.SiteUrl ?? $Request.Body.SiteUrl + $SiteId = $Request.Query.SiteId ?? $Request.Body.SiteId + $ListId = $Request.Query.ListId ?? $Request.Body.ListId + + function Test-SPGuestPrincipal { + param($Principal) + [bool]$Principal.IsShareByEmailGuestUser -or + [bool]$Principal.IsEmailAuthenticationGuestUser -or + ($Principal.LoginName -match '(?i)#ext#|urn%3aspo%3aguest') + } + + function ConvertTo-PrincipalTypeName { + param($PrincipalType) + switch ($PrincipalType) { + 1 { 'User' } + 2 { 'Distribution List' } + 4 { 'Security Group' } + 8 { 'SharePoint Group' } + default { 'Other' } + } + } + + function ConvertTo-PrincipalObject { + param($Member) + if (-not $Member) { return $null } + [PSCustomObject]@{ + principalId = [string]$Member.Id + title = $Member.Title + loginName = $Member.LoginName + email = $Member.Email + userPrincipalName = if ($Member.PrincipalType -eq 1 -and $Member.LoginName) { ($Member.LoginName -split '\|')[-1] } else { $null } + principalType = ConvertTo-PrincipalTypeName -PrincipalType $Member.PrincipalType + principalTypeId = $Member.PrincipalType + isGuest = (Test-SPGuestPrincipal $Member) + isSiteAdmin = [bool]$Member.IsSiteAdmin + } + } + + function ConvertTo-RoleAssignmentRows { + param( + $Assignments, + [string]$Source, + $SystemGroupIds = $null, + $SystemGroupLoginNames = $null + ) + # One row per principal; multiple RoleDefinitionBindings become permissionLevels[]. + $ByPrincipal = [ordered]@{} + foreach ($Assignment in @($Assignments)) { + $Member = $Assignment.Member + if (-not $Member) { continue } + $Principal = ConvertTo-PrincipalObject -Member $Member + $Key = [string]$Principal.principalId + if (-not $ByPrincipal.Contains($Key)) { + $ByPrincipal[$Key] = [PSCustomObject]@{ + source = $Source + principalId = $Principal.principalId + title = $Principal.title + loginName = $Principal.loginName + email = $Principal.email + userPrincipalName = $Principal.userPrincipalName + principalType = $Principal.principalType + isGuest = $Principal.isGuest + permissionLevels = [System.Collections.Generic.List[object]]::new() + } + } + $SeenIds = [System.Collections.Generic.HashSet[string]]::new() + foreach ($Existing in @($ByPrincipal[$Key].permissionLevels)) { + [void]$SeenIds.Add([string]$Existing.roleDefinitionId) + } + foreach ($Binding in @($Assignment.RoleDefinitionBindings)) { + $RoleId = [string]$Binding.Id + if ($SeenIds.Contains($RoleId)) { continue } + [void]$SeenIds.Add($RoleId) + $ByPrincipal[$Key].permissionLevels.Add([PSCustomObject]@{ + name = $Binding.Name + roleDefinitionId = $RoleId + roleTypeKind = $Binding.RoleTypeKind + description = $Binding.Description + isSystemManaged = ($Binding.RoleTypeKind -eq 1) + }) + } + } + + $Rows = [System.Collections.Generic.List[object]]::new() + foreach ($Key in @($ByPrincipal.Keys)) { + $Row = $ByPrincipal[$Key] + $Levels = @($Row.permissionLevels) + # Prefer a non-system level for the primary label; keep all in permissionLevels. + $Primary = @($Levels | Where-Object { -not $_.isSystemManaged } | Select-Object -First 1) + if (-not $Primary) { $Primary = @($Levels | Select-Object -First 1) } + $IsSystemGroup = $false + if ($null -ne $SystemGroupIds -and $Row.principalId) { + $IsSystemGroup = [bool]$SystemGroupIds.Contains([string]$Row.principalId) + } + if (-not $IsSystemGroup -and $null -ne $SystemGroupLoginNames -and $Row.loginName) { + $IsSystemGroup = [bool]$SystemGroupLoginNames.Contains([string]$Row.loginName) + } + $Rows.Add([PSCustomObject]@{ + source = $Row.source + principalId = $Row.principalId + title = $Row.title + loginName = $Row.loginName + email = $Row.email + userPrincipalName = $Row.userPrincipalName + principalType = $Row.principalType + isGuest = $Row.isGuest + permissionLevel = if ($Primary) { $Primary[0].name } else { $null } + roleDefinitionId = if ($Primary) { $Primary[0].roleDefinitionId } else { $null } + roleTypeKind = if ($Primary) { $Primary[0].roleTypeKind } else { $null } + description = if ($Primary) { $Primary[0].description } else { $null } + isSystemManaged = [bool](@($Levels | Where-Object { $_.isSystemManaged }).Count -eq $Levels.Count -and $Levels.Count -gt 0) + hasSystemManaged = [bool](@($Levels | Where-Object { $_.isSystemManaged }).Count) + isSystemGroup = $IsSystemGroup + permissionLevels = $Levels + }) + } + return @($Rows) + } + + if ([string]::IsNullOrWhiteSpace($TenantFilter)) { + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::BadRequest + Body = @{'Results' = 'tenantFilter is required.' } + }) + } + if ([string]::IsNullOrWhiteSpace($SiteUrl)) { + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::BadRequest + Body = @{'Results' = 'SiteUrl is required.' } + }) + } + + $Errors = [System.Collections.Generic.List[object]]::new() + $IsLibrary = -not [string]::IsNullOrWhiteSpace($ListId) + + try { + $SharePointInfo = Get-SharePointAdminLink -Public $false -tenantFilter $TenantFilter + $SpoScope = "$($SharePointInfo.SharePointUrl)/.default" + $JsonAccept = @{ Accept = 'application/json;odata=nometadata' } + $BaseUri = "$($SiteUrl.TrimEnd('/'))/_api" + + # --- Target / inheritance --- + $TargetTitle = $null + $HasUniqueRoleAssignments = $true + if ($IsLibrary) { + try { + $ListInfo = New-GraphGetRequest -uri "$BaseUri/web/lists(guid'$ListId')?`$select=HasUniqueRoleAssignments,Title,Id" -tenantid $TenantFilter -scope $SpoScope -extraHeaders $JsonAccept -UseCertificate -AsApp $true + $HasUniqueRoleAssignments = [bool]$ListInfo.HasUniqueRoleAssignments + $TargetTitle = $ListInfo.Title + } catch { + $Errors.Add([PSCustomObject]@{ section = 'target'; message = $_.Exception.Message }) + } + } else { + try { + $WebInfo = New-GraphGetRequest -uri "$BaseUri/web?`$select=Title,HasUniqueRoleAssignments,Id" -tenantid $TenantFilter -scope $SpoScope -extraHeaders $JsonAccept -UseCertificate -AsApp $true + $TargetTitle = $WebInfo.Title + $HasUniqueRoleAssignments = $true + } catch { + $Errors.Add([PSCustomObject]@{ section = 'target'; message = $_.Exception.Message }) + } + } + + # --- Site collection admins --- + $SiteAdmins = @() + try { + $AdminUsers = @(New-GraphGetRequest -uri "$BaseUri/web/siteusers?`$filter=IsSiteAdmin eq true&`$select=Id,Title,Email,LoginName,PrincipalType,IsSiteAdmin,IsShareByEmailGuestUser,IsEmailAuthenticationGuestUser" -tenantid $TenantFilter -scope $SpoScope -extraHeaders $JsonAccept -UseCertificate -AsApp $true) + $SiteAdmins = @($AdminUsers | ForEach-Object { ConvertTo-PrincipalObject -Member $_ }) + } catch { + $Errors.Add([PSCustomObject]@{ section = 'siteAdmins'; message = $_.Exception.Message }) + } + + # --- Associated Owners / Members / Visitors --- + $AssociatedGroups = [System.Collections.Generic.List[object]]::new() + $AssociatedEndpoints = [ordered]@{ + 'Owners' = 'associatedownergroup' + 'Members' = 'associatedmembergroup' + 'Visitors' = 'associatedvisitorgroup' + } + foreach ($RoleName in $AssociatedEndpoints.Keys) { + try { + $GroupEntity = New-GraphGetRequest -uri "$BaseUri/web/$($AssociatedEndpoints[$RoleName])?`$select=Id,Title,LoginName,Description,OwnerTitle,OnlyAllowMembersViewMembership" -tenantid $TenantFilter -scope $SpoScope -extraHeaders $JsonAccept -UseCertificate -AsApp $true + $Members = @() + if ($GroupEntity.Id) { + try { + $Users = @(New-GraphGetRequest -uri "$BaseUri/web/$($AssociatedEndpoints[$RoleName])/users?`$select=Id,Title,Email,LoginName,PrincipalType,IsSiteAdmin,IsShareByEmailGuestUser,IsEmailAuthenticationGuestUser" -tenantid $TenantFilter -scope $SpoScope -extraHeaders $JsonAccept -UseCertificate -AsApp $true) + $Members = @($Users | ForEach-Object { ConvertTo-PrincipalObject -Member $_ }) + } catch { + $Errors.Add([PSCustomObject]@{ section = "associatedGroups.$RoleName.members"; message = $_.Exception.Message }) + } + } + $AssociatedGroups.Add([PSCustomObject]@{ + role = $RoleName + groupId = [string]$GroupEntity.Id + title = $GroupEntity.Title + loginName = $GroupEntity.LoginName + description = $GroupEntity.Description + ownerTitle = $GroupEntity.OwnerTitle + memberCount = $Members.Count + members = $Members + isSystemGroup = $true + }) + } catch { + $Errors.Add([PSCustomObject]@{ section = "associatedGroups.$RoleName"; message = $_.Exception.Message }) + } + } + + $SystemGroupIds = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) + $SystemGroupLoginNames = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) + foreach ($Associated in $AssociatedGroups) { + if (-not [string]::IsNullOrWhiteSpace($Associated.groupId)) { + [void]$SystemGroupIds.Add([string]$Associated.groupId) + } + if (-not [string]::IsNullOrWhiteSpace($Associated.loginName)) { + [void]$SystemGroupLoginNames.Add([string]$Associated.loginName) + } + } + + # --- All SharePoint groups + members --- + $SharePointGroups = [System.Collections.Generic.List[object]]::new() + try { + $Groups = @(New-GraphGetRequest -uri "$BaseUri/web/sitegroups?`$select=Id,Title,LoginName,Description,OwnerTitle,OnlyAllowMembersViewMembership,AllowMembersEditMembership,RequestToJoinLeaveEmailSetting" -tenantid $TenantFilter -scope $SpoScope -extraHeaders $JsonAccept -UseCertificate -AsApp $true) + foreach ($Group in $Groups) { + $Members = @() + try { + $Users = @(New-GraphGetRequest -uri "$BaseUri/web/sitegroups($($Group.Id))/users?`$select=Id,Title,Email,LoginName,PrincipalType,IsSiteAdmin,IsShareByEmailGuestUser,IsEmailAuthenticationGuestUser" -tenantid $TenantFilter -scope $SpoScope -extraHeaders $JsonAccept -UseCertificate -AsApp $true) + $Members = @($Users | ForEach-Object { ConvertTo-PrincipalObject -Member $_ }) + } catch { + $Errors.Add([PSCustomObject]@{ section = "sharePointGroups.$($Group.Id).members"; message = $_.Exception.Message }) + } + $GroupId = [string]$Group.Id + $SharePointGroups.Add([PSCustomObject]@{ + groupId = $GroupId + title = $Group.Title + loginName = $Group.LoginName + description = $Group.Description + ownerTitle = $Group.OwnerTitle + onlyAllowMembersViewMembership = [bool]$Group.OnlyAllowMembersViewMembership + allowMembersEditMembership = [bool]$Group.AllowMembersEditMembership + memberCount = $Members.Count + members = $Members + isSystemGroup = $SystemGroupIds.Contains($GroupId) + }) + } + } catch { + $Errors.Add([PSCustomObject]@{ section = 'sharePointGroups'; message = $_.Exception.Message }) + } + + # --- Role assignments (web always; library when ListId and unique) --- + $WebRoleAssignments = @() + try { + $WebAssignments = @(New-GraphGetRequest -uri "$BaseUri/web/roleassignments?`$expand=Member,RoleDefinitionBindings" -tenantid $TenantFilter -scope $SpoScope -extraHeaders $JsonAccept -UseCertificate -AsApp $true) + $WebRoleAssignments = ConvertTo-RoleAssignmentRows -Assignments $WebAssignments -Source 'WebRoleAssignment' -SystemGroupIds $SystemGroupIds -SystemGroupLoginNames $SystemGroupLoginNames + } catch { + $Errors.Add([PSCustomObject]@{ section = 'webRoleAssignments'; message = $_.Exception.Message }) + } + + $LibraryRoleAssignments = @() + if ($IsLibrary) { + if ($HasUniqueRoleAssignments) { + try { + $LibraryAssignments = @(New-GraphGetRequest -uri "$BaseUri/web/lists(guid'$ListId')/roleassignments?`$expand=Member,RoleDefinitionBindings" -tenantid $TenantFilter -scope $SpoScope -extraHeaders $JsonAccept -UseCertificate -AsApp $true) + $LibraryRoleAssignments = ConvertTo-RoleAssignmentRows -Assignments $LibraryAssignments -Source 'LibraryRoleAssignment' -SystemGroupIds $SystemGroupIds -SystemGroupLoginNames $SystemGroupLoginNames + } catch { + $Errors.Add([PSCustomObject]@{ section = 'libraryRoleAssignments'; message = $_.Exception.Message }) + } + } + } + + # --- Graph site permissions (Sites.Selected / app-only grants; site-scoped) --- + $GraphSitePermissions = @() + $ResolvedSiteId = $SiteId + try { + if ([string]::IsNullOrWhiteSpace($ResolvedSiteId)) { + $ParsedUrl = [System.Uri]$SiteUrl + $SiteSegment = if ($ParsedUrl.AbsolutePath -in @('', '/')) { + $ParsedUrl.Host + } else { + "$($ParsedUrl.Host):$($ParsedUrl.AbsolutePath):" + } + $SiteMeta = New-GraphGetRequest -uri "https://graph.microsoft.com/v1.0/sites/$SiteSegment`?`$select=id" -tenantid $TenantFilter -asapp $true + $ResolvedSiteId = $SiteMeta.id + } + if ([string]::IsNullOrWhiteSpace($ResolvedSiteId)) { + throw 'Could not resolve Graph site id.' + } + $RawGraphPerms = @(New-GraphGetRequest -uri "https://graph.microsoft.com/v1.0/sites/$ResolvedSiteId/permissions" -tenantid $TenantFilter -asapp $true) + $GraphSitePermissions = foreach ($Perm in $RawGraphPerms) { + $IdentitySets = @($Perm.grantedToIdentitiesV2) + if ($IdentitySets.Count -eq 0) { $IdentitySets = @($Perm.grantedToIdentities) } + if ($IdentitySets.Count -eq 0 -and $Perm.grantedToV2) { $IdentitySets = @($Perm.grantedToV2) } + if ($IdentitySets.Count -eq 0 -and $Perm.grantedTo) { $IdentitySets = @($Perm.grantedTo) } + + $Identities = foreach ($Set in $IdentitySets) { + if ($Set.application) { + [PSCustomObject]@{ + type = 'application' + id = [string]$Set.application.id + displayName = $Set.application.displayName + } + } elseif ($Set.user) { + [PSCustomObject]@{ + type = 'user' + id = [string]$Set.user.id + displayName = $Set.user.displayName + } + } elseif ($Set.group) { + [PSCustomObject]@{ + type = 'group' + id = [string]$Set.group.id + displayName = $Set.group.displayName + } + } + } + $Identities = @($Identities) + $Primary = $Identities | Select-Object -First 1 + [PSCustomObject]@{ + permissionId = [string]$Perm.id + roles = @($Perm.roles) + identities = $Identities + title = if ($Primary) { $Primary.displayName } else { $null } + identityType = if ($Primary) { $Primary.type } else { $null } + identityId = if ($Primary) { $Primary.id } else { $null } + link = if ($Perm.link) { $true } else { $false } + } + } + # Sharing-link shaped Graph permissions belong elsewhere; keep app/user/group grants only. + $GraphSitePermissions = @($GraphSitePermissions | Where-Object { + -not $_.link -and (@($_.identities).Count -gt 0) + }) + } catch { + $Errors.Add([PSCustomObject]@{ section = 'graphSitePermissions'; message = $_.Exception.Message }) + } + + $Body = [PSCustomObject]@{ + target = [PSCustomObject]@{ + type = if ($IsLibrary) { 'library' } else { 'site' } + title = $TargetTitle + siteUrl = $SiteUrl + siteId = $ResolvedSiteId + listId = if ($IsLibrary) { $ListId } else { $null } + hasUniqueRoleAssignments = $HasUniqueRoleAssignments + inheritsFromSite = $IsLibrary -and -not $HasUniqueRoleAssignments + } + siteAdmins = @($SiteAdmins) + associatedGroups = @($AssociatedGroups) + sharePointGroups = @($SharePointGroups) + webRoleAssignments = @($WebRoleAssignments) + libraryRoleAssignments = @($LibraryRoleAssignments) + graphSitePermissions = @($GraphSitePermissions) + errors = @($Errors) + collectedAt = (Get-Date).ToUniversalTime().ToString('o') + } + + $StatusCode = [HttpStatusCode]::OK + } catch { + $ErrorMessage = Get-CippException -Exception $_ + $Body = "Failed to list site browser permissions: $($ErrorMessage.NormalizedError)" + Write-LogMessage -Headers $Request.Headers -API $APIName -tenant $TenantFilter -message $Body -sev Error -LogData $ErrorMessage + $StatusCode = [HttpStatusCode]::BadRequest + } + + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = @{'Results' = $Body } + }) +} diff --git a/frontend/src/components/CippComponents/CippSharePointBrowserBanner.jsx b/frontend/src/components/CippComponents/CippSharePointBrowserBanner.jsx new file mode 100644 index 0000000000..6c2b778827 --- /dev/null +++ b/frontend/src/components/CippComponents/CippSharePointBrowserBanner.jsx @@ -0,0 +1,123 @@ +import PropTypes from 'prop-types' +import { Box, Button, Card, Skeleton, Stack, Typography } from '@mui/material' +import { Add, Edit, Security, Storage as StorageIcon } from '@mui/icons-material' +import { ActionsMenu } from '../actions-menu' + +/** + * Top chrome for the SharePoint site browser: selection title on the left, + * bulk Actions + Storage + Permissions + contextual New / Edit Site on the right. + * + * Title rules: + * - site only → "SiteName" + * - site + library → "SiteName / LibraryName" (slash subdued) + * - nothing selected → placeholder + * + * Storage: when a site context is available (site-scoped reclaim). + * Permissions: only when a site or library row is selected. + * New button: + * - root → "New Site" + * - inside a site → "New Library" + * Edit Site: when a site is selected or drilled into a site (stub). + */ +export const CippSharePointBrowserBanner = ({ + site, + library, + bulkActions = [], + selectedRows = [], + isFetching = false, + queryKeys, + atRoot = true, + showStorage = false, + onStorageClick, + showPermissions = false, + onPermissionsClick, + showEditSite = false, + onEditSiteClick, +}) => { + const siteName = site?.displayName ?? null + const libraryName = library?.displayName ?? null + const hasTitle = Boolean(siteName || libraryName) + const showActions = selectedRows.length > 0 && bulkActions.length > 0 + const newLabel = atRoot ? 'New Site' : 'New Library' + + return ( + + + + {isFetching && !hasTitle ? ( + + ) : hasTitle ? ( + <> + {siteName ?? 'Site'} + {libraryName ? ( + <> + + / + + {libraryName} + + ) : null} + + ) : ( + + Select a site + + )} + + + {showActions ? ( + 1 ? 'Bulk Actions' : 'Actions'} + actions={bulkActions} + data={selectedRows} + queryKeys={queryKeys} + /> + ) : null} + {showStorage ? ( + + ) : null} + {showPermissions ? ( + + ) : null} + + {showEditSite ? ( + + ) : null} + + + + ) +} + +CippSharePointBrowserBanner.propTypes = { + site: PropTypes.object, + library: PropTypes.object, + bulkActions: PropTypes.array, + selectedRows: PropTypes.array, + isFetching: PropTypes.bool, + queryKeys: PropTypes.oneOfType([PropTypes.string, PropTypes.array]), + atRoot: PropTypes.bool, + showStorage: PropTypes.bool, + onStorageClick: PropTypes.func, + showPermissions: PropTypes.bool, + onPermissionsClick: PropTypes.func, + showEditSite: PropTypes.bool, + onEditSiteClick: PropTypes.func, +} diff --git a/frontend/src/components/CippComponents/CippSharePointBrowserPermissions.jsx b/frontend/src/components/CippComponents/CippSharePointBrowserPermissions.jsx new file mode 100644 index 0000000000..ab3c86e7a3 --- /dev/null +++ b/frontend/src/components/CippComponents/CippSharePointBrowserPermissions.jsx @@ -0,0 +1,1637 @@ +import { useEffect, useMemo, useState } from 'react' +import PropTypes from 'prop-types' +import { + Alert, + AlertTitle, + Box, + Button, + Chip, + CircularProgress, + Dialog, + DialogContent, + DialogTitle, + Divider, + IconButton, + List, + ListItemButton, + ListItemText, + Skeleton, + Stack, + Tab, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Tabs, + Tooltip, + Typography, +} from '@mui/material' +import { + Add, + Close, + DeleteOutline, + EditOutlined, + LinkOff, + Link as LinkIcon, + PersonSearch, + Refresh, + Security, +} from '@mui/icons-material' +import { useForm } from 'react-hook-form' +import { ApiGetCall } from '../../api/ApiCall' +import { CippApiDialog } from './CippApiDialog' +import CippFormComponent from './CippFormComponent' +import { useDialog } from '../../hooks/use-dialog' +import { usePermissions } from '../../hooks/use-permissions' + +const EMPTY = [] + +const optionValue = (value) => + value && typeof value === 'object' && 'value' in value ? value.value : value + +const TabPanel = ({ value, index, children }) => + value === index ? {children} : null + +TabPanel.propTypes = { + value: PropTypes.number.isRequired, + index: PropTypes.number.isRequired, + children: PropTypes.node, +} + +const SectionToolbar = ({ + title, + count, + actions = EMPTY, +}) => ( + + + {title} + {typeof count === 'number' ? : null} + + {actions.length ? ( + + {actions.map((action) => ( + + + + + + ))} + + ) : null} + +) + +SectionToolbar.propTypes = { + title: PropTypes.string.isRequired, + count: PropTypes.number, + actions: PropTypes.arrayOf( + PropTypes.shape({ + label: PropTypes.string.isRequired, + onClick: PropTypes.func, + disabled: PropTypes.bool, + disabledTitle: PropTypes.string, + icon: PropTypes.node, + }) + ), +} + +const RowActions = ({ onEdit, onRemove, disableActions = true, disabledTitle = 'Coming soon' }) => ( + + {onEdit ? ( + + + + + + + + ) : null} + {onRemove ? ( + + + + + + + + ) : null} + +) + +RowActions.propTypes = { + onEdit: PropTypes.func, + onRemove: PropTypes.func, + disableActions: PropTypes.bool, + disabledTitle: PropTypes.string, +} + +const PrincipalChips = ({ row }) => ( + + {row.isGuest ? : null} + {row.isSiteAdmin ? : null} + {row.isSystemGroup ? : null} + {row.isSystemManaged ? : null} + +) + +PrincipalChips.propTypes = { + row: PropTypes.object.isRequired, +} + +const EmptyState = ({ message = 'None' }) => ( + + {message} + +) + +EmptyState.propTypes = { + message: PropTypes.string, +} + +const AccessTable = ({ rows = EMPTY, canWrite = false, systemGroupIds = EMPTY, onEdit, onRemove }) => { + const systemIds = useMemo(() => { + const set = new Set() + ;(Array.isArray(systemGroupIds) ? systemGroupIds : []).forEach((id) => { + if (id !== null && id !== undefined && `${id}`.length) set.add(`${id}`) + }) + return set + }, [systemGroupIds]) + + if (!rows.length) return + + return ( + + + + + Principal + Type + Permission + Email / UPN + + Actions + + + + + {rows.map((row, index) => { + const levels = + Array.isArray(row.permissionLevels) && row.permissionLevels.length + ? row.permissionLevels + : row.permissionLevel + ? [ + { + name: row.permissionLevel, + isSystemManaged: row.isSystemManaged, + roleDefinitionId: row.roleDefinitionId, + }, + ] + : [] + const onlySystem = levels.length > 0 && levels.every((level) => level.isSystemManaged) + const isSystemGroup = + Boolean(row.isSystemGroup) || + (row.principalId != null && systemIds.has(`${row.principalId}`)) + const canAct = canWrite && !onlySystem && !isSystemGroup && !!row.principalId + + return ( + + + + + {row.title || '—'} + + + + + + {row.principalType || '—'} + + + + {levels.length + ? levels.map((level) => ( + } + variant={level.isSystemManaged ? 'outlined' : 'filled'} + label={level.name || '—'} + title={ + level.isSystemManaged + ? 'System-managed (e.g. Limited Access)' + : undefined + } + /> + )) + : '—'} + + + + + {row.userPrincipalName || row.email || row.loginName || '—'} + + + + onEdit(row) : undefined} + onRemove={canAct && onRemove ? () => onRemove(row) : undefined} + /> + + + ) + })} + +
    +
    + ) +} + +AccessTable.propTypes = { + rows: PropTypes.array, + canWrite: PropTypes.bool, + systemGroupIds: PropTypes.array, + onEdit: PropTypes.func, + onRemove: PropTypes.func, +} + +const MembersTable = ({ + rows = EMPTY, + canWrite = false, + onRemoveMember, + disableRemove = false, + disableRemoveTitle = 'Remove unavailable', +}) => { + if (!rows.length) return + + return ( + + + + + Name + Type + Email / UPN + + Actions + + + + + {rows.map((row, index) => { + const canRemove = + canWrite && + !disableRemove && + typeof onRemoveMember === 'function' && + !!row.principalId + + return ( + + + + + {row.title || '—'} + + + + + + {row.principalType || '—'} + + + + {row.userPrincipalName || row.email || row.loginName || '—'} + + + + onRemoveMember(row) + : undefined + } + /> + + + ) + })} + +
    +
    + ) +} + +MembersTable.propTypes = { + rows: PropTypes.array, + canWrite: PropTypes.bool, + onRemoveMember: PropTypes.func, + disableRemove: PropTypes.bool, + disableRemoveTitle: PropTypes.string, +} + +const GraphSitePermissionsTable = ({ rows = EMPTY, canWrite = false, onRemove }) => { + if (!rows.length) { + return + } + + return ( + + + + + Principal + Type + Roles + Id + + Actions + + + + + {rows.map((row, index) => { + const canRemove = canWrite && !!row.permissionId && typeof onRemove === 'function' + return ( + + + + {row.title || '—'} + + + + + {row.identityType || '—'} + + + + + {(row.roles ?? []).length + ? row.roles.map((role) => ( + } label={role} /> + )) + : '—'} + + + + + {row.identityId || '—'} + + + + onRemove(row) : undefined} + /> + + + ) + })} + +
    +
    + ) +} + +GraphSitePermissionsTable.propTypes = { + rows: PropTypes.array, + canWrite: PropTypes.bool, + onRemove: PropTypes.func, +} + +const SITE_ROOT = '__siteRoot__' +const SITE_ROOT_OPTION = { label: 'Site root (whole site)', value: SITE_ROOT } + +/** + * Effective-access check: one user × this site/library, with every route explained. + * Lives inline in Permissions (not a stacked dialog). Reuses ListSiteUserAccess. + */ +const CheckAccessPanel = ({ + open, + tenantFilter, + siteUrl, + siteId, + defaultListId, + defaultListLabel, +}) => { + const defaultScope = useMemo(() => { + if (defaultListId) { + return { + label: defaultListLabel || 'Current library', + value: defaultListId, + } + } + return SITE_ROOT_OPTION + }, [defaultListId, defaultListLabel]) + + const formControl = useForm({ + defaultValues: { user: null, scope: defaultScope }, + }) + const selectedUser = formControl.watch('user') + const selectedScope = formControl.watch('scope') + const [query, setQuery] = useState(null) + + useEffect(() => { + if (!open) { + setQuery(null) + formControl.reset({ user: null, scope: defaultScope }) + return + } + formControl.setValue('scope', defaultScope) + }, [open, defaultScope, formControl]) + + const libraries = ApiGetCall({ + url: '/api/ListSiteLibraries', + data: { SiteId: siteId, SiteUrl: siteUrl, tenantFilter }, + queryKey: `SiteLibraries-${siteId ?? siteUrl}`, + waiting: open && !!siteUrl, + }) + + const scopeOptions = useMemo(() => { + const libs = Array.isArray(libraries.data?.Results) ? libraries.data.Results : [] + const fromApi = libs.map((library) => ({ + label: library.Title, + value: library.Id, + })) + // Keep the current library visible even if ListSiteLibraries is still loading. + if ( + defaultListId && + !fromApi.some((option) => String(option.value) === String(defaultListId)) + ) { + fromApi.unshift({ + label: defaultListLabel || 'Current library', + value: defaultListId, + }) + } + return [SITE_ROOT_OPTION, ...fromApi] + }, [libraries.data, defaultListId, defaultListLabel]) + + const access = ApiGetCall({ + url: '/api/ListSiteUserAccess', + data: query ?? {}, + queryKey: `SiteUserAccess-${siteUrl}-${query?.ListId || 'root'}-${query?.UserPrincipalName}`, + waiting: open && !!query, + }) + + const runCheck = () => { + const upn = optionValue(selectedUser) + if (!upn) return + const scopeId = optionValue(selectedScope) + setQuery({ + tenantFilter, + SiteUrl: siteUrl, + ListId: !scopeId || scopeId === SITE_ROOT ? '' : scopeId, + UserPrincipalName: upn, + }) + } + + const result = access.data?.Results + const data = typeof result === 'object' && result !== null ? result : null + const loadError = typeof result === 'string' ? result : null + const paths = Array.isArray(data?.Paths) ? data.Paths : EMPTY + const realPaths = paths.filter((path) => path.GrantsRealAccess) + const limitedOnly = paths.length > 0 && realPaths.length === 0 + + return ( + + + Pick a user to see every route that grants them access here — direct grants, SharePoint + groups, nested Entra groups, tenant-wide claims, and (when cached) sharing links. This is + the inverse of the Access tab: who can reach this place, and how. + + + + + `${user.displayName} (${user.userPrincipalName})`, + valueField: 'userPrincipalName', + showRefresh: true, + }} + /> + + + + + + + + {loadError ? {loadError} : null} + + {access.isFetching ? : null} + + {!access.isFetching && data ? ( + + + {data.HasAccess ? ( + + + {data.DisplayName} has access via {data.AccessPathCount}{' '} + {data.AccessPathCount === 1 ? 'route' : 'routes'} + + Removing one route does not remove the others — every route below has to go for + access to stop. + + ) : ( + + {data.DisplayName} has no access + {limitedOnly + ? 'The only entry found is Limited Access, which SharePoint adds so a user can traverse to a specific item. It does not let them open or list anything here.' + : 'No permission, group membership or sharing link grants this user access to this scope.'} + + )} + + {data.LibraryInherits ? ( + + This library inherits permissions from the site, so the site's permissions were + evaluated. + + ) : null} + + + + {data.IsGuest ? ( + + ) : null} + {!data.SharingLinksChecked ? ( + + ) : null} + + + {!paths.length ? ( + + ) : ( + + + + + Route + Via + Permission + Applies to + Flags + + + + {paths.map((path, index) => ( + + {path.Route || '—'} + {path.Via || '—'} + {path.PermissionLevel || '—'} + {path.AppliesTo || '—'} + + + {path.IsSystemManaged ? ( + + ) : null} + {path.GrantsRealAccess === false ? ( + + ) : null} + + + + ))} + +
    +
    + )} +
    + ) : null} +
    + ) +} + +CheckAccessPanel.propTypes = { + open: PropTypes.bool, + tenantFilter: PropTypes.string, + siteUrl: PropTypes.string, + siteId: PropTypes.string, + defaultListId: PropTypes.string, + defaultListLabel: PropTypes.string, +} + +/** + * Permissions dialog for the SharePoint site browser. + * Access / Groups / Admins / Apps / Check access. + * Sharing links are out of scope (handled elsewhere). + */ +export const CippSharePointBrowserPermissions = ({ + open = false, + onClose, + item, + tenantFilter, + siteUrl: siteUrlProp, + siteId: siteIdProp, +}) => { + const [tab, setTab] = useState(0) + const [selectedGroupKey, setSelectedGroupKey] = useState(null) + const { checkPermissions } = usePermissions() + const canWrite = checkPermissions(['Sharepoint.Site.ReadWrite']) + + const addUserDialog = useDialog() + const addGroupDialog = useDialog() + const removeMemberDialog = useDialog() + const grantUserDialog = useDialog() + const grantGroupDialog = useDialog() + const replaceAccessDialog = useDialog() + const removeAccessDialog = useDialog() + const addAdminDialog = useDialog() + const removeAdminDialog = useDialog() + const breakInheritanceDialog = useDialog() + const restoreInheritanceDialog = useDialog() + const removeGraphPermissionDialog = useDialog() + + const isLibrary = item?.type === 'library' + const siteUrl = siteUrlProp ?? (isLibrary ? null : item?.webUrl) + const siteId = siteIdProp ?? (isLibrary ? null : item?.id) + const listId = isLibrary ? item?.id : null + const effectiveSiteUrl = siteUrl ?? item?.webUrl + const effectiveSiteId = siteId ?? item?.siteId ?? item?.id + const permissionsQueryKey = `ListSiteBrowserPermissions-${tenantFilter}-${effectiveSiteUrl}-${listId || 'site'}` + + const api = ApiGetCall({ + url: '/api/ListSiteBrowserPermissions', + data: { + tenantFilter, + SiteUrl: effectiveSiteUrl, + SiteId: effectiveSiteId, + ...(listId ? { ListId: listId } : {}), + }, + queryKey: permissionsQueryKey, + waiting: open && !!tenantFilter && !!effectiveSiteUrl, + }) + + const roleDefinitions = ApiGetCall({ + url: '/api/ListSiteRoleDefinitions', + data: { SiteUrl: effectiveSiteUrl, tenantFilter }, + queryKey: `SiteRoleDefinitions-${effectiveSiteUrl}`, + waiting: open && !!tenantFilter && !!effectiveSiteUrl, + }) + + const result = api.data?.Results + const loadError = + typeof result === 'string' + ? result + : api.isError + ? (api.error?.message ?? 'Failed to load permissions.') + : null + const data = typeof result === 'object' && result !== null ? result : null + + const titleName = data?.target?.title || item?.displayName || item?.name || 'Permissions' + const targetType = data?.target?.type || (isLibrary ? 'library' : 'site') + const inherits = Boolean(data?.target?.inheritsFromSite) + const hasUnique = Boolean(data?.target?.hasUniqueRoleAssignments) + const canMutateAccess = canWrite && !(targetType === 'library' && inherits) + const writeDisabledTitle = !canWrite + ? 'Requires SharePoint write permission' + : inherits + ? 'Break inheritance to change library access' + : 'Unavailable' + + const levelOptions = useMemo(() => { + const definitions = Array.isArray(roleDefinitions.data?.Results) + ? roleDefinitions.data.Results + : [] + return definitions.map((definition) => ({ + label: definition.IsCustom ? `${definition.Name} (custom)` : definition.Name, + value: definition.Id, + })) + }, [roleDefinitions.data]) + + const scopePayload = { + tenantFilter, + SiteUrl: effectiveSiteUrl, + ListId: targetType === 'library' ? listId : '', + LibraryName: targetType === 'library' ? titleName : '', + } + + const accessRows = useMemo(() => { + if (!data) return [] + if (targetType === 'library' && !inherits) { + return data.libraryRoleAssignments ?? [] + } + return data.webRoleAssignments ?? [] + }, [data, targetType, inherits]) + + const systemGroupIds = useMemo( + () => + (data?.associatedGroups ?? []) + .map((group) => group.groupId) + .filter((id) => id !== null && id !== undefined && `${id}`.length), + [data] + ) + const groupList = useMemo(() => { + if (!data) return [] + const associated = (data.associatedGroups ?? []).map((group) => ({ + key: `assoc-${group.role}`, + kind: 'associated', + label: group.role, + subtitle: group.title || '', + memberCount: group.memberCount ?? group.members?.length ?? 0, + members: group.members ?? [], + groupId: group.groupId, + isSystemGroup: true, + })) + const associatedIds = new Set(associated.map((g) => g.groupId).filter(Boolean)) + const custom = (data.sharePointGroups ?? []) + .filter((group) => !associatedIds.has(group.groupId)) + .map((group) => ({ + key: `sp-${group.groupId}`, + kind: 'sharepoint', + label: group.title || group.loginName || group.groupId, + subtitle: group.description || 'SharePoint group', + memberCount: group.memberCount ?? group.members?.length ?? 0, + members: group.members ?? [], + groupId: group.groupId, + isSystemGroup: Boolean(group.isSystemGroup), + })) + return [...associated, ...custom] + }, [data]) + + const activeGroup = + groupList.find((group) => group.key === selectedGroupKey) || groupList[0] || null + const canNestIntoActiveGroup = canWrite && !!activeGroup?.groupId + + const handleClose = () => { + setTab(0) + setSelectedGroupKey(null) + onClose?.() + } + + const removeMember = removeMemberDialog.data + const accessRow = replaceAccessDialog.data || removeAccessDialog.data + const adminRow = removeAdminDialog.data + const graphPermissionRow = removeGraphPermissionDialog.data + const graphSitePermissions = data?.graphSitePermissions ?? [] + const accessScopeLabel = + targetType === 'library' && !inherits ? `library ${titleName}` : 'the site' + + return ( + + + + + Permissions — {titleName} + + + + {inherits ? : null} + {hasUnique && targetType === 'library' ? ( + + ) : null} + {data?.collectedAt ? ( + + Collected {new Date(data.collectedAt).toLocaleString()} + + ) : null} + + + + + + api.refetch()} + disabled={!effectiveSiteUrl || api.isFetching} + > + + + + + + + + + + + {!effectiveSiteUrl ? ( + No site URL available for this selection. + ) : api.isFetching && !data ? ( + + + + ) : loadError ? ( + {loadError} + ) : data ? ( + + {data.errors?.length ? ( + + Some sections failed to load ({data.errors.length}). Showing what was collected. + + ) : null} + + {targetType === 'library' && inherits ? ( + } + disabled={!canWrite} + onClick={() => breakInheritanceDialog.handleOpen()} + > + Stop inheriting + + } + > + This library inherits permissions from the site. Showing site role assignments; + stop inheriting to manage library-specific access. + + ) : null} + + {targetType === 'library' && hasUnique && !inherits ? ( + } + disabled={!canWrite} + onClick={() => restoreInheritanceDialog.handleOpen()} + > + Restore inheritance + + } + > + This library has unique permissions. Restoring inheritance discards them and + follows the site again. + + ) : null} + + setTab(next)} + variant="scrollable" + allowScrollButtonsMobile + > + + + + + + + + + + grantUserDialog.handleOpen(), + disabled: !canMutateAccess, + disabledTitle: writeDisabledTitle, + }, + { + label: 'Grant group', + onClick: () => grantGroupDialog.handleOpen(), + disabled: !canMutateAccess, + disabledTitle: writeDisabledTitle, + }, + ]} + /> + replaceAccessDialog.handleOpen(row)} + onRemove={(row) => removeAccessDialog.handleOpen(row)} + /> + + + + {!groupList.length ? ( + + ) : ( + + + + Groups + + + {groupList.map((group) => ( + setSelectedGroupKey(group.key)} + > + + + ))} + + + + + addUserDialog.handleOpen(), + disabled: !canNestIntoActiveGroup, + disabledTitle: !canWrite + ? 'Requires SharePoint write permission' + : !activeGroup?.groupId + ? 'Select a SharePoint group' + : 'Unavailable', + }, + { + label: 'Add group', + onClick: () => addGroupDialog.handleOpen(), + disabled: !canNestIntoActiveGroup, + disabledTitle: !canWrite + ? 'Requires SharePoint write permission' + : !activeGroup?.groupId + ? 'Select a SharePoint group' + : 'Unavailable', + }, + ]} + /> + {activeGroup?.subtitle ? ( + + {activeGroup.subtitle} + {activeGroup.kind === 'associated' ? ' · Associated group' : ''} + + ) : null} + removeMemberDialog.handleOpen(row)} + /> + + + )} + + + + addAdminDialog.handleOpen(), + disabled: !canWrite, + disabledTitle: 'Requires SharePoint write permission', + }, + ]} + /> + removeAdminDialog.handleOpen(row)} + /> + + Site collection admins are separate from Owners group membership. + + + + + + removeGraphPermissionDialog.handleOpen(row)} + /> + + Site-scoped Graph grants (typically Sites.Selected app access). Separate from + SharePoint role assignments and sharing links. + + + + + + + + ) : null} + + + ({ + ...scopePayload, + Action: 'GrantAccess', + RoleDefinitionId: optionValue(formData.RoleDefinitionId), + Users: formData.Users ?? [], + }), + multiPost: false, + }} + row={item ?? {}} + > + {({ formHook }) => ( + <> + `${user.displayName} (${user.userPrincipalName})`, + valueField: 'userPrincipalName', + addedField: { id: 'id' }, + showRefresh: true, + }} + /> + + + )} + + + ({ + ...scopePayload, + Action: 'GrantAccess', + RoleDefinitionId: optionValue(formData.RoleDefinitionId), + Groups: formData.Groups ?? [], + }), + multiPost: false, + }} + row={item ?? {}} + > + {({ formHook }) => ( + <> + + group.mail ? `${group.displayName} (${group.mail})` : group.displayName, + valueField: 'id', + addedField: { + securityEnabled: 'securityEnabled', + groupTypes: 'groupTypes', + }, + showRefresh: true, + }} + /> + + + )} + + + ({ + ...scopePayload, + Action: 'ReplaceAccess', + PrincipalId: accessRow?.principalId, + PrincipalName: accessRow?.title, + RoleDefinitionId: optionValue(formData.RoleDefinitionId), + }), + multiPost: false, + }} + row={accessRow ?? {}} + > + {({ formHook }) => ( + + )} + + + ({ + ...scopePayload, + Action: 'RemoveAccess', + PrincipalId: accessRow?.principalId, + PrincipalName: accessRow?.title, + }), + multiPost: false, + }} + row={accessRow ?? {}} + /> + + ({ + tenantFilter, + SiteUrl: effectiveSiteUrl, + Action: 'AddGroupMember', + GroupId: activeGroup?.groupId, + GroupName: activeGroup?.subtitle || activeGroup?.label, + Users: formData.Users ?? [], + }), + multiPost: false, + }} + row={activeGroup ?? {}} + > + {({ formHook }) => ( + `${user.displayName} (${user.userPrincipalName})`, + valueField: 'userPrincipalName', + addedField: { id: 'id' }, + showRefresh: true, + }} + /> + )} + + + ({ + tenantFilter, + SiteUrl: effectiveSiteUrl, + Action: 'AddGroupMember', + GroupId: activeGroup?.groupId, + GroupName: activeGroup?.subtitle || activeGroup?.label, + Groups: formData.Groups ?? [], + }), + multiPost: false, + }} + row={activeGroup ?? {}} + > + {({ formHook }) => ( + + group.mail ? `${group.displayName} (${group.mail})` : group.displayName, + valueField: 'id', + addedField: { + securityEnabled: 'securityEnabled', + groupTypes: 'groupTypes', + }, + showRefresh: true, + }} + /> + )} + + + ({ + tenantFilter, + SiteUrl: effectiveSiteUrl, + Action: 'RemoveGroupMember', + GroupId: activeGroup?.groupId, + GroupName: activeGroup?.subtitle || activeGroup?.label, + PrincipalId: removeMember?.principalId, + PrincipalName: removeMember?.title, + }), + multiPost: false, + }} + row={removeMember ?? {}} + /> + + ({ + tenantFilter, + SiteUrl: effectiveSiteUrl, + Action: 'AddSiteAdmin', + Users: formData.Users ?? [], + }), + multiPost: false, + }} + row={item ?? {}} + > + {({ formHook }) => ( + `${user.displayName} (${user.userPrincipalName})`, + valueField: 'userPrincipalName', + addedField: { id: 'id' }, + showRefresh: true, + }} + /> + )} + + + ({ + tenantFilter, + SiteUrl: effectiveSiteUrl, + Action: 'RemoveSiteAdmin', + Users: [ + { + value: adminRow?.userPrincipalName || adminRow?.email || adminRow?.title, + label: adminRow?.title, + }, + ], + PrincipalName: adminRow?.title, + userPrincipalName: adminRow?.userPrincipalName, + }), + multiPost: false, + }} + row={adminRow ?? {}} + /> + + ({ + ...scopePayload, + Action: 'BreakInheritance', + CopyRoleAssignments: formData.CopyRoleAssignments !== false, + ClearSubscopes: formData.ClearSubscopes === true, + }), + multiPost: false, + }} + row={item ?? {}} + > + {({ formHook }) => ( + <> + + + Turn this off to start from an empty permission set. Only site collection admins can + reach the library until permissions are granted. + + + + )} + + + ({ + ...scopePayload, + Action: 'RestoreInheritance', + }), + multiPost: false, + }} + row={item ?? {}} + /> + + ({ + tenantFilter, + SiteUrl: effectiveSiteUrl, + SiteId: data?.target?.siteId || effectiveSiteId, + Action: 'RemoveGraphSitePermission', + PermissionId: graphPermissionRow?.permissionId, + PrincipalName: graphPermissionRow?.title || graphPermissionRow?.identityId, + }), + multiPost: false, + }} + row={graphPermissionRow ?? {}} + /> + + ) +} + +CippSharePointBrowserPermissions.propTypes = { + open: PropTypes.bool, + onClose: PropTypes.func, + item: PropTypes.object, + tenantFilter: PropTypes.string, + siteUrl: PropTypes.string, + siteId: PropTypes.string, +} diff --git a/frontend/src/components/CippComponents/CippSharePointBrowserProperties.jsx b/frontend/src/components/CippComponents/CippSharePointBrowserProperties.jsx new file mode 100644 index 0000000000..128652107f --- /dev/null +++ b/frontend/src/components/CippComponents/CippSharePointBrowserProperties.jsx @@ -0,0 +1,184 @@ +import { useEffect } from 'react' +import PropTypes from 'prop-types' +import { Card, CardHeader, Typography } from '@mui/material' +import { CippPropertyList } from './CippPropertyList' +import { CippCopyToClipBoard } from './CippCopyToClipboard' +import { ApiPostCall } from '../../api/ApiCall' + +const isSiteLike = (item) => item && (item.type === 'site' || item.canOpen) + +const formatVersionPolicy = (props) => { + if (!props || typeof props !== 'object') return null + if (props.InheritVersionPolicyFromTenant) { + return 'Tenant default' + } + const major = + props.MajorVersionLimit === null || props.MajorVersionLimit === undefined + ? null + : Number(props.MajorVersionLimit) + const days = + props.ExpireVersionsAfterDays === null || props.ExpireVersionsAfterDays === undefined + ? null + : Number(props.ExpireVersionsAfterDays) + + if (props.EnableAutoExpirationVersionTrim) { + const parts = ['Auto trim'] + if (major !== null && !Number.isNaN(major) && major > 0) { + parts.push(`${major.toLocaleString()} major`) + } + if (days !== null && !Number.isNaN(days) && days > 0) { + parts.push(`${days.toLocaleString()} days`) + } + return parts.join(' · ') + } + + if (major !== null && !Number.isNaN(major)) { + if (major <= 0) return 'Unlimited / not set' + const label = `${major.toLocaleString()} major versions` + if (days !== null && !Number.isNaN(days) && days > 0) { + return `${label} · expire after ${days.toLocaleString()} days` + } + return label + } + + return '—' +} + +/** + * Left-hand property panel for the selected SharePoint site or library. + * List columns cover type / name / files / size — this pane keeps IDs, URL, and site version policy. + */ +export const CippSharePointBrowserProperties = ({ + item, + tenantFilter, + isFetching = false, + emptyMessage = 'Select an item to view details.', +}) => { + const siteUrl = isSiteLike(item) ? item.webUrl : null + const siteId = isSiteLike(item) ? item.id : null + const sitePropsApi = ApiPostCall({}) + + useEffect(() => { + if (!tenantFilter || (!siteUrl && !siteId)) return + sitePropsApi.mutate({ + url: '/api/ExecSiteBrowserActions', + data: { + Action: 'GetSiteProperties', + tenantFilter, + SiteUrl: siteUrl, + SiteId: siteId, + }, + }) + // refetch when the selected site changes + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [tenantFilter, siteUrl, siteId]) + + const rawSiteProps = sitePropsApi.data?.data?.Results + const normalizedSiteUrl = siteUrl ? siteUrl.replace(/\/+$/, '') : null + const siteAdminProps = + typeof rawSiteProps === 'object' && + rawSiteProps !== null && + !Array.isArray(rawSiteProps) && + (!normalizedSiteUrl || + !rawSiteProps.Url || + String(rawSiteProps.Url).replace(/\/+$/, '') === normalizedSiteUrl) + ? rawSiteProps + : null + const versionsLabel = formatVersionPolicy(siteAdminProps) + const versionsFetching = Boolean( + (siteUrl || siteId) && (sitePropsApi.isPending || (!siteAdminProps && !sitePropsApi.isError)) + ) + + const propertyItems = (() => { + if (!item) return [] + + if (isSiteLike(item)) { + return [ + { + label: 'Description', + value: item.description?.trim() ? item.description : '—', + }, + { + label: 'Versions', + value: versionsFetching ? '' : versionsLabel || '—', + }, + { + label: 'Site ID', + value: item.siteId ? : '—', + }, + { + label: 'Graph ID', + value: item.id ? : '—', + }, + { + label: 'Web ID', + value: item.webId ? : '—', + }, + { + label: 'URL', + value: item.webUrl ? : '—', + }, + ] + } + + return [ + { label: 'Template', value: item.template || '—' }, + { + label: 'List ID', + value: item.id ? : '—', + }, + { + label: 'Site ID', + value: item.siteId ? : '—', + }, + { + label: 'URL', + value: item.webUrl ? : '—', + }, + ] + })() + + return ( + + + {!item && !isFetching ? ( + + {emptyMessage} + + ) : ( + + )} + + ) +} + +CippSharePointBrowserProperties.propTypes = { + item: PropTypes.object, + tenantFilter: PropTypes.string, + isFetching: PropTypes.bool, + emptyMessage: PropTypes.string, +} diff --git a/frontend/src/components/CippComponents/CippSharePointBrowserStorage.jsx b/frontend/src/components/CippComponents/CippSharePointBrowserStorage.jsx new file mode 100644 index 0000000000..a054dd438c --- /dev/null +++ b/frontend/src/components/CippComponents/CippSharePointBrowserStorage.jsx @@ -0,0 +1,747 @@ +import { useEffect, useMemo, useState } from 'react' +import PropTypes from 'prop-types' +import { + Alert, + Box, + Button, + Chip, + CircularProgress, + Dialog, + DialogContent, + DialogTitle, + Divider, + IconButton, + LinearProgress, + Stack, + Tab, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Tabs, + Tooltip, + Typography, +} from '@mui/material' +import { + CleaningServices, + Close, + Refresh, + RestoreFromTrash, + Storage as StorageIcon, +} from '@mui/icons-material' +import { CippDataTable } from '../CippTable/CippDataTable' +import { CippApiDialog } from './CippApiDialog' +import CippFormComponent from './CippFormComponent' +import { CippFormCondition } from './CippFormCondition' +import { CippPropertyList } from './CippPropertyList' +import { ApiGetCall, ApiPostCall } from '../../api/ApiCall' +import { useDialog } from '../../hooks/use-dialog' +import { usePermissions } from '../../hooks/use-permissions' + +const optionValue = (value) => + value && typeof value === 'object' && 'value' in value ? value.value : value + +const TabPanel = ({ value, index, children }) => + value === index ? {children} : null + +TabPanel.propTypes = { + value: PropTypes.number.isRequired, + index: PropTypes.number.isRequired, + children: PropTypes.node, +} + +const VERSION_CLEANUP_LABELS = { + Status: 'Status', + BatchDeleteMode: 'Cleanup Mode', + RequestTimeInUTC: 'Requested (UTC)', + LastProcessTimeInUTC: 'Last Processed (UTC)', + CompleteTimeInUTC: 'Completed (UTC)', + ListsProcessed: 'Lists Processed', + ListsUpdated: 'Lists Updated', + ListsFailed: 'Lists Failed', + FilesProcessed: 'Files Processed', + VersionsProcessed: 'Versions Processed', + VersionsDeleted: 'Versions Deleted', + VersionsFailed: 'Versions Failed', + StorageReleased: 'Storage Released (bytes)', + ErrorMessage: 'Error Message', + WorkItemId: 'Work Item ID', + Message: 'Message', +} +const VERSION_CLEANUP_FIELDS = Object.keys(VERSION_CLEANUP_LABELS) +const TOP_LIBRARIES = 8 + +const formatBytes = (bytes) => { + const num = Number(bytes) + if (bytes === null || bytes === undefined || bytes === '' || Number.isNaN(num)) return null + if (num < 1024) return `${num} B` + const gb = num / (1024 * 1024 * 1024) + if (gb >= 0.01) return `${gb.toLocaleString(undefined, { maximumFractionDigits: 2 })} GB` + const mb = num / (1024 * 1024) + return `${mb.toLocaleString(undefined, { maximumFractionDigits: 2 })} MB` +} + +const toBytesFromMb = (mb) => { + if (mb === null || mb === undefined || mb === '') return null + const num = Number(mb) + if (Number.isNaN(num)) return null + return num * 1024 * 1024 +} + +const formatVersionPolicy = (props) => { + if (!props || typeof props !== 'object') return null + if (props.InheritVersionPolicyFromTenant) return 'Tenant default' + const major = + props.MajorVersionLimit === null || props.MajorVersionLimit === undefined + ? null + : Number(props.MajorVersionLimit) + const days = + props.ExpireVersionsAfterDays === null || props.ExpireVersionsAfterDays === undefined + ? null + : Number(props.ExpireVersionsAfterDays) + + if (props.EnableAutoExpirationVersionTrim) { + const parts = ['Auto trim'] + if (major !== null && !Number.isNaN(major) && major > 0) { + parts.push(`${major.toLocaleString()} major`) + } + if (days !== null && !Number.isNaN(days) && days > 0) { + parts.push(`${days.toLocaleString()} days`) + } + return parts.join(' · ') + } + + if (major !== null && !Number.isNaN(major)) { + if (major <= 0) return 'Unlimited / not set' + const label = `${major.toLocaleString()} major versions` + if (days !== null && !Number.isNaN(days) && days > 0) { + return `${label} · expire after ${days.toLocaleString()} days` + } + return label + } + return null +} + +const jobStatusChip = (progress) => { + if (!progress || typeof progress === 'string') { + return { label: 'No job', color: 'default' } + } + if (progress.Status === 'NoRequestFound' || progress.Status === 'NoJob') { + return { label: 'No job', color: 'default' } + } + const status = String(progress.Status ?? '').toLowerCase() + if (!status) return { label: 'Unknown', color: 'default' } + if (status.includes('complete') || status.includes('success')) { + return { label: progress.Status, color: 'success' } + } + if (status.includes('fail') || status.includes('error')) { + return { label: progress.Status, color: 'error' } + } + if (status.includes('run') || status.includes('progress') || status.includes('pending')) { + return { label: progress.Status, color: 'warning' } + } + return { label: progress.Status, color: 'info' } +} + +const VersionCleanupFields = ({ formHook }) => ( + <> + + + + + + + + + +) + +VersionCleanupFields.propTypes = { + formHook: PropTypes.object.isRequired, +} + +/** + * Site-scoped Storage sheet for cleanup. + * Overview (cheap live): used/quota, version policy, top libraries. + * Recycle / Versions tabs: cleanup actions — no file-level scans. + */ +export const CippSharePointBrowserStorage = ({ + open = false, + onClose, + item, + tenantFilter, +}) => { + const [tab, setTab] = useState(0) + const { checkPermissions } = usePermissions() + const canWriteSite = checkPermissions(['Sharepoint.Site.ReadWrite']) + const canReadRecycleBin = checkPermissions([ + 'Sharepoint.SiteRecycleBin.Read', + 'Sharepoint.SiteRecycleBin.ReadWrite', + ]) + const canRestore = checkPermissions(['Sharepoint.SiteRecycleBin.ReadWrite']) + const startCleanupDialog = useDialog() + + const siteUrl = item?.webUrl + const siteId = item?.id + const siteName = item?.displayName || item?.name || 'Site' + const tenant = item?.Tenant ?? tenantFilter + const sitePropsApi = ApiPostCall({}) + const jobStatusApi = ApiPostCall({}) + + const librariesApi = ApiGetCall({ + url: '/api/ListSiteBrowser', + data: { + tenantFilter: tenant, + SiteId: siteId, + SiteUrl: siteUrl, + }, + queryKey: `SiteBrowserStorageLibs-${tenant}-${siteId || siteUrl}`, + waiting: open && !!tenant && !!(siteId || siteUrl), + }) + + const fetchSiteProps = () => { + if (!tenant || (!siteUrl && !siteId)) return + sitePropsApi.mutate({ + url: '/api/ExecSiteBrowserActions', + data: { + Action: 'GetSiteProperties', + tenantFilter: tenant, + SiteUrl: siteUrl, + SiteId: siteId, + }, + }) + } + + const fetchJobStatus = () => { + if (!tenant || (!siteUrl && !siteId)) return + jobStatusApi.mutate({ + url: '/api/ExecSiteBrowserActions', + data: { + Action: 'GetVersionCleanupStatus', + tenantFilter: tenant, + SiteUrl: siteUrl, + SiteId: siteId, + }, + }) + } + + const refreshAll = () => { + fetchSiteProps() + librariesApi.refetch?.() + if (tab === 2) fetchJobStatus() + } + + useEffect(() => { + if (!open) return + setTab(0) + fetchSiteProps() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, siteUrl, siteId, tenant]) + + useEffect(() => { + if (!open || tab !== 2) return + fetchJobStatus() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, tab, siteUrl, siteId, tenant]) + + const siteProps = + typeof sitePropsApi.data?.data?.Results === 'object' && + sitePropsApi.data?.data?.Results !== null && + !Array.isArray(sitePropsApi.data?.data?.Results) + ? sitePropsApi.data.data.Results + : null + + const jobProgress = jobStatusApi.data?.data?.Results + const versionsLabel = formatVersionPolicy(siteProps) + const chip = useMemo(() => jobStatusChip(jobProgress), [jobProgress]) + + const usedBytes = useMemo(() => { + const fromItem = Number(item?.storageUsedInBytes) + if (!Number.isNaN(fromItem) && fromItem > 0) return fromItem + return toBytesFromMb(siteProps?.StorageUsage) + }, [item?.storageUsedInBytes, siteProps?.StorageUsage]) + + const quotaBytes = toBytesFromMb(siteProps?.StorageMaximumLevel) + const warningBytes = toBytesFromMb(siteProps?.StorageWarningLevel) + const usedLabel = formatBytes(usedBytes) || '—' + const quotaLabel = formatBytes(quotaBytes) + const usedPct = + quotaBytes && usedBytes !== null && quotaBytes > 0 + ? Math.min(100, Math.round((usedBytes / quotaBytes) * 1000) / 10) + : null + const nearWarning = + warningBytes && usedBytes !== null ? usedBytes >= warningBytes : usedPct !== null && usedPct >= 85 + const quotaBarColor = nearWarning ? 'warning' : 'primary' + + const libraryRows = useMemo(() => { + const raw = librariesApi.data?.Results + if (!Array.isArray(raw)) return [] + return [...raw] + .map((lib) => ({ + ...lib, + _bytes: Number(lib.storageUsedInBytes), + })) + .sort((a, b) => { + const aOk = !Number.isNaN(a._bytes) ? a._bytes : -1 + const bOk = !Number.isNaN(b._bytes) ? b._bytes : -1 + return bOk - aOk + }) + }, [librariesApi.data]) + + const topLibraries = libraryRows.slice(0, TOP_LIBRARIES) + const librariesMeasuredBytes = useMemo( + () => + libraryRows.reduce((sum, lib) => { + if (Number.isNaN(lib._bytes) || lib._bytes < 0) return sum + return sum + lib._bytes + }, 0), + [libraryRows] + ) + const librariesMeasuredLabel = formatBytes(librariesMeasuredBytes) + const maxLibBytes = topLibraries[0]?._bytes > 0 ? topLibraries[0]._bytes : 0 + + const glanceLoading = sitePropsApi.isPending && !siteProps + const libsLoading = librariesApi.isFetching && !libraryRows.length + + const handleClose = () => { + setTab(0) + onClose?.() + } + + const recycleBinQueryKey = `SiteBrowserRecycleBin-${siteUrl}` + + const recycleActions = [ + { + label: 'Restore Item', + type: 'POST', + icon: , + url: '/api/ExecRestoreRecycleBinItems', + data: { + Ids: 'Id', + ItemNames: 'LeafName', + SiteUrl: siteUrl, + tenantFilter: tenant, + }, + confirmText: 'Restore [LeafName] from the recycle bin?', + condition: () => canRestore, + multiPost: false, + }, + ] + + return ( + <> + + + + Storage — {siteName} + + + + + + + + + + + + + + + + {!siteUrl ? ( + No site URL available for this selection. + ) : ( + + setTab(next)} + variant="scrollable" + allowScrollButtonsMobile + > + + + + + + + + + {glanceLoading ? ( + + + + ) : ( + + + } + color={nearWarning ? 'warning' : 'default'} + label={ + quotaLabel + ? `Used ${usedLabel} / ${quotaLabel}${ + usedPct !== null ? ` (${usedPct}%)` : '' + }` + : `Used ${usedLabel}` + } + /> + + {librariesMeasuredLabel ? ( + + ) : null} + + + {quotaBytes ? ( + + + + {nearWarning + ? 'Near quota warning — reclaim recycle or trim versions before the site locks writes.' + : 'Quota usage from site properties (live).'} + + + ) : null} + + + Cleanup path: check largest libraries → Recycle bin + (1st/2nd stage) → Versions if history looks like the gap. Version bytes are + not measured live (that would scan files). + + + )} + + + + Largest libraries + {libsLoading ? : null} + + {librariesApi.isError ? ( + + Could not load library sizes. You can still use Recycle and Versions. + + ) : !libsLoading && !topLibraries.length ? ( + + No document libraries returned for this site. + + ) : ( + + + + + Library + Type + Files + + Size + + + + + {topLibraries.map((lib) => { + const pct = + maxLibBytes > 0 && !Number.isNaN(lib._bytes) && lib._bytes > 0 + ? Math.min(100, (lib._bytes / maxLibBytes) * 100) + : 0 + return ( + + + + {lib.displayName || lib.name || '—'} + + + + + {lib.siteType || '—'} + + + + + {lib.fileCount != null + ? Number(lib.fileCount).toLocaleString() + : '—'} + + + + + + {formatBytes(lib.storageUsedInBytes) || '—'} + + {pct > 0 ? ( + + ) : null} + + + + ) + })} + +
    +
    + )} + + Library size = root folder StorageMetrics (live). Site used may be higher — + recycle, versions, and other lists are not in this table. + {libraryRows.length > TOP_LIBRARIES + ? ` Showing top ${TOP_LIBRARIES} of ${libraryRows.length}.` + : ''} + +
    +
    +
    + + + {!canReadRecycleBin ? ( + + Recycle bin requires SharePoint recycle bin read permission. + + ) : ( + <> + + First and second stage together (newest first, capped by the API). Filter on + Item State. Sizes are per item — totals are not fully summed live. + + + + )} + + + + + + Version history trim + + + + + + + + + + + + + + Site policy: {versionsLabel || '—'}. A cleanup job trims existing file versions; it + does not change the policy. Use when libraries look smaller than site used and + recycle is already thin — classic version bloat. + + + {jobStatusApi.isError ? ( + + {typeof jobStatusApi.error?.response?.data?.Results === 'string' + ? jobStatusApi.error.response.data.Results + : 'Failed to load cleanup job status.'} + + ) : null} + + {jobStatusApi.isPending && !jobProgress ? ( + + + + ) : !jobProgress || + (typeof jobProgress === 'string' && !jobProgress.trim()) || + jobProgress?.Status === 'NoRequestFound' || + jobProgress?.Status === 'NoJob' ? ( + + {jobProgress?.Message || 'No cleanup job found for this site.'} + + ) : typeof jobProgress === 'string' ? ( + {jobProgress} + ) : ( + jobProgress?.[key] !== undefined && jobProgress?.[key] !== '' + ).map((key) => ({ + label: VERSION_CLEANUP_LABELS[key], + value: String(jobProgress[key]), + }))} + /> + )} + +
    + )} +
    +
    + + { + const mode = parseInt(optionValue(formData.BatchDeleteMode) ?? '2', 10) + return { + tenantFilter: tenant, + SiteUrl: siteUrl, + SiteId: siteId, + Action: 'StartVersionCleanup', + BatchDeleteMode: mode, + DeleteOlderThanDays: mode === 0 ? parseInt(formData.DeleteOlderThanDays, 10) : -1, + MajorVersionLimit: mode === 1 ? parseInt(formData.MajorVersionLimit, 10) : -1, + MajorWithMinorVersionsLimit: + mode === 1 ? parseInt(formData.MajorWithMinorVersionsLimit, 10) : -1, + } + }, + multiPost: false, + onSuccess: () => { + fetchJobStatus() + }, + }} + row={item ?? {}} + > + {({ formHook }) => } + + + ) +} + +CippSharePointBrowserStorage.propTypes = { + open: PropTypes.bool, + onClose: PropTypes.func, + item: PropTypes.object, + tenantFilter: PropTypes.string, +} diff --git a/frontend/src/components/CippComponents/CippSharePointFolderView.jsx b/frontend/src/components/CippComponents/CippSharePointFolderView.jsx new file mode 100644 index 0000000000..5c02606a3e --- /dev/null +++ b/frontend/src/components/CippComponents/CippSharePointFolderView.jsx @@ -0,0 +1,918 @@ +import { useEffect, useMemo, useState } from 'react' +import PropTypes from 'prop-types' +import { + Alert, + Badge, + Box, + Breadcrumbs, + Button, + Card, + Checkbox, + Chip, + CircularProgress, + Divider, + FormControlLabel, + IconButton, + InputAdornment, + Link, + ListItemIcon, + ListItemText, + Menu, + MenuItem, + Popover, + Radio, + RadioGroup, + Stack, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + TableSortLabel, + TextField, + Tooltip, + Typography, +} from '@mui/material' +import { alpha } from '@mui/material/styles' +import { + ArrowUpward, + Clear, + FilterList, + Folder, + FolderOpen, + FolderShared, + MoreVert, + OpenInNew, + Search as SearchIcon, +} from '@mui/icons-material' + +const formatDate = (value) => { + if (!value) return '—' + const date = new Date(value) + if (Number.isNaN(date.getTime()) || date.getUTCFullYear() <= 1) return '—' + return date.toLocaleString(undefined, { + year: 'numeric', + month: 'numeric', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + }) +} + +const formatSizeGb = (bytes) => { + if (bytes === null || bytes === undefined || bytes === '') return null + const num = Number(bytes) + if (Number.isNaN(num)) return null + return num / (1024 * 1024 * 1024) +} + +const formatSizeMb = (bytes) => { + if (bytes === null || bytes === undefined || bytes === '') return null + const num = Number(bytes) + if (Number.isNaN(num)) return null + return num / (1024 * 1024) +} + +const formatSizeGbLabel = (bytes) => { + const gb = formatSizeGb(bytes) + if (gb === null) return '—' + return gb.toLocaleString(undefined, { maximumFractionDigits: 2 }) +} + +const formatSizeMbTooltip = (bytes) => { + const mb = formatSizeMb(bytes) + if (mb === null) return null + return `${mb.toLocaleString(undefined, { maximumFractionDigits: 2 })} MB` +} + +const RowActionsMenu = ({ item, actions = [] }) => { + const [anchorEl, setAnchorEl] = useState(null) + const open = Boolean(anchorEl) + const available = actions.filter((action) => { + if (typeof action.condition === 'function') return action.condition(item) + return true + }) + + if (!available.length) return null + + return ( + <> + { + event.stopPropagation() + setAnchorEl(event.currentTarget) + }} + > + + + setAnchorEl(null)} + onClick={(event) => event.stopPropagation()} + anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }} + transformOrigin={{ horizontal: 'right', vertical: 'top' }} + > + {available.map((action) => ( + { + setAnchorEl(null) + action.onClick?.(item) + }} + component={action.href ? 'a' : 'li'} + href={action.href?.(item)} + target={action.href ? '_blank' : undefined} + rel={action.href ? 'noopener noreferrer' : undefined} + > + {action.icon ? {action.icon} : null} + {action.label} + + ))} + + + ) +} + +RowActionsMenu.propTypes = { + item: PropTypes.object.isRequired, + actions: PropTypes.array, +} + +const formatFileCount = (value) => { + if (value === null || value === undefined || value === '') return '—' + const num = Number(value) + if (Number.isNaN(num)) return '—' + return num.toLocaleString() +} + +const COLUMNS = [ + { id: 'name', label: 'Name', align: 'left', width: undefined, defaultDir: 'asc' }, + { id: 'webUrl', label: 'URL', align: 'center', width: 72, defaultDir: 'asc' }, + { id: 'siteType', label: 'Type', align: 'left', width: '14%', defaultDir: 'asc' }, + { id: 'fileCount', label: 'Files', align: 'right', width: '10%', defaultDir: 'desc' }, + { id: 'size', label: 'Size (GB)', align: 'right', width: '10%', defaultDir: 'desc' }, + { id: 'created', label: 'Created', align: 'left', width: '16%', defaultDir: 'desc' }, +] + +const getSortValue = (item, columnId) => { + switch (columnId) { + case 'name': + return (item.displayName ?? item.name ?? '').toString().toLocaleLowerCase() + case 'webUrl': + return (item.webUrl ?? '').toString().toLocaleLowerCase() + case 'siteType': + return (item.siteType ?? '').toString().toLocaleLowerCase() + case 'fileCount': { + const num = Number(item.fileCount) + return Number.isFinite(num) ? num : null + } + case 'size': { + const num = Number(item.storageUsedInBytes) + return Number.isFinite(num) ? num : null + } + case 'created': { + const time = item.createdDateTime ? Date.parse(item.createdDateTime) : NaN + return Number.isFinite(time) ? time : null + } + default: + return null + } +} + +const compareItems = (a, b, columnId, direction) => { + const aVal = getSortValue(a, columnId) + const bVal = getSortValue(b, columnId) + const aEmpty = aVal === null || aVal === undefined || aVal === '' + const bEmpty = bVal === null || bVal === undefined || bVal === '' + + if (aEmpty && bEmpty) return 0 + if (aEmpty) return 1 + if (bEmpty) return -1 + + let result + if (typeof aVal === 'number' && typeof bVal === 'number') { + result = aVal - bVal + } else { + result = String(aVal).localeCompare(String(bVal), undefined, { sensitivity: 'base' }) + } + + return direction === 'asc' ? result : -result +} + +const itemSearchText = (item) => + [item?.displayName, item?.name, item?.webUrl, item?.siteType, item?.type] + .filter(Boolean) + .join(' ') + .toLowerCase() + +const matchesSearch = (item, query) => { + const q = query.trim().toLowerCase() + if (!q) return true + return itemSearchText(item).includes(q) +} + +const GB = 1024 * 1024 * 1024 +const SIZE_FILTERS = [ + { label: 'Any size', value: 0 }, + { label: 'Over 1 GB', value: 1 * GB }, + { label: 'Over 10 GB', value: 10 * GB }, + { label: 'Over 50 GB', value: 50 * GB }, + { label: 'Over 100 GB', value: 100 * GB }, +] + +const typeLabel = (item) => { + const label = (item?.siteType ?? '').toString().trim() + return label || 'Unknown' +} + +const matchesFilters = (item, { types, minSizeBytes }) => { + if (types.length > 0 && !types.includes(typeLabel(item))) return false + if (minSizeBytes > 0) { + const bytes = Number(item?.storageUsedInBytes) + if (!Number.isFinite(bytes) || bytes < minSizeBytes) return false + } + return true +} + +const sizeFilterLabel = (minSizeBytes) => + SIZE_FILTERS.find((option) => option.value === minSizeBytes)?.label ?? 'Any size' + +/** + * Explorer-style details list for the SharePoint site browser. + * Columns: Name, URL, Type, Files, Size (GB), Created. + * Click selects; double-click / Enter opens when canOpen is true. + */ +export const CippSharePointFolderView = ({ + items = [], + isFetching = false, + error, + path = [], + onNavigate, + onSelect, + checkedIds = [], + onCheckedChange, + onOpen, + rowActions = [], + emptyMessage = 'No items found.', +}) => { + const [sortBy, setSortBy] = useState('name') + const [sortDir, setSortDir] = useState('asc') + const [searchQuery, setSearchQuery] = useState('') + const [filterTypes, setFilterTypes] = useState([]) + const [minSizeBytes, setMinSizeBytes] = useState(0) + const [filterAnchor, setFilterAnchor] = useState(null) + + const pathKey = path.map((crumb) => crumb?.id ?? crumb?.webUrl ?? '').join('/') + useEffect(() => { + setSearchQuery('') + setFilterTypes([]) + setMinSizeBytes(0) + setFilterAnchor(null) + }, [pathKey]) + + const handleCrumbClick = (index) => { + if (!onNavigate) return + if (index < 0) { + onNavigate([]) + } else { + onNavigate(path.slice(0, index + 1)) + } + } + + const canGoUp = path.length > 0 + const handleGoUp = () => { + if (!canGoUp || !onNavigate) return + onNavigate(path.slice(0, -1)) + } + + const handleSort = (columnId) => { + const column = COLUMNS.find((col) => col.id === columnId) + if (!column) return + if (sortBy === columnId) { + setSortDir((prev) => (prev === 'asc' ? 'desc' : 'asc')) + return + } + setSortBy(columnId) + setSortDir(column.defaultDir) + } + + const availableTypes = useMemo(() => { + const counts = new Map() + for (const item of items) { + const label = typeLabel(item) + counts.set(label, (counts.get(label) ?? 0) + 1) + } + return [...counts.entries()] + .map(([label, count]) => ({ label, count })) + .sort((a, b) => a.label.localeCompare(b.label, undefined, { sensitivity: 'base' })) + }, [items]) + + const filtersActive = filterTypes.length > 0 || minSizeBytes > 0 + const activeFilterCount = filterTypes.length + (minSizeBytes > 0 ? 1 : 0) + + const filteredItems = useMemo( + () => + items.filter( + (item) => + matchesSearch(item, searchQuery) && + matchesFilters(item, { types: filterTypes, minSizeBytes }) + ), + [items, searchQuery, filterTypes, minSizeBytes] + ) + + const sortedItems = useMemo(() => { + return [...filteredItems].sort((a, b) => compareItems(a, b, sortBy, sortDir)) + }, [filteredItems, sortBy, sortDir]) + + const checkedIdSet = useMemo(() => new Set(checkedIds), [checkedIds]) + const allChecked = + sortedItems.length > 0 && sortedItems.every((item) => checkedIdSet.has(item.id)) + const someChecked = sortedItems.some((item) => checkedIdSet.has(item.id)) + const searchActive = searchQuery.trim().length > 0 + const noMatches = + (searchActive || filtersActive) && items.length > 0 && sortedItems.length === 0 + const searchPlaceholder = canGoUp ? 'Search libraries…' : 'Search sites…' + + const clearFilters = () => { + setFilterTypes([]) + setMinSizeBytes(0) + } + + const toggleType = (label) => { + setFilterTypes((prev) => + prev.includes(label) ? prev.filter((value) => value !== label) : [...prev, label] + ) + } + + const handleToggleAll = (event) => { + event.stopPropagation() + if (!onCheckedChange) return + if (allChecked) { + onCheckedChange([]) + } else { + onCheckedChange(sortedItems.map((item) => item.id)) + } + } + + const handleToggleOne = (itemId) => { + if (!onCheckedChange) return + if (checkedIdSet.has(itemId)) { + onCheckedChange(checkedIds.filter((id) => id !== itemId)) + } else { + onCheckedChange([...checkedIds, itemId]) + } + } + + // Row click selects that row only; click again clears; Ctrl/Cmd+click toggles multi-select. + const handleRowActivate = (event, item) => { + if (!onCheckedChange) { + onSelect?.(item) + return + } + if (event.ctrlKey || event.metaKey) { + handleToggleOne(item.id) + } else if (checkedIds.length === 1 && checkedIds[0] === item.id) { + onCheckedChange([]) + } else { + onCheckedChange([item.id]) + } + onSelect?.(item) + } + + const showTable = !isFetching && (canGoUp || items.length > 0) + + return ( + + + + + handleCrumbClick(-1)} + sx={{ cursor: 'pointer' }} + > + Sites + + {path.map((crumb, index) => { + const isLast = index === path.length - 1 + if (isLast) { + return ( + + {crumb.displayName ?? crumb.name} + + ) + } + return ( + handleCrumbClick(index)} + sx={{ cursor: 'pointer' }} + > + {crumb.displayName ?? crumb.name} + + ) + })} + + + setSearchQuery(event.target.value)} + placeholder={searchPlaceholder} + aria-label={searchPlaceholder} + disabled={isFetching} + sx={{ + width: { xs: '100%', sm: 240 }, + flex: { xs: 1, sm: 'none' }, + '& .MuiOutlinedInput-root': { + height: 40, + boxSizing: 'border-box', + }, + '& .MuiInputAdornment-root': { + height: 'auto', + maxHeight: 'none', + marginTop: '0 !important', + }, + }} + InputProps={{ + startAdornment: ( + + + + ), + endAdornment: searchQuery ? ( + + setSearchQuery('')} + edge="end" + sx={{ p: 0.5 }} + > + + + + ) : null, + }} + /> + + + + setFilterAnchor(null)} + anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }} + transformOrigin={{ vertical: 'top', horizontal: 'right' }} + slotProps={{ paper: { sx: { width: 300, p: 2 } } }} + > + + + Filters + + + + + + Type + + {availableTypes.length === 0 ? ( + + No types in this list. + + ) : ( + + {availableTypes.map(({ label, count }) => ( + toggleType(label)} + /> + } + label={ + + {label}{' '} + + ({count}) + + + } + sx={{ mr: 0, ml: 0 }} + /> + ))} + + )} + + + + + + + Minimum size + + setMinSizeBytes(Number(event.target.value))} + > + {SIZE_FILTERS.map((option) => ( + } + label={{option.label}} + sx={{ mr: 0, ml: 0 }} + /> + ))} + + + + + + + + {filtersActive ? ( + + {filterTypes.map((label) => ( + toggleType(label)} + /> + ))} + {minSizeBytes > 0 ? ( + setMinSizeBytes(0)} + /> + ) : null} + + + ) : null} + + {error ? ( + {typeof error === 'string' ? error : 'Failed to load items.'} + ) : null} + + {isFetching ? ( + + + + ) : !showTable ? ( + + {emptyMessage} + + ) : ( + + + theme.palette.mode === 'dark' + ? theme.palette.background.default + : alpha(theme.palette.neutral[200], 0.4), + backgroundImage: 'none', + }, + }} + > + + + + + + {COLUMNS.map((column) => ( + + handleSort(column.id)} + sx={ + column.align === 'right' + ? { flexDirection: 'row-reverse', ml: 'auto' } + : column.align === 'center' + ? { mx: 'auto' } + : undefined + } + > + {column.label} + + + ))} + + + + + {canGoUp ? ( + { + if (event.key === 'Enter') handleGoUp() + }} + sx={{ cursor: 'pointer' }} + > + + + + + + .. + + + Go up + + + + + + — + + + + + — + + + + + — + + + + + — + + + + + — + + + + + ) : null} + {noMatches ? ( + + + + {searchActive && filtersActive + ? `No matches for “${searchQuery.trim()}” with the current filters.` + : searchActive + ? `No matches for “${searchQuery.trim()}”.` + : 'No items match the current filters.'} + + + + ) : null} + {sortedItems.length === 0 && canGoUp && !searchActive && !filtersActive ? ( + + + + {emptyMessage} + + + + ) : null} + {sortedItems.map((item) => { + const checked = checkedIdSet.has(item.id) + const isSite = + item.type === 'site' || item.canOpen + const Icon = isSite ? (checked ? FolderOpen : Folder) : FolderShared + + return ( + handleRowActivate(event, item)} + onDoubleClick={() => { + if (item.canOpen) onOpen?.(item) + }} + onKeyDown={(event) => { + if (event.key === 'Enter') { + if (item.canOpen) onOpen?.(item) + else handleRowActivate(event, item) + } + }} + sx={{ + cursor: 'pointer', + borderLeft: (theme) => + checked + ? `3px solid ${theme.palette.warning.main}` + : '3px solid transparent', + '&.Mui-selected': { + bgcolor: (theme) => + alpha( + theme.palette.warning.main, + theme.palette.mode === 'dark' ? 0.22 : 0.14 + ), + }, + '&.Mui-selected:hover': { + bgcolor: (theme) => + alpha( + theme.palette.warning.main, + theme.palette.mode === 'dark' ? 0.3 : 0.2 + ), + }, + }} + > + { + event.stopPropagation() + handleToggleOne(item.id) + }} + > + handleToggleOne(item.id)} + onClick={(event) => event.stopPropagation()} + color="warning" + inputProps={{ + 'aria-label': `Select ${item.displayName ?? item.name ?? 'item'}`, + }} + /> + + + + + + {item.displayName ?? item.name} + + + + event.stopPropagation()}> + {item.webUrl ? ( + + + + + + ) : ( + + — + + )} + + + + {item.siteType || '—'} + + + + + {formatFileCount(item.fileCount)} + + + + {formatSizeMbTooltip(item.storageUsedInBytes) ? ( + + + {formatSizeGbLabel(item.storageUsedInBytes)} + + + ) : ( + + — + + )} + + + + {formatDate(item.createdDateTime)} + + + event.stopPropagation()} + > + + + + ) + })} + +
    +
    + )} +
    +
    + ) +} + +CippSharePointFolderView.propTypes = { + items: PropTypes.array, + isFetching: PropTypes.bool, + error: PropTypes.any, + path: PropTypes.array, + onNavigate: PropTypes.func, + /** @deprecated Selection is driven by checkedIds; kept for optional side-effects. */ + selectedId: PropTypes.string, + onSelect: PropTypes.func, + checkedIds: PropTypes.arrayOf(PropTypes.string), + onCheckedChange: PropTypes.func, + onOpen: PropTypes.func, + rowActions: PropTypes.array, + emptyMessage: PropTypes.string, +} diff --git a/frontend/src/components/actions-menu.js b/frontend/src/components/actions-menu.js index 77a4c1c6a6..475d530fc2 100644 --- a/frontend/src/components/actions-menu.js +++ b/frontend/src/components/actions-menu.js @@ -37,7 +37,7 @@ export const ActionsMenu = (props) => { whiteSpace: "nowrap", }} > - Actions + {label} { + const list = Array.isArray(rows) ? rows : [rows] + list.forEach((row) => { + if (row?.webUrl) { + window.open(row.webUrl, '_blank', 'noopener,noreferrer') + } + }) +} + +const queryString = (value) => (typeof value === 'string' && value.length > 0 ? value : null) + +const isSiteRow = (row) => row?.type === 'site' + +const Page = () => { + const router = useRouter() + const tenantFilter = useSettings().currentTenant + const [checkedIds, setCheckedIds] = useState([]) + const [permissionsOpen, setPermissionsOpen] = useState(false) + const [storageOpen, setStorageOpen] = useState(false) + + // Location is owned by the URL (?siteId=…) — name/url come from navigation or API Site + const siteId = queryString(router.query.siteId) + const [siteMeta, setSiteMeta] = useState(null) + + const openedSite = + router.isReady && siteId + ? { + id: siteId, + webUrl: siteMeta?.id === siteId ? siteMeta.webUrl : undefined, + displayName: siteMeta?.id === siteId ? siteMeta.displayName || siteMeta.webUrl : '…', + type: 'site', + canOpen: true, + storageUsedInBytes: + siteMeta?.id === siteId ? siteMeta.storageUsedInBytes : undefined, + } + : null + const path = openedSite ? [openedSite] : [] + const atRoot = !openedSite + + // Browser back/forward changes location without going through handlers + useEffect(() => { + setCheckedIds([]) + }, [siteId]) + + const setBrowserLocation = (site) => { + if (!router.isReady) return + const query = { ...router.query } + if (site?.id) { + query.siteId = site.id + setSiteMeta(site) + } else { + delete query.siteId + setSiteMeta(null) + } + delete query.siteUrl + delete query.siteName + delete query.siteType + router.replace({ pathname: router.pathname, query }, undefined, { shallow: true }) + } + + const browserApi = ApiGetCall({ + url: '/api/ListSiteBrowser', + data: { + tenantFilter, + ...(siteId ? { SiteId: siteId } : {}), + }, + queryKey: siteId + ? `ListSiteBrowser-${tenantFilter}-${siteId}` + : `ListSiteBrowser-${tenantFilter}-root`, + waiting: router.isReady && !!tenantFilter && tenantFilter !== 'AllTenants', + }) + + // Enrich from API after cold load / refresh + useEffect(() => { + const site = browserApi.data?.Site + if (site?.id && site.id === siteId) { + setSiteMeta((prev) => ({ + ...prev, + ...site, + // Keep storage from the site we opened if the Site payload doesn't include it + storageUsedInBytes: site.storageUsedInBytes ?? prev?.storageUsedInBytes, + })) + } + }, [browserApi.data?.Site, siteId]) + + const rawResults = browserApi.data?.Results + const items = useMemo(() => { + if (!Array.isArray(rawResults)) return [] + return rawResults.map((row) => ({ + ...row, + canOpen: row.type === 'site', + })) + }, [rawResults]) + + const checkedItems = useMemo(() => { + if (!checkedIds.length) return [] + const idSet = new Set(checkedIds) + return items.filter((item) => idSet.has(item.id)) + }, [items, checkedIds]) + + // Single checked row drives properties / permissions; multi-check is for Actions only. + const selected = checkedItems.length === 1 ? checkedItems[0] : null + + const actionRows = useMemo(() => { + if (checkedItems.length) return checkedItems + if (openedSite?.webUrl) return [openedSite] + return [] + }, [checkedItems, openedSite]) + + const errorMessage = + typeof rawResults === 'string' + ? rawResults + : browserApi.isError + ? (browserApi.error?.message ?? 'Failed to load items.') + : null + + // Banner always reflects the opened site; library only when one is selected + const bannerSite = openedSite ?? (selected?.type === 'site' ? selected : null) + const bannerLibrary = selected?.type === 'library' ? selected : null + const propertiesItem = selected + + // Storage is site-scoped: selected site at root, or the opened site when drilled in + const storageSite = isSiteRow(selected) ? selected : openedSite + const showStorage = Boolean(storageSite?.webUrl) + + const handleCheckedChange = (ids) => { + setCheckedIds(ids) + } + + const handleOpen = (item) => { + if (!item?.canOpen) return + setCheckedIds([]) + setBrowserLocation(item) + } + + const handleNavigate = (nextPath) => { + setCheckedIds([]) + setBrowserLocation(nextPath?.[0] ?? null) + } + + const bulkActions = useMemo( + () => [ + { + label: 'Open in SharePoint', + icon: , + showInActionsMenu: true, + noConfirm: true, + customFunction: (rows) => openUrls(rows), + condition: (rows) => + (Array.isArray(rows) ? rows : [rows]).some((row) => Boolean(row?.webUrl)), + }, + { + label: 'Storage', + icon: , + showInActionsMenu: true, + noConfirm: true, + condition: (rows) => { + const list = Array.isArray(rows) ? rows : [rows] + return list.length === 1 && isSiteRow(list[0]) && Boolean(list[0]?.webUrl) + }, + customFunction: (rows) => { + const list = Array.isArray(rows) ? rows : [rows] + if (list[0]?.id) setCheckedIds([list[0].id]) + setStorageOpen(true) + }, + }, + { + label: 'Delete', + icon: , + showInActionsMenu: true, + noConfirm: true, + customFunction: () => {}, + }, + ], + [] + ) + + const rowActions = useMemo( + () => [ + { + label: 'Open in SharePoint', + icon: , + condition: (item) => Boolean(item?.webUrl), + href: (item) => item.webUrl, + }, + { + label: 'Browse', + icon: , + condition: (item) => Boolean(item?.canOpen), + onClick: handleOpen, + }, + { + label: 'Storage', + icon: , + condition: (item) => isSiteRow(item) && Boolean(item?.webUrl), + onClick: (item) => { + if (item?.id) setCheckedIds([item.id]) + setStorageOpen(true) + }, + }, + { + label: 'Delete', + icon: , + onClick: () => {}, + }, + ], + [] + ) + + return ( + <> + + + + + SharePoint Site Browser + + + browserApi.refetch()} + disabled={!tenantFilter || tenantFilter === 'AllTenants' || browserApi.isFetching} + > + + + + + + {!tenantFilter || tenantFilter === 'AllTenants' ? ( + + Select a tenant to browse SharePoint sites. + + ) : ( + <> + setStorageOpen(true)} + showPermissions={selected?.type === 'site' || selected?.type === 'library'} + onPermissionsClick={() => setPermissionsOpen(true)} + showEditSite={Boolean(openedSite) || isSiteRow(selected)} + queryKeys={ + siteId + ? `ListSiteBrowser-${tenantFilter}-${siteId}` + : `ListSiteBrowser-${tenantFilter}-root` + } + /> + setPermissionsOpen(false)} + item={selected} + tenantFilter={tenantFilter} + siteUrl={selected?.type === 'library' ? openedSite?.webUrl : selected?.webUrl} + siteId={selected?.type === 'library' ? openedSite?.id : selected?.id} + /> + setStorageOpen(false)} + item={storageSite} + tenantFilter={tenantFilter} + /> + + + + + + + + + + )} + + + + ) +} + +Page.getLayout = (page) => {page} + +export default Page From 3e3797cdf34611923e393d54fa07f5f2426acc68 Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Sun, 16 Aug 2026 00:36:40 +0200 Subject: [PATCH 082/226] baselines --- .../QuarantineRequestAlert.json | 50 +++----- .../Exchange Standards/AutoAddProxy.json | 57 +++++++++ .../MailboxRecipientLimits.json | 74 ++++++++++++ .../SafeSendersDisable.json | 49 ++++++++ .../Exchange Standards/calDefault.json | 108 ++++++++++++++++++ .../Get-CIPPBaselineAutoAddProxyState.ps1 | 47 ++++++++ .../Baselines/Get-CIPPBaselineCacheRows.ps1 | 12 +- ...IPPBaselineMailboxRecipientLimitsState.ps1 | 69 +++++++++++ ...IPPBaselineQuarantineRequestAlertState.ps1 | 50 ++++++++ ...et-CIPPBaselineSafeSendersDisableState.ps1 | 35 ++++++ .../Get-CIPPBaselinecalDefaultState.ps1 | 37 ++++++ ...oke-CIPPBaselineQuarantineRequestAlert.ps1 | 71 ++++++++++++ .../Baselines/BaselineExecutors.Tests.ps1 | 70 ++++++++++++ .../Baselines/BaselinePrepareHooks.Tests.ps1 | 42 +++++++ 14 files changed, 734 insertions(+), 37 deletions(-) create mode 100644 backend/Config/BaselineStandards/Exchange Standards/AutoAddProxy.json create mode 100644 backend/Config/BaselineStandards/Exchange Standards/MailboxRecipientLimits.json create mode 100644 backend/Config/BaselineStandards/Exchange Standards/SafeSendersDisable.json create mode 100644 backend/Config/BaselineStandards/Exchange Standards/calDefault.json create mode 100644 backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineAutoAddProxyState.ps1 create mode 100644 backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineMailboxRecipientLimitsState.ps1 create mode 100644 backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineQuarantineRequestAlertState.ps1 create mode 100644 backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineSafeSendersDisableState.ps1 create mode 100644 backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselinecalDefaultState.ps1 create mode 100644 backend/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineQuarantineRequestAlert.ps1 diff --git a/backend/Config/BaselineStandards/Defender Standards/QuarantineRequestAlert.json b/backend/Config/BaselineStandards/Defender Standards/QuarantineRequestAlert.json index 193114fb75..554b1726a1 100644 --- a/backend/Config/BaselineStandards/Defender Standards/QuarantineRequestAlert.json +++ b/backend/Config/BaselineStandards/Defender Standards/QuarantineRequestAlert.json @@ -4,7 +4,7 @@ "cat": "Defender Standards", "tag": [], "impact": "Low Impact", - "helpText": "Sets a e-mail address to alert when a User requests to release a quarantined message.", + "helpText": "Sets a e-mail address to alert when a User requests to release a quarantined message. With \"Allow extra addresses\" on, additional recipients are accepted and preserved; with it off, the configured address is enforced as the only recipient.", "executiveText": "Notifies IT administrators when employees request to release emails that were quarantined for security reasons, enabling oversight of potentially dangerous messages. This helps ensure that legitimate emails are released while maintaining security controls over suspicious content.", "docsDescription": "Sets a e-mail address to alert when a User requests to release a quarantined message. This is useful for monitoring and ensuring that the correct messages are released.", "impactColour": "info", @@ -25,13 +25,15 @@ "type": "textField", "label": "E-mail to receive the alert", "required": true + }, + "AllowExtraAddresses": { + "type": "switch", + "label": "Allow extra addresses", + "helperText": "Leave on to accept additional recipients someone added to the alert, and to keep them when the standard writes. Turn off to enforce the configured address as the only recipient.", + "default": true, + "recommended": true } }, - "expected": { - "NotifyUser": [ - "%NotifyUser%" - ] - }, "read": { "cacheType": "ExoProtectionAlert", "filter": [ @@ -42,34 +44,12 @@ ] }, "remediate": { - "executor": "ExoRequest", - "cmdlets": [ - { - "cmdlet": "New-ProtectionAlert", - "compliance": true, - "continueOnError": true, - "params": { - "Name": "CIPP User requested to release a quarantined message", - "ThreatType": "Activity", - "Category": "ThreatManagement", - "Operation": "QuarantineRequestReleaseMessage", - "Severity": "Informational", - "AggregationType": "None", - "NotifyUser": "%NotifyUser%" - } - }, - { - "cmdlet": "Set-ProtectionAlert", - "compliance": true, - "params": { - "Identity": "CIPP User requested to release a quarantined message", - "Category": "ThreatManagement", - "Operation": "QuarantineRequestReleaseMessage", - "Severity": "Informational", - "AggregationType": "None", - "NotifyUser": "%NotifyUser%" - } - } - ] + "executor": "QuarantineRequestAlert", + "notifyUser": "%NotifyUser%", + "allowExtraAddresses": "%AllowExtraAddresses%" + }, + "prepare": "Get-CIPPBaselineQuarantineRequestAlertState", + "expected": { + "NotifyUserPresent": true } } diff --git a/backend/Config/BaselineStandards/Exchange Standards/AutoAddProxy.json b/backend/Config/BaselineStandards/Exchange Standards/AutoAddProxy.json new file mode 100644 index 0000000000..2228924a87 --- /dev/null +++ b/backend/Config/BaselineStandards/Exchange Standards/AutoAddProxy.json @@ -0,0 +1,57 @@ +{ + "name": "AutoAddProxy", + "label": "Automatically deploy proxy addresses", + "cat": "Exchange Standards", + "tag": [], + "impact": "Medium Impact", + "helpText": "Automatically adds all available domains as a proxy address.", + "executiveText": "Automatically creates email addresses for employees across all company domains, ensuring they can receive emails sent to any of the organization's domain names. This improves email delivery reliability and maintains consistent communication channels across different business units or brands.", + "docsDescription": "Automatically finds all available domain names in the tenant, and tries to add proxy addresses based on the user's UPN to each of these.", + "impactColour": "warning", + "addedDate": "2025-02-07", + "powershellEquivalent": "Set-Mailbox -EmailAddresses @{add=$EmailAddress}", + "recommendedBy": [], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ], + "secureScoreImpact": 0, + "compare": "subset", + "variables": {}, + "expected": { + "offenders": [] + }, + "read": { + "cacheType": "Mailboxes", + "collectorArgs": { + "Types": "None" + } + }, + "prepare": "Get-CIPPBaselineAutoAddProxyState", + "remediate": { + "executor": "ExoBulkSweep", + "refreshCache": [ + "Mailboxes" + ], + "refreshCacheArgs": { + "Mailboxes": { + "Types": "None" + } + }, + "writes": [ + { + "cmdlet": "Set-Mailbox", + "params": { + "Identity": "%id%", + "EmailAddresses": { + "@odata.type": "#Exchange.GenericHashTable", + "Add": "%alias%" + } + } + } + ] + } +} diff --git a/backend/Config/BaselineStandards/Exchange Standards/MailboxRecipientLimits.json b/backend/Config/BaselineStandards/Exchange Standards/MailboxRecipientLimits.json new file mode 100644 index 0000000000..d7e94c0f9c --- /dev/null +++ b/backend/Config/BaselineStandards/Exchange Standards/MailboxRecipientLimits.json @@ -0,0 +1,74 @@ +{ + "name": "MailboxRecipientLimits", + "label": "Set Mailbox Recipient Limits", + "cat": "Exchange Standards", + "tag": [], + "impact": "Low Impact", + "helpText": "Sets the maximum number of recipients that can be specified in the To, Cc, and Bcc fields of a message for all mailboxes in the tenant. Mailboxes whose plan caps recipients below the configured value are reported separately - they cannot be written to.", + "executiveText": "Controls how many recipients employees can include in a single email, helping prevent spam distribution and managing email server load. This security measure protects against both accidental mass mailings and potential abuse while ensuring legitimate business communications can still reach necessary recipients.", + "docsDescription": "This standard configures the recipient limits for all mailboxes in the tenant. The recipient limit determines the maximum number of recipients that can be specified in the To, Cc, and Bcc fields of a message.", + "impactColour": "info", + "addedDate": "2025-05-28", + "powershellEquivalent": "Set-Mailbox -RecipientLimits", + "recommendedBy": [ + "CIPP" + ], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ], + "secureScoreImpact": 0, + "compare": "subset", + "variables": { + "RecipientLimit": { + "type": "number", + "label": "Recipient Limit", + "required": true, + "default": 500, + "validators": { + "min": { + "value": 1, + "message": "Minimum value is 1" + }, + "max": { + "value": 1000, + "message": "Maximum value is 1000" + } + } + } + }, + "expected": { + "offenders": [], + "planIssues": [] + }, + "read": { + "cacheType": "Mailboxes", + "collectorArgs": { + "Types": "None" + } + }, + "prepare": "Get-CIPPBaselineMailboxRecipientLimitsState", + "remediate": { + "executor": "ExoBulkSweep", + "refreshCache": [ + "Mailboxes" + ], + "refreshCacheArgs": { + "Mailboxes": { + "Types": "None" + } + }, + "writes": [ + { + "cmdlet": "Set-Mailbox", + "params": { + "Identity": "%id%", + "RecipientLimits": "%RecipientLimit%" + } + } + ] + } +} diff --git a/backend/Config/BaselineStandards/Exchange Standards/SafeSendersDisable.json b/backend/Config/BaselineStandards/Exchange Standards/SafeSendersDisable.json new file mode 100644 index 0000000000..9917f305b6 --- /dev/null +++ b/backend/Config/BaselineStandards/Exchange Standards/SafeSendersDisable.json @@ -0,0 +1,49 @@ +{ + "name": "SafeSendersDisable", + "label": "Remove Safe Senders to prevent SPF bypass", + "cat": "Exchange Standards", + "tag": [], + "impact": "Medium Impact", + "helpText": "Loops through all users and removes the Safe Senders list. This is to prevent SPF bypass attacks, as the Safe Senders list is not checked by SPF. This is a remediate only standard: the per-mailbox Safe Senders list is not readable at scale, so the standard always reports compliant and applies the change on every run.", + "executiveText": "Removes user-defined safe sender lists to prevent security bypasses where malicious emails could avoid spam filtering. This ensures all emails go through proper security screening, even if users have previously marked senders as 'safe', improving overall email security.", + "docsDescription": "Loops through all users and removes the Safe Senders list. This is to prevent SPF bypass attacks, as the Safe Senders list is not checked by SPF. Remediate only - there is no readable state to compare, so the row always reports compliant and the sweep runs on every pass.", + "impactColour": "warning", + "addedDate": "2023-10-26", + "powershellEquivalent": "Set-MailboxJunkEmailConfiguration", + "recommendedBy": [ + "CIPP" + ], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ], + "secureScoreImpact": 0, + "compare": "subset", + "variables": {}, + "expected": { + "state": "This is a remediate only standard. This means we cannot read the status, and always resolve it for all items" + }, + "checkBeforeRun": false, + "read": { + "cacheType": "Mailboxes", + "collectorArgs": { + "Types": "None" + } + }, + "prepare": "Get-CIPPBaselineSafeSendersDisableState", + "remediate": { + "executor": "ExoBulkSweep", + "writes": [ + { + "cmdlet": "Set-MailboxJunkEmailConfiguration", + "params": { + "Identity": "%id%", + "TrustedSendersAndDomains": null + } + } + ] + } +} diff --git a/backend/Config/BaselineStandards/Exchange Standards/calDefault.json b/backend/Config/BaselineStandards/Exchange Standards/calDefault.json new file mode 100644 index 0000000000..c787d69663 --- /dev/null +++ b/backend/Config/BaselineStandards/Exchange Standards/calDefault.json @@ -0,0 +1,108 @@ +{ + "name": "calDefault", + "label": "Set Sharing Level for Default calendar", + "cat": "Exchange Standards", + "tag": [], + "impact": "Low Impact", + "helpText": "Sets the default sharing level for the default calendar, for all users", + "executiveText": "Configures how much calendar information employees share by default with colleagues, balancing collaboration needs with privacy. This setting determines whether others can see meeting details, free/busy times, or just availability, helping optimize scheduling while protecting sensitive meeting information.", + "docsDescription": "Sets the default sharing level for the default calendar for all users in the tenant.", + "impactColour": "info", + "addedDate": "2023-03-14", + "powershellEquivalent": "Set-MailboxFolderPermission", + "recommendedBy": [], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ], + "secureScoreImpact": 0, + "compare": "subset", + "variables": { + "permissionLevel": { + "type": "autoComplete", + "multiple": false, + "label": "Select Sharing Level", + "required": true, + "options": [ + { + "label": "Owner - The user can create, read, edit, and delete all items in the folder, and create subfolders. The user is both folder owner and folder contact.", + "value": "Owner" + }, + { + "label": "Publishing Editor - The user can create, read, edit, and delete all items in the folder, and create subfolders.", + "value": "PublishingEditor" + }, + { + "label": "Editor - The user can create items in the folder. The contents of the folder do not appear.", + "value": "Editor" + }, + { + "label": "Publishing Author. The user can read, create all items/subfolders. Can modify and delete only items they create.", + "value": "PublishingAuthor" + }, + { + "label": "Author - The user can create and read items, and modify and delete items that they create.", + "value": "Author" + }, + { + "label": "Non Editing Author - The user has full read access and create items. Can can delete only own items.", + "value": "NonEditingAuthor" + }, + { + "label": "Reviewer - The user can read all items in the folder.", + "value": "Reviewer" + }, + { + "label": "Contributor - The user can create items and folders.", + "value": "Contributor" + }, + { + "label": "Availability Only - Indicates that the user can view only free/busy time within the calendar.", + "value": "AvailabilityOnly" + }, + { + "label": "Limited Details - The user can view free/busy time within the calendar and the subject and location of appointments.", + "value": "LimitedDetails" + }, + { + "label": "None - The user has no access to the folder.", + "value": "None" + } + ] + } + }, + "expected": { + "offenders": [] + }, + "read": { + "cacheType": "Mailboxes", + "collectorArgs": { + "Types": "CalendarPermissions" + } + }, + "prepare": "Get-CIPPBaselinecalDefaultState", + "remediate": { + "executor": "ExoBulkSweep", + "refreshCache": [ + "Mailboxes" + ], + "refreshCacheArgs": { + "Mailboxes": { + "Types": "CalendarPermissions" + } + }, + "writes": [ + { + "cmdlet": "Set-MailboxFolderPermission", + "params": { + "Identity": "%id%", + "User": "Default", + "AccessRights": "%permissionLevel%" + } + } + ] + } +} diff --git a/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineAutoAddProxyState.ps1 b/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineAutoAddProxyState.ps1 new file mode 100644 index 0000000000..987f7301e0 --- /dev/null +++ b/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineAutoAddProxyState.ps1 @@ -0,0 +1,47 @@ +function Get-CIPPBaselineAutoAddProxyState { + <# + .SYNOPSIS + Prepare hook for AutoAddProxy: mailboxes missing a proxy address for an accepted + domain. + .DESCRIPTION + A cross product, which no declarative read can express: every mailbox is checked + against every accepted domain, and one mailbox can be missing several. Each missing + (mailbox, domain) pair becomes its own target, so the sweep issues one Set-Mailbox per + pair exactly as the classic standard did. + + ExoAcceptedDomains is the second cache and goes through Get-CIPPBaselineCacheRows; + Mailboxes is the declared one and the engine collects it. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param($Item, $TenantFilter) + + $Mailboxes = @(New-CIPPDbRequest -TenantFilter $TenantFilter -Type 'Mailboxes' | Where-Object { $_ }) + if ($Mailboxes.Count -eq 0) { return @{ Current = $null } } + + $Domains = @((Get-CIPPBaselineCacheRows -TenantFilter $TenantFilter -Type 'ExoAcceptedDomains').DomainName | Where-Object { $_ }) + if ($Domains.Count -eq 0) { return @{ Current = $null } } + + $Missing = [System.Collections.Generic.List[object]]::new() + foreach ($Mailbox in $Mailboxes) { + $UPN = "$($Mailbox.UPN)" + if ([string]::IsNullOrWhiteSpace($UPN)) { continue } + $Addresses = @("$($Mailbox.primarySmtpAddress)") + if (-not [string]::IsNullOrWhiteSpace($Mailbox.AdditionalEmailAddresses)) { + $Addresses += @("$($Mailbox.AdditionalEmailAddresses)" -split ',\s*') + } + $LocalPart = ($UPN -split '@') | Select-Object -First 1 + foreach ($Domain in $Domains) { + if (@($Addresses | Where-Object { $_ -like "*@$Domain" }).Count -gt 0) { continue } + $Missing.Add([PSCustomObject]@{ id = $UPN; alias = "smtp:$LocalPart@$Domain"; display = "$UPN -> $Domain" }) + } + } + + @{ + Current = [PSCustomObject]@{ + offenders = @($Missing.display | Sort-Object) + targets = @($Missing | ForEach-Object { [PSCustomObject]@{ id = $_.id; alias = $_.alias } }) + } + } +} diff --git a/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineCacheRows.ps1 b/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineCacheRows.ps1 index e05eeacfab..e5a84fe557 100644 --- a/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineCacheRows.ps1 +++ b/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineCacheRows.ps1 @@ -17,6 +17,12 @@ function Get-CIPPBaselineCacheRows { Pass CollectorArgs for umbrella collectors whose default is their heaviest option, the same way a definition declares read.collectorArgs. + + CollectorType covers the types that have no collector NAMED after them because an + umbrella collector writes them - CalendarPermissions, MailboxPermissions and + MailboxRules are all produced by Set-CIPPDBCacheMailboxes under a -Types switch. + Without it the convention lookup finds nothing and the caller sees a permanently + empty set. .FUNCTIONALITY Internal #> @@ -26,15 +32,17 @@ function Get-CIPPBaselineCacheRows { [string]$TenantFilter, [Parameter(Mandatory = $true)] [string]$Type, + [string]$CollectorType, [hashtable]$CollectorArgs = @{} ) $Rows = @(New-CIPPDbRequest -TenantFilter $TenantFilter -Type $Type | Where-Object { $_ }) if ($Rows.Count -gt 0) { return $Rows } - $Collector = Get-Command -Name "Set-CIPPDBCache$Type" -ErrorAction SilentlyContinue + $CollectorFor = if ([string]::IsNullOrWhiteSpace($CollectorType)) { $Type } else { $CollectorType } + $Collector = Get-Command -Name "Set-CIPPDBCache$CollectorFor" -ErrorAction SilentlyContinue if (-not $Collector) { - Write-Information "Baselines: no collector exists for cache type $Type on $TenantFilter." + Write-Information "Baselines: no collector exists for cache type $CollectorFor on $TenantFilter." return @() } diff --git a/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineMailboxRecipientLimitsState.ps1 b/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineMailboxRecipientLimitsState.ps1 new file mode 100644 index 0000000000..042cc16981 --- /dev/null +++ b/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineMailboxRecipientLimitsState.ps1 @@ -0,0 +1,69 @@ +function Get-CIPPBaselineMailboxRecipientLimitsState { + <# + .SYNOPSIS + Prepare hook for MailboxRecipientLimits: mailboxes whose per-message recipient limit + is not the configured value. + .DESCRIPTION + Produces two graded sets, because two different things can be wrong and only one of + them is fixable here: + offenders - mailboxes whose limit differs and CAN be set to the configured value. + planIssues - mailboxes whose mailbox plan caps recipients BELOW the configured + value. Exchange rejects the write, so sweeping them would fail every + run forever. They are graded rather than hidden: the configuration is + wrong for those plans and an operator needs to see that, but the fix is + to lower the baseline's limit, not to retry the write. + + Plan caps come from ExoMailboxPlans, the second cache, joined on MailboxPlanId. + Discovery and system mailboxes are skipped exactly as the classic standard did. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Item, + $TenantFilter + ) + + $Mailboxes = @(New-CIPPDbRequest -TenantFilter $TenantFilter -Type 'Mailboxes' | Where-Object { $_ }) + if ($Mailboxes.Count -eq 0) { return @{ Current = $null } } + + $Plans = @(Get-CIPPBaselineCacheRows -TenantFilter $TenantFilter -Type 'ExoMailboxPlans') + $PlanCap = @{} + foreach ($Plan in $Plans) { + $Key = "$($Plan.Guid ?? $Plan.GUID)" + if ($Key) { $PlanCap[$Key] = $Plan } + } + + $Limit = [int]"$($Item.Variables.RecipientLimit)" + $Offenders = [System.Collections.Generic.List[object]]::new() + $PlanIssues = [System.Collections.Generic.List[object]]::new() + + foreach ($Mailbox in $Mailboxes) { + $UPN = "$($Mailbox.UPN)" + if ([string]::IsNullOrWhiteSpace($UPN)) { continue } + if ($UPN -like 'DiscoverySearchMailbox*' -or $UPN -like 'SystemMailbox*') { continue } + + $Plan = $PlanCap["$($Mailbox.MailboxPlanId)"] + $Cap = if ($Plan) { [int]"$($Plan.MaxRecipientsPerMessage)" } else { 0 } + if ($Plan -and $Cap -gt 0 -and $Limit -gt $Cap) { + $PlanIssues.Add("$UPN (plan $($Plan.DisplayName) caps at $Cap)") + continue + } + + # 'Unlimited' means the plan maximum, which is not the configured value unless the + # operator asked for exactly that. + $Current = "$($Mailbox.RecipientLimits)" + $Effective = if ($Current -eq 'Unlimited') { $Cap } else { $(try { [int]$Current } catch { -1 }) } + if ($Effective -ne $Limit) { + $Offenders.Add([PSCustomObject]@{ id = $UPN }) + } + } + + @{ + Current = [PSCustomObject]@{ + offenders = @($Offenders.id | Sort-Object) + planIssues = @($PlanIssues | Sort-Object) + targets = @($Offenders) + } + } +} diff --git a/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineQuarantineRequestAlertState.ps1 b/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineQuarantineRequestAlertState.ps1 new file mode 100644 index 0000000000..f41e2d37ad --- /dev/null +++ b/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineQuarantineRequestAlertState.ps1 @@ -0,0 +1,50 @@ +function Get-CIPPBaselineQuarantineRequestAlertState { + <# + .SYNOPSIS + Prepare hook for QuarantineRequestAlert, in either of its two modes. + .DESCRIPTION + The 'Allow extra addresses' switch decides what correct means, so it decides the shape + of the comparison too: + + on - the configured address must be ON the notify list, and anything else there is + somebody's deliberate addition. Graded as a single boolean, because an array + compare can only demand the lists match. This is what the classic standard did. + off - the notify list must be exactly the configured address. Graded as the list + itself, so the drift row names the recipients that should not be there. + + Absence of the alert is DRIFT, not No Data: the classic standard treated a missing + alert as incorrect and remediation creates it. Only an ExoProtectionAlert cache that + has never been collected is genuinely unknown. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Item, + $TenantFilter + ) + + $PolicyName = 'CIPP User requested to release a quarantined message' + $Configured = "$($Item.Variables.NotifyUser)" + $AllowExtra = "$($Item.Variables.AllowExtraAddresses)" -notin @('False', 'false', '0') + + $Alerts = @(New-CIPPDbRequest -TenantFilter $TenantFilter -Type 'ExoProtectionAlert' | Where-Object { $_ }) + if ($Alerts.Count -eq 0 -and -not (Test-CIPPBaselineCacheCollected -TenantFilter $TenantFilter -Type 'ExoProtectionAlert')) { + return @{ Current = $null } + } + + $Alert = @($Alerts | Where-Object { $_.Name -eq $PolicyName }) | Select-Object -First 1 + $Recipients = @(@($Alert.NotifyUser) | Where-Object { $_ }) + + if ($AllowExtra) { + return @{ + Expected = [PSCustomObject]@{ NotifyUserPresent = $true } + Current = [PSCustomObject]@{ NotifyUserPresent = [bool]($Recipients -contains $Configured) } + } + } + + @{ + Expected = [PSCustomObject]@{ NotifyUser = @($Configured) } + Current = [PSCustomObject]@{ NotifyUser = @($Recipients | Sort-Object) } + } +} diff --git a/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineSafeSendersDisableState.ps1 b/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineSafeSendersDisableState.ps1 new file mode 100644 index 0000000000..28f9a9e4bf --- /dev/null +++ b/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineSafeSendersDisableState.ps1 @@ -0,0 +1,35 @@ +function Get-CIPPBaselineSafeSendersDisableState { + <# + .SYNOPSIS + Prepare hook for SafeSendersDisable: an always-compliant state plus the mailbox list to + sweep. + .DESCRIPTION + A REMEDIATE-ONLY standard. Per-mailbox junk configuration is not cached anywhere and + reading it would cost one Get-MailboxJunkEmailConfiguration per mailbox on every + compare, so there is no state to grade. Rather than invent a verdict, the hook reports + the same constant on both sides: the row always reads compliant and says why. + + The work still happens - the definition sets checkBeforeRun false, so the engine writes + on every run where remediation is enabled regardless of the compare, and the sweep + clears TrustedSendersAndDomains for every mailbox. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Item, + $TenantFilter + ) + + $Explanation = 'This is a remediate only standard. This means we cannot read the status, and always resolve it for all items' + + $Mailboxes = @(New-CIPPDbRequest -TenantFilter $TenantFilter -Type 'Mailboxes' | Where-Object { $_ }) + + @{ + Expected = [PSCustomObject]@{ state = $Explanation } + Current = [PSCustomObject]@{ + state = $Explanation + targets = @($Mailboxes | Where-Object { $_.UPN } | ForEach-Object { [PSCustomObject]@{ id = "$($_.UPN)" } }) + } + } +} diff --git a/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselinecalDefaultState.ps1 b/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselinecalDefaultState.ps1 new file mode 100644 index 0000000000..59a2456e6d --- /dev/null +++ b/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselinecalDefaultState.ps1 @@ -0,0 +1,37 @@ +function Get-CIPPBaselinecalDefaultState { + <# + .SYNOPSIS + Prepare hook for calDefault: calendars whose Default permission is not the configured + level. + .DESCRIPTION + CalendarPermissions has no collector named after it - Set-CIPPDBCacheMailboxes writes + it under -Types CalendarPermissions - so the read goes through Get-CIPPBaselineCacheRows + with an explicit CollectorType. Without that the type would never be collected on a + tenant that has not run a full mailbox collection, and the standard would sit at No + Data forever. + + Only the 'Default' principal is graded; named delegates are somebody's deliberate + grant and are none of this standard's business. AccessRights arrives as an array on + some rows and a string on others, so it is joined before comparing. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param($Item, $TenantFilter) + + $Permissions = @(Get-CIPPBaselineCacheRows -TenantFilter $TenantFilter -Type 'CalendarPermissions' -CollectorType 'Mailboxes' -CollectorArgs @{ Types = 'CalendarPermissions' }) + if ($Permissions.Count -eq 0) { return @{ Current = $null } } + + $Level = "$($Item.Variables.permissionLevel)" + $Offending = @($Permissions | Where-Object { + $_.User -eq 'Default' -and + (($(if ($_.AccessRights -is [array]) { $_.AccessRights -join ',' } else { "$($_.AccessRights)" })) -ne $Level) + }) + + @{ + Current = [PSCustomObject]@{ + offenders = @($Offending.Identity | Sort-Object) + targets = @($Offending | ForEach-Object { [PSCustomObject]@{ id = "$($_.Identity)" } }) + } + } +} diff --git a/backend/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineQuarantineRequestAlert.ps1 b/backend/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineQuarantineRequestAlert.ps1 new file mode 100644 index 0000000000..8deed5164e --- /dev/null +++ b/backend/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineQuarantineRequestAlert.ps1 @@ -0,0 +1,71 @@ +function Invoke-CIPPBaselineQuarantineRequestAlert { + <# + .SYNOPSIS + QuarantineRequestAlert executor: creates or updates the quarantine release-request + alert without discarding recipients it did not add. + .DESCRIPTION + Needs its own executor because the recipient list it writes depends on the list already + there, and a rendered ExoRequest spec is fixed before it ever sees the tenant. + + With allowExtraAddresses set, the write is a MERGE: the configured address is added to + whatever is already on the alert, deduplicated, case-insensitively. Somebody who added + their own address by hand keeps it. Without it, the configured address is the whole + list and anything else is removed - the classic standard's behaviour. + + The existing list is read LIVE rather than from cache. A cached list can be hours old, + and merging into a stale one would silently drop a recipient added since the last + collection, which is precisely the loss the merge exists to prevent. + + Create-vs-update is decided the same way: the alert is looked up by name, and only + created when it genuinely is not there. Both cmdlets are Security & Compliance only. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Remediate, + $TenantFilter, + # The read result. Unused - the merge base has to be live, see above. + $Current + ) + + $PolicyName = 'CIPP User requested to release a quarantined message' + $Configured = "$($Remediate.notifyUser)" + if ([string]::IsNullOrWhiteSpace($Configured)) { throw 'QuarantineRequestAlert: no notify address configured to write.' } + $AllowExtra = [bool]($Remediate.allowExtraAddresses -eq $true) + + $Existing = $null + try { + $Existing = @(New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-ProtectionAlert' -Compliance | + Where-Object { $_.Name -eq $PolicyName }) | Select-Object -First 1 + } catch { + throw "QuarantineRequestAlert: could not read the existing alert to merge into: $($_.Exception.Message)" + } + + $Recipients = [System.Collections.Generic.List[string]]::new() + $Recipients.Add($Configured) + if ($AllowExtra) { + foreach ($Address in @($Existing.NotifyUser)) { + if ([string]::IsNullOrWhiteSpace($Address)) { continue } + if (@($Recipients | Where-Object { $_ -eq "$Address" }).Count -gt 0) { continue } + $Recipients.Add("$Address") + } + } + + $Parameters = @{ + Category = 'ThreatManagement' + Operation = 'QuarantineRequestReleaseMessage' + Severity = 'Informational' + AggregationType = 'None' + NotifyUser = @($Recipients) + } + + if ($Existing) { + $Parameters['Identity'] = $PolicyName + $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Set-ProtectionAlert' -Compliance -cmdParams $Parameters -useSystemMailbox $true + } else { + $Parameters['Name'] = $PolicyName + $Parameters['ThreatType'] = 'Activity' + $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'New-ProtectionAlert' -Compliance -cmdParams $Parameters -useSystemMailbox $true + } +} diff --git a/backend/Tests/Baselines/BaselineExecutors.Tests.ps1 b/backend/Tests/Baselines/BaselineExecutors.Tests.ps1 index 083c94bb97..5bac86e136 100644 --- a/backend/Tests/Baselines/BaselineExecutors.Tests.ps1 +++ b/backend/Tests/Baselines/BaselineExecutors.Tests.ps1 @@ -418,3 +418,73 @@ Describe 'Invoke-CIPPBaselineExoBulkSweep' { Should -Invoke Set-CIPPDBCacheMailboxes -Times 1 -ParameterFilter { $Types -eq 'None' } } } + +Describe 'Invoke-CIPPBaselineQuarantineRequestAlert' { + # The 'Allow extra addresses' switch decides whether the write preserves recipients it did + # not add. Getting this wrong silently deletes somebody's notification address. + BeforeAll { + . (Join-Path $Baselines 'Invoke-CIPPBaselineQuarantineRequestAlert.ps1') + $script:AlertName = 'CIPP User requested to release a quarantined message' + } + BeforeEach { + Mock New-ExoRequest { @([PSCustomObject]@{ Name = $script:AlertName; NotifyUser = @('soc@contoso.com', 'dpo@contoso.com') }) } -ParameterFilter { $cmdlet -eq 'Get-ProtectionAlert' } + Mock New-ExoRequest {} -ParameterFilter { $cmdlet -ne 'Get-ProtectionAlert' } + } + + It 'keeps recipients it did not add when extras are allowed' { + $Spec = @{ notifyUser = 'soc@contoso.com'; allowExtraAddresses = $true } | ConvertTo-Spec + Invoke-CIPPBaselineQuarantineRequestAlert -Remediate $Spec -TenantFilter $script:Tenant -Current $null + Should -Invoke New-ExoRequest -Times 1 -ParameterFilter { + $cmdlet -eq 'Set-ProtectionAlert' -and + @($cmdParams['NotifyUser']).Count -eq 2 -and + @($cmdParams['NotifyUser']) -contains 'dpo@contoso.com' + } + } + + It 'adds the configured address when it is missing, without dropping the others' { + Mock New-ExoRequest { @([PSCustomObject]@{ Name = $script:AlertName; NotifyUser = @('dpo@contoso.com') }) } -ParameterFilter { $cmdlet -eq 'Get-ProtectionAlert' } + $Spec = @{ notifyUser = 'soc@contoso.com'; allowExtraAddresses = $true } | ConvertTo-Spec + Invoke-CIPPBaselineQuarantineRequestAlert -Remediate $Spec -TenantFilter $script:Tenant -Current $null + Should -Invoke New-ExoRequest -Times 1 -ParameterFilter { + $cmdlet -eq 'Set-ProtectionAlert' -and + @($cmdParams['NotifyUser']) -contains 'soc@contoso.com' -and + @($cmdParams['NotifyUser']) -contains 'dpo@contoso.com' + } + } + + It 'does not duplicate the configured address when it is already present' { + $Spec = @{ notifyUser = 'soc@contoso.com'; allowExtraAddresses = $true } | ConvertTo-Spec + Invoke-CIPPBaselineQuarantineRequestAlert -Remediate $Spec -TenantFilter $script:Tenant -Current $null + Should -Invoke New-ExoRequest -Times 1 -ParameterFilter { + $cmdlet -eq 'Set-ProtectionAlert' -and + @(@($cmdParams['NotifyUser']) | Where-Object { $_ -eq 'soc@contoso.com' }).Count -eq 1 + } + } + + It 'enforces the configured address as the only recipient when extras are not allowed' { + $Spec = @{ notifyUser = 'soc@contoso.com'; allowExtraAddresses = $false } | ConvertTo-Spec + Invoke-CIPPBaselineQuarantineRequestAlert -Remediate $Spec -TenantFilter $script:Tenant -Current $null + Should -Invoke New-ExoRequest -Times 1 -ParameterFilter { + $cmdlet -eq 'Set-ProtectionAlert' -and + @($cmdParams['NotifyUser']).Count -eq 1 -and + @($cmdParams['NotifyUser'])[0] -eq 'soc@contoso.com' + } + } + + It 'creates the alert when it does not exist yet' { + Mock New-ExoRequest { @() } -ParameterFilter { $cmdlet -eq 'Get-ProtectionAlert' } + $Spec = @{ notifyUser = 'soc@contoso.com'; allowExtraAddresses = $true } | ConvertTo-Spec + Invoke-CIPPBaselineQuarantineRequestAlert -Remediate $Spec -TenantFilter $script:Tenant -Current $null + Should -Invoke New-ExoRequest -Times 1 -ParameterFilter { + $cmdlet -eq 'New-ProtectionAlert' -and $cmdParams['ThreatType'] -eq 'Activity' -and $cmdParams['Name'] -eq $script:AlertName + } + } + + It 'refuses to write if the existing alert cannot be read' { + # Merging into a list we failed to read would delete whatever was on it. + Mock New-ExoRequest { throw 'compliance endpoint unavailable' } -ParameterFilter { $cmdlet -eq 'Get-ProtectionAlert' } + $Spec = @{ notifyUser = 'soc@contoso.com'; allowExtraAddresses = $true } | ConvertTo-Spec + { Invoke-CIPPBaselineQuarantineRequestAlert -Remediate $Spec -TenantFilter $script:Tenant -Current $null } | Should -Throw '*could not read the existing alert*' + Should -Invoke New-ExoRequest -Times 0 -ParameterFilter { $cmdlet -eq 'Set-ProtectionAlert' } + } +} diff --git a/backend/Tests/Baselines/BaselinePrepareHooks.Tests.ps1 b/backend/Tests/Baselines/BaselinePrepareHooks.Tests.ps1 index 75a193c505..57544d1d27 100644 --- a/backend/Tests/Baselines/BaselinePrepareHooks.Tests.ps1 +++ b/backend/Tests/Baselines/BaselinePrepareHooks.Tests.ps1 @@ -265,3 +265,45 @@ Describe 'Empty-but-collected caches' { (Get-CIPPBaselineTeamsDisableResourceAccountsState -Item $null -TenantFilter $script:Tenant).Current | Should -BeNullOrEmpty } } + +Describe 'Get-CIPPBaselineQuarantineRequestAlertState' { + # The classic standard graded this with -contains: correct as long as the configured + # address is ON the notify list. Recipients an operator added by hand are left alone. + # An exact array compare would strip them, which is a behaviour change this must not make. + BeforeAll { + . (Join-Path $Baselines 'Test-CIPPBaselineCacheCollected.ps1') + . (Join-Path $Baselines 'Get-CIPPBaselineQuarantineRequestAlertState.ps1') + function Get-CIPPDbItem { param($TenantFilter, $Type, [switch]$CountsOnly) } + $script:AlertName = 'CIPP User requested to release a quarantined message' + $script:Item = [PSCustomObject]@{ Variables = [PSCustomObject]@{ NotifyUser = 'soc@contoso.com' } } + } + BeforeEach { Mock Get-CIPPDbItem { [PSCustomObject]@{ RowKey = 'ExoProtectionAlert-Count'; DataCount = 1 } } } + + It 'is compliant when the configured address is the only recipient' { + Mock New-CIPPDbRequest { @(@{ Name = $script:AlertName; NotifyUser = @('soc@contoso.com') } | ConvertTo-Cached) } + (Get-CIPPBaselineQuarantineRequestAlertState -Item $script:Item -TenantFilter $script:Tenant).Current.NotifyUserPresent | Should -BeTrue + } + + It 'tolerates extra recipients rather than reporting drift on them' { + Mock New-CIPPDbRequest { @(@{ Name = $script:AlertName; NotifyUser = @('soc@contoso.com', 'dpo@contoso.com') } | ConvertTo-Cached) } + (Get-CIPPBaselineQuarantineRequestAlertState -Item $script:Item -TenantFilter $script:Tenant).Current.NotifyUserPresent | Should -BeTrue + } + + It 'reports drift when the configured address is absent' { + Mock New-CIPPDbRequest { @(@{ Name = $script:AlertName; NotifyUser = @('dpo@contoso.com') } | ConvertTo-Cached) } + (Get-CIPPBaselineQuarantineRequestAlertState -Item $script:Item -TenantFilter $script:Tenant).Current.NotifyUserPresent | Should -BeFalse + } + + It 'treats a missing alert as drift so remediation creates it' { + Mock New-CIPPDbRequest { @(@{ Name = 'some other alert'; NotifyUser = @('x@y.com') } | ConvertTo-Cached) } + $Prepared = Get-CIPPBaselineQuarantineRequestAlertState -Item $script:Item -TenantFilter $script:Tenant + $Prepared.Current | Should -Not -BeNullOrEmpty + $Prepared.Current.NotifyUserPresent | Should -BeFalse + } + + It 'reports unknown only when the alert cache has never been collected' { + Mock New-CIPPDbRequest { @() } + Mock Get-CIPPDbItem { $null } + (Get-CIPPBaselineQuarantineRequestAlertState -Item $script:Item -TenantFilter $script:Tenant).Current | Should -BeNullOrEmpty + } +} From 1ab4046949f1cdd87234a832f725b60790968e4d Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Sun, 16 Aug 2026 01:00:10 +0200 Subject: [PATCH 083/226] more baseline conversion --- .../Defender Standards/AntiPhishPolicy.json | 282 ++++++++++++ .../MalwareFilterPolicy.json | 129 ++++++ .../SafeAttachmentPolicy.json | 131 ++++++ .../Defender Standards/SafeLinksPolicy.json | 99 +++++ .../Defender Standards/SpamFilterPolicy.json | 410 ++++++++++++++++++ backend/Config/openapi.json | 329 ++++++++++++++ .../Get-CIPPBaselineAntiPhishPolicyState.ps1 | 126 ++++++ ...t-CIPPBaselineMalwareFilterPolicyState.ps1 | 100 +++++ ...-CIPPBaselineSafeAttachmentPolicyState.ps1 | 94 ++++ .../Get-CIPPBaselineSafeLinksPolicyState.ps1 | 92 ++++ .../Get-CIPPBaselineSpamFilterPolicyState.ps1 | 166 +++++++ .../Invoke-CIPPBaselineExoPolicyRule.ps1 | 75 ++++ .../Baselines/BaselinePrepareHooks.Tests.ps1 | 60 +++ 13 files changed, 2093 insertions(+) create mode 100644 backend/Config/BaselineStandards/Defender Standards/AntiPhishPolicy.json create mode 100644 backend/Config/BaselineStandards/Defender Standards/MalwareFilterPolicy.json create mode 100644 backend/Config/BaselineStandards/Defender Standards/SafeAttachmentPolicy.json create mode 100644 backend/Config/BaselineStandards/Defender Standards/SafeLinksPolicy.json create mode 100644 backend/Config/BaselineStandards/Defender Standards/SpamFilterPolicy.json create mode 100644 backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineAntiPhishPolicyState.ps1 create mode 100644 backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineMalwareFilterPolicyState.ps1 create mode 100644 backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineSafeAttachmentPolicyState.ps1 create mode 100644 backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineSafeLinksPolicyState.ps1 create mode 100644 backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineSpamFilterPolicyState.ps1 create mode 100644 backend/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineExoPolicyRule.ps1 diff --git a/backend/Config/BaselineStandards/Defender Standards/AntiPhishPolicy.json b/backend/Config/BaselineStandards/Defender Standards/AntiPhishPolicy.json new file mode 100644 index 0000000000..ba61502c78 --- /dev/null +++ b/backend/Config/BaselineStandards/Defender Standards/AntiPhishPolicy.json @@ -0,0 +1,282 @@ +{ + "name": "AntiPhishPolicy", + "label": "Default Anti-Phishing Policy", + "cat": "Defender Standards", + "tag": [ + "CIS M365 7.0.0 (2.1.7)", + "mdo_antiphishingpolicy", + "NIST CSF 2.0 (DE.CM-09)" + ], + "impact": "Low Impact", + "helpText": "This creates an Anti-Phishing policy. On tenants without Defender for Office 365 only the settings that exist there are graded; impersonation and mailbox-intelligence protection are skipped.", + "executiveText": "Detects attempts to impersonate staff and partner domains, and warns employees about unusual or first-contact senders. This is the main defence against business email compromise.", + "docsDescription": "Creates or updates the Anti-Phishing policy and the rule that scopes it to every accepted domain. The graded property set depends on tenant licensing.", + "impactColour": "info", + "addedDate": "2024-03-25", + "powershellEquivalent": "Set-AntiPhishPolicy or New-AntiPhishPolicy", + "appliesToTest": [ + "CIS_2_1_7" + ], + "recommendedBy": [ + "CIS" + ], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ], + "secureScoreImpact": 0, + "compare": "subset", + "variables": { + "name": { + "type": "textField", + "label": "Policy Name", + "required": true, + "default": "CIPP Default Anti-Phishing Policy" + }, + "PhishThresholdLevel": { + "type": "select", + "multiple": false, + "label": "Phishing Threshold Level", + "required": true, + "options": [ + { + "label": "1", + "value": "1" + }, + { + "label": "2", + "value": "2" + }, + { + "label": "3", + "value": "3" + }, + { + "label": "4", + "value": "4" + } + ], + "default": "3" + }, + "EnableFirstContactSafetyTips": { + "type": "switch", + "label": "First contact safety tips", + "default": true + }, + "EnableSimilarUsersSafetyTips": { + "type": "switch", + "label": "Similar users safety tips", + "default": true + }, + "EnableSimilarDomainsSafetyTips": { + "type": "switch", + "label": "Similar domains safety tips", + "default": true + }, + "EnableUnusualCharactersSafetyTips": { + "type": "switch", + "label": "Unusual characters safety tips", + "default": true + }, + "AuthenticationFailAction": { + "type": "select", + "multiple": false, + "label": "Authentication fail action", + "required": true, + "options": [ + { + "label": "MoveToJmf", + "value": "MoveToJmf" + }, + { + "label": "Quarantine", + "value": "Quarantine" + } + ], + "default": "MoveToJmf" + }, + "SpoofQuarantineTag": { + "type": "select", + "multiple": false, + "label": "Spoof quarantine tag", + "required": true, + "options": [ + { + "label": "AdminOnlyAccessPolicy", + "value": "AdminOnlyAccessPolicy" + }, + { + "label": "DefaultFullAccessPolicy", + "value": "DefaultFullAccessPolicy" + }, + { + "label": "DefaultFullAccessWithNotificationPolicy", + "value": "DefaultFullAccessWithNotificationPolicy" + } + ], + "default": "DefaultFullAccessPolicy" + }, + "MailboxIntelligenceProtectionAction": { + "type": "select", + "multiple": false, + "label": "Mailbox intelligence protection action", + "required": true, + "options": [ + { + "label": "NoAction", + "value": "NoAction" + }, + { + "label": "MoveToJmf", + "value": "MoveToJmf" + }, + { + "label": "Delete", + "value": "Delete" + }, + { + "label": "Quarantine", + "value": "Quarantine" + } + ], + "default": "Quarantine" + }, + "MailboxIntelligenceQuarantineTag": { + "type": "select", + "multiple": false, + "label": "Mailbox intelligence quarantine tag", + "required": true, + "options": [ + { + "label": "AdminOnlyAccessPolicy", + "value": "AdminOnlyAccessPolicy" + }, + { + "label": "DefaultFullAccessPolicy", + "value": "DefaultFullAccessPolicy" + }, + { + "label": "DefaultFullAccessWithNotificationPolicy", + "value": "DefaultFullAccessWithNotificationPolicy" + } + ], + "default": "DefaultFullAccessPolicy" + }, + "TargetedUserProtectionAction": { + "type": "select", + "multiple": false, + "label": "Targeted user protection action", + "required": true, + "options": [ + { + "label": "NoAction", + "value": "NoAction" + }, + { + "label": "MoveToJmf", + "value": "MoveToJmf" + }, + { + "label": "Delete", + "value": "Delete" + }, + { + "label": "Quarantine", + "value": "Quarantine" + } + ], + "default": "Quarantine" + }, + "TargetedUserQuarantineTag": { + "type": "select", + "multiple": false, + "label": "Targeted user quarantine tag", + "required": true, + "options": [ + { + "label": "AdminOnlyAccessPolicy", + "value": "AdminOnlyAccessPolicy" + }, + { + "label": "DefaultFullAccessPolicy", + "value": "DefaultFullAccessPolicy" + }, + { + "label": "DefaultFullAccessWithNotificationPolicy", + "value": "DefaultFullAccessWithNotificationPolicy" + } + ], + "default": "DefaultFullAccessPolicy" + }, + "TargetedDomainProtectionAction": { + "type": "select", + "multiple": false, + "label": "Targeted domain protection action", + "required": true, + "options": [ + { + "label": "NoAction", + "value": "NoAction" + }, + { + "label": "MoveToJmf", + "value": "MoveToJmf" + }, + { + "label": "Delete", + "value": "Delete" + }, + { + "label": "Quarantine", + "value": "Quarantine" + } + ], + "default": "Quarantine" + }, + "TargetedDomainQuarantineTag": { + "type": "select", + "multiple": false, + "label": "Targeted domain quarantine tag", + "required": true, + "options": [ + { + "label": "AdminOnlyAccessPolicy", + "value": "AdminOnlyAccessPolicy" + }, + { + "label": "DefaultFullAccessPolicy", + "value": "DefaultFullAccessPolicy" + }, + { + "label": "DefaultFullAccessWithNotificationPolicy", + "value": "DefaultFullAccessWithNotificationPolicy" + } + ], + "default": "DefaultFullAccessPolicy" + } + }, + "read": { + "cacheType": "ExoAntiPhishPolicies" + }, + "prepare": "Get-CIPPBaselineAntiPhishPolicyState", + "remediate": { + "executor": "ExoPolicyRule", + "policyCmdlet": "AntiPhishPolicy", + "ruleCmdlet": "AntiPhishRule", + "policyParams": { + "Enabled": true, + "EnableSpoofIntelligence": true, + "EnableUnauthenticatedSender": true, + "EnableViaTag": true, + "EnableFirstContactSafetyTips": "%EnableFirstContactSafetyTips%", + "AuthenticationFailAction": "%AuthenticationFailAction%", + "SpoofQuarantineTag": "%SpoofQuarantineTag%" + }, + "ruleParams": { + "Priority": 0 + } + } +} diff --git a/backend/Config/BaselineStandards/Defender Standards/MalwareFilterPolicy.json b/backend/Config/BaselineStandards/Defender Standards/MalwareFilterPolicy.json new file mode 100644 index 0000000000..ca9397d286 --- /dev/null +++ b/backend/Config/BaselineStandards/Defender Standards/MalwareFilterPolicy.json @@ -0,0 +1,129 @@ +{ + "name": "MalwareFilterPolicy", + "label": "Default Malware Filter Policy", + "cat": "Defender Standards", + "tag": [ + "CIS M365 7.0.0 (2.1.2)", + "mdo_commonattachmentsfilter", + "mdo_zapmalware", + "NIST CSF 2.0 (DE.CM-09)" + ], + "impact": "Low Impact", + "helpText": "This creates a Malware filter policy. A custom policy name is taken literally; only the CIPP default name adopts an existing Microsoft default policy.", + "executiveText": "Blocks dangerous file types before they reach employee mailboxes and removes malware already delivered. This reduces the chance of a ransomware infection starting from an email attachment.", + "docsDescription": "Creates or updates the Malware filter policy and the rule that scopes it to every accepted domain.", + "impactColour": "info", + "addedDate": "2024-03-25", + "powershellEquivalent": "Set-MalwareFilterPolicy or New-MalwareFilterPolicy", + "appliesToTest": [ + "CIS_2_1_2" + ], + "recommendedBy": [ + "CIS" + ], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ], + "secureScoreImpact": 0, + "compare": "subset", + "variables": { + "name": { + "type": "textField", + "label": "Policy Name", + "required": true, + "default": "CIPP Default Malware Policy" + }, + "FileTypeAction": { + "type": "select", + "multiple": false, + "label": "File Type Action", + "required": true, + "options": [ + { + "label": "Quarantine", + "value": "Quarantine" + }, + { + "label": "Reject", + "value": "Reject" + } + ], + "default": "Quarantine" + }, + "QuarantineTag": { + "type": "select", + "multiple": false, + "label": "Quarantine Tag", + "required": true, + "options": [ + { + "label": "AdminOnlyAccessPolicy", + "value": "AdminOnlyAccessPolicy" + }, + { + "label": "DefaultFullAccessPolicy", + "value": "DefaultFullAccessPolicy" + }, + { + "label": "DefaultFullAccessWithNotificationPolicy", + "value": "DefaultFullAccessWithNotificationPolicy" + } + ], + "default": "AdminOnlyAccessPolicy" + }, + "OptionalFileTypes": { + "type": "textField", + "label": "Optional File Types, Comma separated", + "omitWhenBlank": true, + "default": "" + }, + "EnableInternalSenderAdminNotifications": { + "type": "switch", + "label": "Notify admins about internal senders", + "default": false + }, + "InternalSenderAdminAddress": { + "type": "textField", + "label": "Internal sender admin address", + "omitWhenBlank": true, + "default": "" + }, + "EnableExternalSenderAdminNotifications": { + "type": "switch", + "label": "Notify admins about external senders", + "default": false + }, + "ExternalSenderAdminAddress": { + "type": "textField", + "label": "External sender admin address", + "omitWhenBlank": true, + "default": "" + } + }, + "read": { + "cacheType": "ExoMalwareFilterPolicies" + }, + "prepare": "Get-CIPPBaselineMalwareFilterPolicyState", + "remediate": { + "executor": "ExoPolicyRule", + "policyCmdlet": "MalwareFilterPolicy", + "ruleCmdlet": "MalwareFilterRule", + "policyParams": { + "EnableFileFilter": true, + "FileTypeAction": "%FileTypeAction%", + "ZapEnabled": true, + "QuarantineTag": "%QuarantineTag%", + "EnableInternalSenderAdminNotifications": "%EnableInternalSenderAdminNotifications%", + "InternalSenderAdminAddress": "%InternalSenderAdminAddress%", + "EnableExternalSenderAdminNotifications": "%EnableExternalSenderAdminNotifications%", + "ExternalSenderAdminAddress": "%ExternalSenderAdminAddress%" + }, + "ruleParams": { + "Priority": 0 + } + } +} diff --git a/backend/Config/BaselineStandards/Defender Standards/SafeAttachmentPolicy.json b/backend/Config/BaselineStandards/Defender Standards/SafeAttachmentPolicy.json new file mode 100644 index 0000000000..2ddc6d1340 --- /dev/null +++ b/backend/Config/BaselineStandards/Defender Standards/SafeAttachmentPolicy.json @@ -0,0 +1,131 @@ +{ + "name": "SafeAttachmentPolicy", + "label": "Default Safe Attachment Policy", + "cat": "Defender Standards", + "tag": [ + "CIS M365 7.0.0 (2.1.4)", + "mdo_safedocuments", + "mdo_commonattachmentsfilter", + "mdo_safeattachmentpolicy", + "NIST CSF 2.0 (DE.CM-09)" + ], + "impact": "Low Impact", + "helpText": "This creates a Safe Attachment policy. An existing policy carrying a legacy CIPP or Microsoft default name is adopted and updated rather than duplicated.", + "executiveText": "Scans email attachments in a secure environment before delivery, blocking malicious files that traditional filters miss. This protects employees from malware and ransomware delivered through email attachments.", + "docsDescription": "Creates or updates the Safe Attachment policy and the rule that scopes it to every accepted domain.", + "impactColour": "info", + "addedDate": "2024-03-25", + "powershellEquivalent": "Set-SafeAttachmentPolicy or New-SafeAttachmentPolicy", + "appliesToTest": [ + "CIS_2_1_4", + "ORCA158", + "ORCA189", + "ORCA227" + ], + "recommendedBy": [ + "CIS" + ], + "requiredCapabilities": [ + [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ], + [ + "ATP_ENTERPRISE", + "ATP_ENTERPRISE_GOV", + "THREAT_INTELLIGENCE", + "THREAT_INTELLIGENCE_GOV" + ] + ], + "secureScoreImpact": 0, + "compare": "subset", + "variables": { + "name": { + "type": "textField", + "label": "Policy Name", + "required": true, + "default": "CIPP Default Safe Attachment Policy" + }, + "SafeAttachmentAction": { + "type": "select", + "multiple": false, + "label": "Safe Attachment Action", + "required": true, + "options": [ + { + "label": "Allow", + "value": "Allow" + }, + { + "label": "Block", + "value": "Block" + }, + { + "label": "DynamicDelivery", + "value": "DynamicDelivery" + } + ], + "default": "Block" + }, + "QuarantineTag": { + "type": "select", + "multiple": false, + "creatable": true, + "label": "QuarantineTag", + "required": true, + "options": [ + { + "label": "AdminOnlyAccessPolicy", + "value": "AdminOnlyAccessPolicy" + }, + { + "label": "DefaultFullAccessPolicy", + "value": "DefaultFullAccessPolicy" + }, + { + "label": "DefaultFullAccessWithNotificationPolicy", + "value": "DefaultFullAccessWithNotificationPolicy" + } + ], + "default": "AdminOnlyAccessPolicy" + }, + "Redirect": { + "type": "switch", + "label": "Redirect", + "default": false + }, + "RedirectAddress": { + "type": "textField", + "label": "Redirect Address", + "omitWhenBlank": true, + "default": "", + "condition": { + "field": "standards.SafeAttachmentPolicy.Redirect", + "compareType": "is", + "compareValue": true + } + } + }, + "read": { + "cacheType": "ExoSafeAttachmentPolicies" + }, + "prepare": "Get-CIPPBaselineSafeAttachmentPolicyState", + "remediate": { + "executor": "ExoPolicyRule", + "policyCmdlet": "SafeAttachmentPolicy", + "ruleCmdlet": "SafeAttachmentRule", + "policyParams": { + "Enable": true, + "Action": "%SafeAttachmentAction%", + "QuarantineTag": "%QuarantineTag%", + "Redirect": "%Redirect%", + "RedirectAddress": "%RedirectAddress%" + }, + "ruleParams": { + "Priority": 0 + } + } +} diff --git a/backend/Config/BaselineStandards/Defender Standards/SafeLinksPolicy.json b/backend/Config/BaselineStandards/Defender Standards/SafeLinksPolicy.json new file mode 100644 index 0000000000..0fba393249 --- /dev/null +++ b/backend/Config/BaselineStandards/Defender Standards/SafeLinksPolicy.json @@ -0,0 +1,99 @@ +{ + "name": "SafeLinksPolicy", + "label": "Default SafeLinks Policy", + "cat": "Defender Standards", + "tag": [ + "CIS M365 7.0.0 (2.1.1)", + "mdo_safelinksforemail", + "mdo_safelinksforOfficeApps", + "NIST CSF 2.0 (DE.CM-09)" + ], + "impact": "Low Impact", + "helpText": "This creates a SafeLinks policy. An existing policy carrying a legacy CIPP or Microsoft default name is adopted and updated rather than duplicated.", + "executiveText": "Scans links in email and Office documents at click time, blocking known-malicious destinations even when the link was safe at delivery. This protects employees from phishing sites that go live after the message arrives.", + "docsDescription": "Creates or updates the SafeLinks policy and the rule that scopes it to every accepted domain.", + "impactColour": "info", + "addedDate": "2024-03-25", + "powershellEquivalent": "Set-SafeLinksPolicy or New-SafeLinksPolicy", + "appliesToTest": [ + "CIS_2_1_1" + ], + "recommendedBy": [ + "CIS" + ], + "requiredCapabilities": [ + [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ], + [ + "ATP_ENTERPRISE", + "ATP_ENTERPRISE_GOV", + "THREAT_INTELLIGENCE", + "THREAT_INTELLIGENCE_GOV" + ] + ], + "secureScoreImpact": 0, + "compare": "subset", + "variables": { + "name": { + "type": "textField", + "label": "Policy Name", + "required": true, + "default": "CIPP Default SafeLinks Policy" + }, + "AllowClickThrough": { + "type": "switch", + "label": "Allow users to click through to the original URL", + "default": false, + "recommended": false + }, + "DisableUrlRewrite": { + "type": "switch", + "label": "Disable URL rewriting", + "default": false, + "recommended": false + }, + "EnableOrganizationBranding": { + "type": "switch", + "label": "Enable organization branding on notification pages", + "default": false + }, + "DoNotRewriteUrls": { + "type": "autoComplete", + "multiple": true, + "creatable": true, + "label": "Do not rewrite the following URLs", + "omitWhenBlank": true, + "default": "" + } + }, + "read": { + "cacheType": "ExoSafeLinksPolicies" + }, + "prepare": "Get-CIPPBaselineSafeLinksPolicyState", + "remediate": { + "executor": "ExoPolicyRule", + "policyCmdlet": "SafeLinksPolicy", + "ruleCmdlet": "SafeLinksRule", + "policyParams": { + "EnableSafeLinksForEmail": true, + "EnableSafeLinksForTeams": true, + "EnableSafeLinksForOffice": true, + "TrackClicks": true, + "ScanUrls": true, + "EnableForInternalSenders": true, + "DeliverMessageAfterScan": true, + "AllowClickThrough": "%AllowClickThrough%", + "DisableUrlRewrite": "%DisableUrlRewrite%", + "EnableOrganizationBranding": "%EnableOrganizationBranding%", + "DoNotRewriteUrls": "%DoNotRewriteUrls%" + }, + "ruleParams": { + "Priority": 0 + } + } +} diff --git a/backend/Config/BaselineStandards/Defender Standards/SpamFilterPolicy.json b/backend/Config/BaselineStandards/Defender Standards/SpamFilterPolicy.json new file mode 100644 index 0000000000..165fade56c --- /dev/null +++ b/backend/Config/BaselineStandards/Defender Standards/SpamFilterPolicy.json @@ -0,0 +1,410 @@ +{ + "name": "SpamFilterPolicy", + "label": "Default Spam Filter Policy", + "cat": "Defender Standards", + "tag": [ + "CIS M365 7.0.0 (2.1.6)", + "mdo_spamfilterpolicy", + "NIST CSF 2.0 (DE.CM-09)" + ], + "impact": "Low Impact", + "helpText": "This creates a Spam filter policy. When the adopted policy is the built-in \"Default\", Exchange owns its scoping and no rule is graded or written.", + "executiveText": "Sets how suspected spam, bulk mail and phishing are handled, and where those messages are quarantined. Consistent settings reduce both nuisance mail and the chance a malicious message reaches an inbox.", + "docsDescription": "Creates or updates the Hosted Content Filter policy and the rule that scopes it to every accepted domain.", + "impactColour": "info", + "addedDate": "2024-03-25", + "powershellEquivalent": "Set-HostedContentFilterPolicy or New-HostedContentFilterPolicy", + "appliesToTest": [ + "CIS_2_1_6" + ], + "recommendedBy": [ + "CIS" + ], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ], + "secureScoreImpact": 0, + "compare": "subset", + "variables": { + "name": { + "type": "textField", + "label": "Policy Name", + "required": true, + "default": "CIPP Default Spam Filter Policy" + }, + "SpamAction": { + "type": "select", + "multiple": false, + "label": "Spam action", + "required": true, + "options": [ + { + "label": "MoveToJmf", + "value": "MoveToJmf" + }, + { + "label": "AddXHeader", + "value": "AddXHeader" + }, + { + "label": "ModifySubject", + "value": "ModifySubject" + }, + { + "label": "Redirect", + "value": "Redirect" + }, + { + "label": "Delete", + "value": "Delete" + }, + { + "label": "Quarantine", + "value": "Quarantine" + }, + { + "label": "NoAction", + "value": "NoAction" + } + ], + "default": "MoveToJmf" + }, + "SpamQuarantineTag": { + "type": "select", + "multiple": false, + "label": "Spam quarantine tag", + "required": true, + "options": [ + { + "label": "AdminOnlyAccessPolicy", + "value": "AdminOnlyAccessPolicy" + }, + { + "label": "DefaultFullAccessPolicy", + "value": "DefaultFullAccessPolicy" + }, + { + "label": "DefaultFullAccessWithNotificationPolicy", + "value": "DefaultFullAccessWithNotificationPolicy" + } + ], + "default": "DefaultFullAccessPolicy" + }, + "HighConfidenceSpamAction": { + "type": "select", + "multiple": false, + "label": "High confidence spam action", + "required": true, + "options": [ + { + "label": "MoveToJmf", + "value": "MoveToJmf" + }, + { + "label": "AddXHeader", + "value": "AddXHeader" + }, + { + "label": "ModifySubject", + "value": "ModifySubject" + }, + { + "label": "Redirect", + "value": "Redirect" + }, + { + "label": "Delete", + "value": "Delete" + }, + { + "label": "Quarantine", + "value": "Quarantine" + }, + { + "label": "NoAction", + "value": "NoAction" + } + ], + "default": "Quarantine" + }, + "HighConfidenceSpamQuarantineTag": { + "type": "select", + "multiple": false, + "label": "High confidence spam quarantine tag", + "required": true, + "options": [ + { + "label": "AdminOnlyAccessPolicy", + "value": "AdminOnlyAccessPolicy" + }, + { + "label": "DefaultFullAccessPolicy", + "value": "DefaultFullAccessPolicy" + }, + { + "label": "DefaultFullAccessWithNotificationPolicy", + "value": "DefaultFullAccessWithNotificationPolicy" + } + ], + "default": "AdminOnlyAccessPolicy" + }, + "BulkSpamAction": { + "type": "select", + "multiple": false, + "label": "Bulk spam action", + "required": true, + "options": [ + { + "label": "MoveToJmf", + "value": "MoveToJmf" + }, + { + "label": "AddXHeader", + "value": "AddXHeader" + }, + { + "label": "ModifySubject", + "value": "ModifySubject" + }, + { + "label": "Redirect", + "value": "Redirect" + }, + { + "label": "Delete", + "value": "Delete" + }, + { + "label": "Quarantine", + "value": "Quarantine" + }, + { + "label": "NoAction", + "value": "NoAction" + } + ], + "default": "MoveToJmf" + }, + "BulkQuarantineTag": { + "type": "select", + "multiple": false, + "label": "Bulk quarantine tag", + "required": true, + "options": [ + { + "label": "AdminOnlyAccessPolicy", + "value": "AdminOnlyAccessPolicy" + }, + { + "label": "DefaultFullAccessPolicy", + "value": "DefaultFullAccessPolicy" + }, + { + "label": "DefaultFullAccessWithNotificationPolicy", + "value": "DefaultFullAccessWithNotificationPolicy" + } + ], + "default": "DefaultFullAccessPolicy" + }, + "PhishSpamAction": { + "type": "select", + "multiple": false, + "label": "Phish action", + "required": true, + "options": [ + { + "label": "MoveToJmf", + "value": "MoveToJmf" + }, + { + "label": "AddXHeader", + "value": "AddXHeader" + }, + { + "label": "ModifySubject", + "value": "ModifySubject" + }, + { + "label": "Redirect", + "value": "Redirect" + }, + { + "label": "Delete", + "value": "Delete" + }, + { + "label": "Quarantine", + "value": "Quarantine" + }, + { + "label": "NoAction", + "value": "NoAction" + } + ], + "default": "Quarantine" + }, + "PhishQuarantineTag": { + "type": "select", + "multiple": false, + "label": "Phish quarantine tag", + "required": true, + "options": [ + { + "label": "AdminOnlyAccessPolicy", + "value": "AdminOnlyAccessPolicy" + }, + { + "label": "DefaultFullAccessPolicy", + "value": "DefaultFullAccessPolicy" + }, + { + "label": "DefaultFullAccessWithNotificationPolicy", + "value": "DefaultFullAccessWithNotificationPolicy" + } + ], + "default": "AdminOnlyAccessPolicy" + }, + "HighConfidencePhishQuarantineTag": { + "type": "select", + "multiple": false, + "label": "High confidence phish quarantine tag", + "required": true, + "options": [ + { + "label": "AdminOnlyAccessPolicy", + "value": "AdminOnlyAccessPolicy" + }, + { + "label": "DefaultFullAccessPolicy", + "value": "DefaultFullAccessPolicy" + }, + { + "label": "DefaultFullAccessWithNotificationPolicy", + "value": "DefaultFullAccessWithNotificationPolicy" + } + ], + "default": "AdminOnlyAccessPolicy" + }, + "BulkThreshold": { + "type": "number", + "label": "Bulk threshold", + "required": true, + "default": 7 + }, + "IncreaseScoreWithImageLinks": { + "type": "switch", + "label": "Increase score with image links", + "default": false + }, + "IncreaseScoreWithBizOrInfoUrls": { + "type": "switch", + "label": "Increase score with .biz or .info URLs", + "default": false + }, + "MarkAsSpamFramesInHtml": { + "type": "switch", + "label": "Mark as spam: frames in HTML", + "default": false + }, + "MarkAsSpamObjectTagsInHtml": { + "type": "switch", + "label": "Mark as spam: object tags in HTML", + "default": false + }, + "MarkAsSpamEmbedTagsInHtml": { + "type": "switch", + "label": "Mark as spam: embed tags in HTML", + "default": false + }, + "MarkAsSpamFormTagsInHtml": { + "type": "switch", + "label": "Mark as spam: form tags in HTML", + "default": false + }, + "MarkAsSpamWebBugsInHtml": { + "type": "switch", + "label": "Mark as spam: web bugs in HTML", + "default": false + }, + "MarkAsSpamSensitiveWordList": { + "type": "switch", + "label": "Mark as spam: sensitive word list", + "default": false + }, + "EnableLanguageBlockList": { + "type": "switch", + "label": "Enable language block list", + "default": false + }, + "LanguageBlockList": { + "type": "autoComplete", + "multiple": true, + "creatable": true, + "label": "Blocked languages", + "omitWhenBlank": true, + "default": "" + }, + "EnableRegionBlockList": { + "type": "switch", + "label": "Enable region block list", + "default": false + }, + "RegionBlockList": { + "type": "autoComplete", + "multiple": true, + "creatable": true, + "label": "Blocked regions", + "omitWhenBlank": true, + "default": "" + }, + "AllowedSenderDomains": { + "type": "autoComplete", + "multiple": true, + "creatable": true, + "label": "Allowed sender domains", + "omitWhenBlank": true, + "default": "" + } + }, + "read": { + "cacheType": "ExoHostedContentFilterPolicy" + }, + "prepare": "Get-CIPPBaselineSpamFilterPolicyState", + "remediate": { + "executor": "ExoPolicyRule", + "policyCmdlet": "HostedContentFilterPolicy", + "ruleCmdlet": "HostedContentFilterRule", + "policyParams": { + "SpamAction": "%SpamAction%", + "SpamQuarantineTag": "%SpamQuarantineTag%", + "HighConfidenceSpamAction": "%HighConfidenceSpamAction%", + "HighConfidenceSpamQuarantineTag": "%HighConfidenceSpamQuarantineTag%", + "BulkSpamAction": "%BulkSpamAction%", + "BulkQuarantineTag": "%BulkQuarantineTag%", + "PhishSpamAction": "%PhishSpamAction%", + "PhishQuarantineTag": "%PhishQuarantineTag%", + "HighConfidencePhishAction": "Quarantine", + "HighConfidencePhishQuarantineTag": "%HighConfidencePhishQuarantineTag%", + "BulkThreshold": "%BulkThreshold%", + "QuarantineRetentionPeriod": 30, + "IncreaseScoreWithNumericIps": "Off", + "IncreaseScoreWithRedirectToOtherPort": "Off", + "MarkAsSpamEmptyMessages": "Off", + "MarkAsSpamJavaScriptInHtml": "Off", + "MarkAsSpamSpfRecordHardFail": "Off", + "MarkAsSpamFromAddressAuthFail": "Off", + "MarkAsSpamNdrBackscatter": "Off", + "MarkAsSpamBulkMail": "On", + "InlineSafetyTipsEnabled": true, + "PhishZapEnabled": true, + "SpamZapEnabled": true + }, + "ruleParams": { + "Priority": 0 + } + } +} diff --git a/backend/Config/openapi.json b/backend/Config/openapi.json index 038ad310e2..01817fbfcd 100644 --- a/backend/Config/openapi.json +++ b/backend/Config/openapi.json @@ -33316,6 +33316,207 @@ "x-cipp-any-tenant": true } }, + "/api/ExecSiteBrowserActions": { + "post": { + "summary": "ExecSiteBrowserActions", + "operationId": "ExecSiteBrowserActions", + "tags": [ + "Teams-Sharepoint" + ], + "description": "Mutating / operational actions for the SharePoint site browser (non-permissions).\nBody.Action selects the operation. SiteUrl + tenantFilter are always required.\nVersion cleanup: StartVersionCleanup, GetVersionCleanupStatus.\nSite admin properties (incl. version policy): GetSiteProperties.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "Action": { + "type": "string" + }, + "BatchDeleteMode": { + "$ref": "#/components/schemas/LabelValueNumber" + }, + "DeleteOlderThanDays": { + "type": "string" + }, + "MajorVersionLimit": { + "type": "string" + }, + "MajorWithMinorVersionsLimit": { + "type": "string" + }, + "SiteId": { + "type": "string" + }, + "SiteUrl": { + "type": "string" + }, + "SyncListPolicy": { + "type": "boolean" + }, + "tenantFilter": { + "type": "string" + } + }, + "required": [ + "Action", + "tenantFilter" + ] + } + } + } + }, + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StandardResults" + } + } + } + }, + "400": { + "description": "Bad request - missing required field or invalid input" + }, + "401": { + "description": "Unauthorized - invalid or missing bearer token" + }, + "403": { + "description": "Forbidden - caller lacks the required RBAC role" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "x-cipp-role": "Sharepoint.Site.ReadWrite" + } + }, + "/api/ExecSiteBrowserPermissions": { + "post": { + "summary": "ExecSiteBrowserPermissions", + "operationId": "ExecSiteBrowserPermissions", + "tags": [ + "Teams-Sharepoint" + ], + "description": "Mutating actions for the SharePoint site browser permissions dialog.\nBody.Action selects the operation. SiteUrl + tenantFilter are always required.\nListId scopes library actions; omit it for the site root web.\nSharing links / Graph drive permissions are out of scope.\nGraph site permissions (Sites.Selected app grants): RemoveGraphSitePermission.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "Action": { + "type": "string" + }, + "ClearSubscopes": { + "type": "boolean" + }, + "CopyRoleAssignments": { + "type": "string" + }, + "GroupId": { + "type": "string" + }, + "GroupName": { + "type": "string" + }, + "Groups": { + "type": "string" + }, + "LibraryName": { + "type": "string" + }, + "ListId": { + "type": "string" + }, + "PermissionId": { + "type": "string" + }, + "PermissionLevel": { + "$ref": "#/components/schemas/LabelValue" + }, + "PrincipalId": { + "type": "string" + }, + "PrincipalName": { + "type": "string", + "description": "Allow a single login/UPN from a selected admin row." + }, + "RoleDefinitionId": { + "$ref": "#/components/schemas/LabelValue" + }, + "SiteId": { + "type": "string" + }, + "SiteUrl": { + "type": "string" + }, + "tenantFilter": { + "type": "string" + }, + "Title": { + "type": "string" + }, + "userPrincipalName": { + "type": "string", + "description": "Allow a single login/UPN from a selected admin row." + }, + "Users": { + "type": "string" + } + }, + "required": [ + "Action", + "GroupId", + "ListId", + "PermissionId", + "PrincipalId", + "SiteId", + "SiteUrl", + "tenantFilter" + ] + } + } + } + }, + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StandardResults" + } + } + } + }, + "400": { + "description": "Bad request - missing required field or invalid input" + }, + "401": { + "description": "Unauthorized - invalid or missing bearer token" + }, + "403": { + "description": "Forbidden - caller lacks the required RBAC role" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "x-cipp-role": "Sharepoint.Site.ReadWrite", + "x-cipp-reads-via": [ + "Resolve-BrowserPermissionRoleDefId" + ] + } + }, "/api/ExecSnoozeAlert": { "post": { "summary": "ExecSnoozeAlert", @@ -53220,6 +53421,134 @@ "x-cipp-role": "Sharepoint.Site.Read" } }, + "/api/ListSiteBrowser": { + "get": { + "summary": "ListSiteBrowser", + "operationId": "ListSiteBrowser", + "tags": [ + "Teams-Sharepoint" + ], + "description": "SharePoint site browser listing (sites only — not OneDrive).\nRoot: Get-CIPPSPOAdminListData (SPO.Tenant/RenderAdminListData, Active sites catalog) —\nStorageUsed / NumOfFiles / TemplateName in one paged call.\nGraph getAllSites joins only for Graph site.id (drill-in).\nWith SiteId/SiteUrl: root document/page libraries (Graph lists + SPO StorageMetrics).", + "parameters": [ + { + "name": "SiteId", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "SiteUrl", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "$ref": "#/components/parameters/tenantFilter" + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "description": "Not described statically: this endpoint returns the upstream response as-is, so its fields are determined by the upstream API rather than by CIPP. Call the endpoint to see the actual shape, or add a response schema in backend/Config/openapi-overrides." + } + } + } + } + }, + "400": { + "description": "Bad request - missing required field or invalid input" + }, + "401": { + "description": "Unauthorized - invalid or missing bearer token" + }, + "403": { + "description": "Forbidden - caller lacks the required RBAC role" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "x-cipp-role": "Sharepoint.Site.Read" + } + }, + "/api/ListSiteBrowserPermissions": { + "get": { + "summary": "ListSiteBrowserPermissions", + "operationId": "ListSiteBrowserPermissions", + "tags": [ + "Teams-Sharepoint" + ], + "description": "Extensive permission inventory for a SharePoint site or library for the site browser.\nCollects SPO site admins, associated Owners/Members/Visitors (with members), all site\ngroups (with members), web/library role assignments, and Graph site permissions\n(Sites.Selected / app-only grants). Partial failures are returned in Errors so the UI\ncan still show what was collected. SiteUrl is required; ListId targets a library.\nSharing links / Graph drive permissions are intentionally out of scope (handled elsewhere).", + "parameters": [ + { + "name": "ListId", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "SiteId", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "SiteUrl", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + }, + { + "$ref": "#/components/parameters/tenantFilter" + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StandardResults" + } + } + } + }, + "400": { + "description": "Bad request - missing required field or invalid input" + }, + "401": { + "description": "Unauthorized - invalid or missing bearer token" + }, + "403": { + "description": "Forbidden - caller lacks the required RBAC role" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "x-cipp-role": "Sharepoint.Site.Read" + } + }, "/api/ListSiteLibraries": { "get": { "summary": "ListSiteLibraries", diff --git a/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineAntiPhishPolicyState.ps1 b/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineAntiPhishPolicyState.ps1 new file mode 100644 index 0000000000..1b8d0c9bcf --- /dev/null +++ b/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineAntiPhishPolicyState.ps1 @@ -0,0 +1,126 @@ +function Get-CIPPBaselineAntiPhishPolicyState { + <# + .SYNOPSIS + Prepare hook for AntiPhishPolicy: the policy and the rule that scopes it. + .DESCRIPTION + The graded property set depends on LICENSING, which is why this cannot be a static + template. With Defender for Office 365 (ATP_ENTERPRISE) the classic standard grades 23 + properties including impersonation and mailbox-intelligence protection; without it, + only the 8 that exist on a plain Exchange tenant. Grading the full set on an + unlicensed tenant reports drift for settings that cannot be configured there. + + Note this is a per-tenant CAPABILITY check, not the standard's licence gate: the + standard still runs on an unlicensed tenant, just against the smaller set. That is why + requiredCapabilities carries only the Exchange group. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Item, + $TenantFilter + ) + + $Policies = @(Get-CIPPBaselineCacheRows -TenantFilter $TenantFilter -Type 'ExoAntiPhishPolicies') + if ($Policies.Count -eq 0) { return @{ Current = $null } } + $Rules = @(Get-CIPPBaselineCacheRows -TenantFilter $TenantFilter -Type 'ExoAntiPhishRules' -CollectorType 'ExoAntiPhishPolicies') + $AcceptedDomains = @((Get-CIPPBaselineCacheRows -TenantFilter $TenantFilter -Type 'ExoAcceptedDomains').Name | Where-Object { $_ } | Sort-Object) + + $Capabilities = $(try { Get-CIPPTenantCapabilities -TenantFilter $TenantFilter } catch { $null }) + $MDOLicensed = $Capabilities.ATP_ENTERPRISE -eq $true + + $Configured = if ([string]::IsNullOrWhiteSpace("$($Item.Variables.name)")) { 'CIPP Default Anti-Phishing Policy' } else { "$($Item.Variables.name)" } + $PolicyCandidates = @($Configured, 'CIPP Default Anti-Phishing Policy', 'Default Anti-Phishing Policy') + $ExistingPolicy = @($Policies | Where-Object { $PolicyCandidates -contains "$($_.Name)" }) | Select-Object -First 1 + $PolicyName = if ($ExistingPolicy.Name) { "$($ExistingPolicy.Name)" } else { $Configured } + + $DesiredRuleName = "$PolicyName Rule" + $RuleCandidates = @($DesiredRuleName, 'CIPP Default Anti-Phishing Rule', 'CIPP Default Anti-Phishing Policy') + $ExistingRule = @($Rules | Where-Object { $RuleCandidates -contains "$($_.Name)" }) | Select-Object -First 1 + $RuleName = if ($ExistingRule.Name) { "$($ExistingRule.Name)" } else { $DesiredRuleName } + + $Policy = @($Policies | Where-Object { "$($_.Name)" -eq $PolicyName }) | Select-Object -First 1 + $Rule = @($Rules | Where-Object { "$($_.Name)" -eq $RuleName }) | Select-Object -First 1 + $V = $Item.Variables + + # The eight properties every tenant has. + $Expected = [PSCustomObject]@{ + name = $PolicyName + enabled = $true + enableSpoofIntelligence = $true + enableFirstContactSafetyTips = [bool]($V.EnableFirstContactSafetyTips -eq $true) + enableUnauthenticatedSender = $true + enableViaTag = $true + authenticationFailAction = "$($V.AuthenticationFailAction)" + spoofQuarantineTag = "$($V.SpoofQuarantineTag)" + } + $Current = [PSCustomObject]@{ + name = "$($Policy.Name)" + enabled = [bool]$Policy.Enabled + enableSpoofIntelligence = [bool]$Policy.EnableSpoofIntelligence + enableFirstContactSafetyTips = [bool]$Policy.EnableFirstContactSafetyTips + enableUnauthenticatedSender = [bool]$Policy.EnableUnauthenticatedSender + enableViaTag = [bool]$Policy.EnableViaTag + authenticationFailAction = "$($Policy.AuthenticationFailAction)" + spoofQuarantineTag = "$($Policy.SpoofQuarantineTag)" + } + + if ($MDOLicensed) { + $MdoExpected = [ordered]@{ + phishThresholdLevel = "$($V.PhishThresholdLevel)" + enableMailboxIntelligence = $true + enableMailboxIntelligenceProtection = $true + enableSimilarUsersSafetyTips = [bool]($V.EnableSimilarUsersSafetyTips -eq $true) + enableSimilarDomainsSafetyTips = [bool]($V.EnableSimilarDomainsSafetyTips -eq $true) + enableUnusualCharactersSafetyTips = [bool]($V.EnableUnusualCharactersSafetyTips -eq $true) + mailboxIntelligenceProtectionAction = "$($V.MailboxIntelligenceProtectionAction)" + mailboxIntelligenceQuarantineTag = "$($V.MailboxIntelligenceQuarantineTag)" + targetedUserProtectionAction = "$($V.TargetedUserProtectionAction)" + targetedUserQuarantineTag = "$($V.TargetedUserQuarantineTag)" + targetedDomainProtectionAction = "$($V.TargetedDomainProtectionAction)" + targetedDomainQuarantineTag = "$($V.TargetedDomainQuarantineTag)" + enableTargetedDomainsProtection = $true + enableTargetedUserProtection = $true + enableOrganizationDomainsProtection = $true + } + $MdoCurrent = [ordered]@{ + phishThresholdLevel = "$($Policy.PhishThresholdLevel)" + enableMailboxIntelligence = [bool]$Policy.EnableMailboxIntelligence + enableMailboxIntelligenceProtection = [bool]$Policy.EnableMailboxIntelligenceProtection + enableSimilarUsersSafetyTips = [bool]$Policy.EnableSimilarUsersSafetyTips + enableSimilarDomainsSafetyTips = [bool]$Policy.EnableSimilarDomainsSafetyTips + enableUnusualCharactersSafetyTips = [bool]$Policy.EnableUnusualCharactersSafetyTips + mailboxIntelligenceProtectionAction = "$($Policy.MailboxIntelligenceProtectionAction)" + mailboxIntelligenceQuarantineTag = "$($Policy.MailboxIntelligenceQuarantineTag)" + targetedUserProtectionAction = "$($Policy.TargetedUserProtectionAction)" + targetedUserQuarantineTag = "$($Policy.TargetedUserQuarantineTag)" + targetedDomainProtectionAction = "$($Policy.TargetedDomainProtectionAction)" + targetedDomainQuarantineTag = "$($Policy.TargetedDomainQuarantineTag)" + enableTargetedDomainsProtection = [bool]$Policy.EnableTargetedDomainsProtection + enableTargetedUserProtection = [bool]$Policy.EnableTargetedUserProtection + enableOrganizationDomainsProtection = [bool]$Policy.EnableOrganizationDomainsProtection + } + foreach ($Key in $MdoExpected.Keys) { $Expected | Add-Member -NotePropertyName $Key -NotePropertyValue $MdoExpected[$Key] } + foreach ($Key in $MdoCurrent.Keys) { $Current | Add-Member -NotePropertyName $Key -NotePropertyValue $MdoCurrent[$Key] } + } + + $Expected | Add-Member -NotePropertyName 'rule' -NotePropertyValue ([PSCustomObject]@{ + name = $RuleName; policy = $PolicyName; priority = 0; recipientDomainIs = @($AcceptedDomains) + }) + $Current | Add-Member -NotePropertyName 'rule' -NotePropertyValue ([PSCustomObject]@{ + name = "$($Rule.Name)" + policy = "$($Rule.AntiPhishPolicy)" + priority = $(if ($null -eq $Rule.Priority) { -1 } else { [int]$Rule.Priority }) + recipientDomainIs = @(@($Rule.RecipientDomainIs) | Where-Object { $_ } | Sort-Object) + }) + + $Current | Add-Member -NotePropertyName 'policyName' -NotePropertyValue $PolicyName + $Current | Add-Member -NotePropertyName 'ruleName' -NotePropertyValue $RuleName + $Current | Add-Member -NotePropertyName 'policyExists' -NotePropertyValue ([bool]$Policy) + $Current | Add-Member -NotePropertyName 'ruleExists' -NotePropertyValue ([bool]$Rule) + $Current | Add-Member -NotePropertyName 'ruleLinkedPolicy' -NotePropertyValue "$($Rule.AntiPhishPolicy)" + $Current | Add-Member -NotePropertyName 'acceptedDomains' -NotePropertyValue @($AcceptedDomains) + $Current | Add-Member -NotePropertyName 'mdoLicensed' -NotePropertyValue $MDOLicensed + + @{ Expected = $Expected; Current = $Current } +} diff --git a/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineMalwareFilterPolicyState.ps1 b/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineMalwareFilterPolicyState.ps1 new file mode 100644 index 0000000000..e633388a38 --- /dev/null +++ b/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineMalwareFilterPolicyState.ps1 @@ -0,0 +1,100 @@ +function Get-CIPPBaselineMalwareFilterPolicyState { + <# + .SYNOPSIS + Prepare hook for MalwareFilterPolicy: the policy and the rule that scopes it. + .DESCRIPTION + Legacy name adoption here is NARROWER than the other families and is carried verbatim: + the classic standard only adopts an existing name when the operator left the policy + name at the CIPP default. A custom name is taken literally, so a tenant with a custom + policy never silently binds to the Microsoft default. + + FileTypes is the 55-entry default list plus whatever the operator adds as a + comma-separated string, compared as a set - the classic used Compare-Object, which is + order-insensitive, so both sides are sorted here. + + The two admin-notification addresses are graded only when supplied, matching the + classic '($null -eq $Settings.X) -or ...' tests. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Item, + $TenantFilter + ) + + $DefaultPolicyName = 'CIPP Default Malware Policy' + $Policies = @(Get-CIPPBaselineCacheRows -TenantFilter $TenantFilter -Type 'ExoMalwareFilterPolicies') + if ($Policies.Count -eq 0) { return @{ Current = $null } } + $Rules = @(Get-CIPPBaselineCacheRows -TenantFilter $TenantFilter -Type 'ExoMalwareFilterRules' -CollectorType 'ExoMalwareFilterPolicies') + $AcceptedDomains = @((Get-CIPPBaselineCacheRows -TenantFilter $TenantFilter -Type 'ExoAcceptedDomains').Name | Where-Object { $_ } | Sort-Object) + + $PolicyName = if ([string]::IsNullOrWhiteSpace("$($Item.Variables.name)")) { $DefaultPolicyName } else { "$($Item.Variables.name)" } + if ($PolicyName -eq $DefaultPolicyName) { + $ExistingPolicy = @($Policies | Where-Object { @($PolicyName, 'Default Malware Policy') -contains "$($_.Name)" }) | Select-Object -First 1 + if ($ExistingPolicy.Name) { $PolicyName = "$($ExistingPolicy.Name)" } + } + + $RuleName = "$PolicyName Rule" + if ($PolicyName -eq $DefaultPolicyName) { + $ExistingRule = @($Rules | Where-Object { @($RuleName, 'CIPP Default Malware Rule', 'CIPP Default Malware Policy') -contains "$($_.Name)" }) | Select-Object -First 1 + if ($ExistingRule.Name) { $RuleName = "$($ExistingRule.Name)" } + } + + $Policy = @($Policies | Where-Object { "$($_.Name)" -eq $PolicyName }) | Select-Object -First 1 + $Rule = @($Rules | Where-Object { "$($_.Name)" -eq $RuleName }) | Select-Object -First 1 + + $DefaultFileTypes = @('ace', 'ani', 'apk', 'app', 'appx', 'arj', 'bat', 'cab', 'cmd', 'com', 'deb', 'dex', 'dll', 'docm', 'elf', 'exe', 'hta', 'img', 'iso', 'jar', 'jnlp', 'kext', 'lha', 'lib', 'library', 'lnk', 'lzh', 'macho', 'msc', 'msi', 'msix', 'msp', 'mst', 'pif', 'ppa', 'ppam', 'reg', 'rev', 'scf', 'scr', 'sct', 'sys', 'uif', 'vb', 'vbe', 'vbs', 'vxd', 'wsc', 'wsf', 'wsh', 'xll', 'xz', 'z') + $Optional = @("$($Item.Variables.OptionalFileTypes)" -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ }) + $ExpectedFileTypes = @(($DefaultFileTypes + $Optional) | Sort-Object) + + $Expected = [PSCustomObject]@{ + name = $PolicyName + enableFileFilter = $true + fileTypeAction = "$($Item.Variables.FileTypeAction)" + fileTypes = @($ExpectedFileTypes) + zapEnabled = $true + quarantineTag = "$($Item.Variables.QuarantineTag)" + enableInternalSenderAdminNotifications = [bool]($Item.Variables.EnableInternalSenderAdminNotifications -eq $true) + enableExternalSenderAdminNotifications = [bool]($Item.Variables.EnableExternalSenderAdminNotifications -eq $true) + rule = [PSCustomObject]@{ + name = $RuleName + policy = $PolicyName + priority = 0 + recipientDomainIs = @($AcceptedDomains) + } + } + $Current = [PSCustomObject]@{ + name = "$($Policy.Name)" + enableFileFilter = [bool]$Policy.EnableFileFilter + fileTypeAction = "$($Policy.FileTypeAction)" + fileTypes = @(@($Policy.FileTypes) | Where-Object { $_ } | Sort-Object) + zapEnabled = [bool]$Policy.ZapEnabled + quarantineTag = "$($Policy.QuarantineTag)" + enableInternalSenderAdminNotifications = [bool]$Policy.EnableInternalSenderAdminNotifications + enableExternalSenderAdminNotifications = [bool]$Policy.EnableExternalSenderAdminNotifications + rule = [PSCustomObject]@{ + name = "$($Rule.Name)" + policy = "$($Rule.MalwareFilterPolicy)" + priority = $(if ($null -eq $Rule.Priority) { -1 } else { [int]$Rule.Priority }) + recipientDomainIs = @(@($Rule.RecipientDomainIs) | Where-Object { $_ } | Sort-Object) + } + } + + foreach ($Pair in @(@{ v = 'InternalSenderAdminAddress'; k = 'internalSenderAdminAddress' }, @{ v = 'ExternalSenderAdminAddress'; k = 'externalSenderAdminAddress' })) { + if (-not [string]::IsNullOrWhiteSpace("$($Item.Variables.($Pair.v))")) { + $Expected | Add-Member -NotePropertyName $Pair.k -NotePropertyValue "$($Item.Variables.($Pair.v))" + $Current | Add-Member -NotePropertyName $Pair.k -NotePropertyValue "$($Policy.($Pair.v))" + } + } + + $Current | Add-Member -NotePropertyName 'policyName' -NotePropertyValue $PolicyName + $Current | Add-Member -NotePropertyName 'ruleName' -NotePropertyValue $RuleName + $Current | Add-Member -NotePropertyName 'policyExists' -NotePropertyValue ([bool]$Policy) + $Current | Add-Member -NotePropertyName 'ruleExists' -NotePropertyValue ([bool]$Rule) + $Current | Add-Member -NotePropertyName 'ruleLinkedPolicy' -NotePropertyValue "$($Rule.MalwareFilterPolicy)" + $Current | Add-Member -NotePropertyName 'acceptedDomains' -NotePropertyValue @($AcceptedDomains) + $Current | Add-Member -NotePropertyName 'expectedFileTypes' -NotePropertyValue @($ExpectedFileTypes) + + @{ Expected = $Expected; Current = $Current } +} diff --git a/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineSafeAttachmentPolicyState.ps1 b/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineSafeAttachmentPolicyState.ps1 new file mode 100644 index 0000000000..cc9c6b4ade --- /dev/null +++ b/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineSafeAttachmentPolicyState.ps1 @@ -0,0 +1,94 @@ +function Get-CIPPBaselineSafeAttachmentPolicyState { + <# + .SYNOPSIS + Prepare hook for SafeAttachmentPolicy: the policy and the rule that scopes it. + .DESCRIPTION + Three things here cannot be expressed declaratively: + + Legacy name adoption. The policy is whichever of the configured name, 'CIPP Default + Safe Attachment Policy' or 'Default Safe Attachment Policy' the tenant already has - + first match wins, and the found name becomes the name everything else is keyed on. A + read filter takes one fixed value and cannot express 'whichever of these exists'. + Adopting matters: without it a tenant carrying the older name gets a SECOND policy + rather than an update. + + Rule scoping. The rule must list every accepted domain, which is tenant state rather + than a configured value, so no %token% can render it. + + Rule grading. The classic standard remediated the rule independently of the policy but + reported only the policy. Here the rule joins the compare, so a rule-only deviation + still triggers the write - which is what the classic did - and is visible in the row. + + The resolved names and existence flags ride along on Current for the executor, so the + write targets exactly what was graded rather than re-resolving and possibly disagreeing. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Item, + $TenantFilter + ) + + $Policies = @(Get-CIPPBaselineCacheRows -TenantFilter $TenantFilter -Type 'ExoSafeAttachmentPolicies') + if ($Policies.Count -eq 0) { return @{ Current = $null } } + $Rules = @(Get-CIPPBaselineCacheRows -TenantFilter $TenantFilter -Type 'ExoSafeAttachmentRules' -CollectorType 'ExoSafeAttachmentPolicies') + $AcceptedDomains = @((Get-CIPPBaselineCacheRows -TenantFilter $TenantFilter -Type 'ExoAcceptedDomains').Name | Where-Object { $_ } | Sort-Object) + + $Configured = if ([string]::IsNullOrWhiteSpace("$($Item.Variables.name)")) { 'CIPP Default Safe Attachment Policy' } else { "$($Item.Variables.name)" } + $PolicyCandidates = @($Configured, 'CIPP Default Safe Attachment Policy', 'Default Safe Attachment Policy') + $ExistingPolicy = @($Policies | Where-Object { $PolicyCandidates -contains "$($_.Name)" }) | Select-Object -First 1 + $PolicyName = if ($ExistingPolicy.Name) { "$($ExistingPolicy.Name)" } else { $Configured } + + $DesiredRuleName = "$PolicyName Rule" + $RuleCandidates = @($DesiredRuleName, 'CIPP Default Safe Attachment Rule', 'CIPP Default Safe Attachment Policy') + $ExistingRule = @($Rules | Where-Object { $RuleCandidates -contains "$($_.Name)" }) | Select-Object -First 1 + $RuleName = if ($ExistingRule.Name) { "$($ExistingRule.Name)" } else { $DesiredRuleName } + + $Policy = @($Policies | Where-Object { "$($_.Name)" -eq $PolicyName }) | Select-Object -First 1 + $Rule = @($Rules | Where-Object { "$($_.Name)" -eq $RuleName }) | Select-Object -First 1 + + $Expected = [PSCustomObject]@{ + name = $PolicyName + enable = $true + action = "$($Item.Variables.SafeAttachmentAction)" + quarantineTag = "$($Item.Variables.QuarantineTag)" + redirect = [bool]($Item.Variables.Redirect -eq $true) + rule = [PSCustomObject]@{ + name = $RuleName + policy = $PolicyName + priority = 0 + recipientDomainIs = @($AcceptedDomains) + } + } + $Current = [PSCustomObject]@{ + name = "$($Policy.Name)" + enable = [bool]$Policy.Enable + action = "$($Policy.Action)" + quarantineTag = "$($Policy.QuarantineTag)" + redirect = [bool]$Policy.Redirect + rule = [PSCustomObject]@{ + name = "$($Rule.Name)" + policy = "$($Rule.SafeAttachmentPolicy)" + priority = $(if ($null -eq $Rule.Priority) { -1 } else { [int]$Rule.Priority }) + recipientDomainIs = @(@($Rule.RecipientDomainIs) | Where-Object { $_ } | Sort-Object) + } + } + + # RedirectAddress is only graded when the operator supplied one, matching the classic + # '($null -eq $Settings.RedirectAddress) -or ...' test. + if (-not [string]::IsNullOrWhiteSpace("$($Item.Variables.RedirectAddress)")) { + $Expected | Add-Member -NotePropertyName 'redirectAddress' -NotePropertyValue "$($Item.Variables.RedirectAddress)" + $Current | Add-Member -NotePropertyName 'redirectAddress' -NotePropertyValue "$($Policy.RedirectAddress)" + } + + # Carried for the executor, not graded - the engine projects Current to the Expected keys. + $Current | Add-Member -NotePropertyName 'policyName' -NotePropertyValue $PolicyName + $Current | Add-Member -NotePropertyName 'ruleName' -NotePropertyValue $RuleName + $Current | Add-Member -NotePropertyName 'policyExists' -NotePropertyValue ([bool]$Policy) + $Current | Add-Member -NotePropertyName 'ruleExists' -NotePropertyValue ([bool]$Rule) + $Current | Add-Member -NotePropertyName 'ruleLinkedPolicy' -NotePropertyValue "$($Rule.SafeAttachmentPolicy)" + $Current | Add-Member -NotePropertyName 'acceptedDomains' -NotePropertyValue @($AcceptedDomains) + + @{ Expected = $Expected; Current = $Current } +} diff --git a/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineSafeLinksPolicyState.ps1 b/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineSafeLinksPolicyState.ps1 new file mode 100644 index 0000000000..3c5fffa351 --- /dev/null +++ b/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineSafeLinksPolicyState.ps1 @@ -0,0 +1,92 @@ +function Get-CIPPBaselineSafeLinksPolicyState { + <# + .SYNOPSIS + Prepare hook for SafeLinksPolicy: the policy and the rule that scopes it. + .DESCRIPTION + Same shape as the other Defender families - legacy name adoption, accepted-domain rule + scoping, and the rule graded alongside the policy so a rule-only deviation still + triggers the write. + + Two quirks specific to this family, both carried verbatim: + the desired rule name uses an UNDERSCORE ("_Rule"), while the older CIPP name + used a space, so both are candidates; and DoNotRewriteUrls compares against an empty + list when nothing is configured, matching the classic '?? @()'. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Item, + $TenantFilter + ) + + $Policies = @(Get-CIPPBaselineCacheRows -TenantFilter $TenantFilter -Type 'ExoSafeLinksPolicies') + if ($Policies.Count -eq 0) { return @{ Current = $null } } + $Rules = @(Get-CIPPBaselineCacheRows -TenantFilter $TenantFilter -Type 'ExoSafeLinksRules' -CollectorType 'ExoSafeLinksPolicies') + $AcceptedDomains = @((Get-CIPPBaselineCacheRows -TenantFilter $TenantFilter -Type 'ExoAcceptedDomains').Name | Where-Object { $_ } | Sort-Object) + + $Configured = if ([string]::IsNullOrWhiteSpace("$($Item.Variables.name)")) { 'CIPP Default SafeLinks Policy' } else { "$($Item.Variables.name)" } + $PolicyCandidates = @($Configured, 'CIPP Default SafeLinks Policy', 'Default SafeLinks Policy') + $ExistingPolicy = @($Policies | Where-Object { $PolicyCandidates -contains "$($_.Name)" }) | Select-Object -First 1 + $PolicyName = if ($ExistingPolicy.Name) { "$($ExistingPolicy.Name)" } else { $Configured } + + $DesiredRuleName = "$($PolicyName)_Rule" + $RuleCandidates = @($DesiredRuleName, "$PolicyName Rule", 'CIPP Default SafeLinks Rule', 'CIPP Default SafeLinks Policy') + $ExistingRule = @($Rules | Where-Object { $RuleCandidates -contains "$($_.Name)" }) | Select-Object -First 1 + $RuleName = if ($ExistingRule.Name) { "$($ExistingRule.Name)" } else { $DesiredRuleName } + + $Policy = @($Policies | Where-Object { "$($_.Name)" -eq $PolicyName }) | Select-Object -First 1 + $Rule = @($Rules | Where-Object { "$($_.Name)" -eq $RuleName }) | Select-Object -First 1 + + $DoNotRewrite = @(@($Item.Variables.DoNotRewriteUrls) | Where-Object { $_ } | Sort-Object) + + $Expected = [PSCustomObject]@{ + name = $PolicyName + enableSafeLinksForEmail = $true + enableSafeLinksForTeams = $true + enableSafeLinksForOffice = $true + trackClicks = $true + scanUrls = $true + enableForInternalSenders = $true + deliverMessageAfterScan = $true + allowClickThrough = [bool]($Item.Variables.AllowClickThrough -eq $true) + disableUrlRewrite = [bool]($Item.Variables.DisableUrlRewrite -eq $true) + enableOrganizationBranding = [bool]($Item.Variables.EnableOrganizationBranding -eq $true) + doNotRewriteUrls = @($DoNotRewrite) + rule = [PSCustomObject]@{ + name = $RuleName + policy = $PolicyName + priority = 0 + recipientDomainIs = @($AcceptedDomains) + } + } + $Current = [PSCustomObject]@{ + name = "$($Policy.Name)" + enableSafeLinksForEmail = [bool]$Policy.EnableSafeLinksForEmail + enableSafeLinksForTeams = [bool]$Policy.EnableSafeLinksForTeams + enableSafeLinksForOffice = [bool]$Policy.EnableSafeLinksForOffice + trackClicks = [bool]$Policy.TrackClicks + scanUrls = [bool]$Policy.ScanUrls + enableForInternalSenders = [bool]$Policy.EnableForInternalSenders + deliverMessageAfterScan = [bool]$Policy.DeliverMessageAfterScan + allowClickThrough = [bool]$Policy.AllowClickThrough + disableUrlRewrite = [bool]$Policy.DisableUrlRewrite + enableOrganizationBranding = [bool]$Policy.EnableOrganizationBranding + doNotRewriteUrls = @(@($Policy.DoNotRewriteUrls) | Where-Object { $_ } | Sort-Object) + rule = [PSCustomObject]@{ + name = "$($Rule.Name)" + policy = "$($Rule.SafeLinksPolicy)" + priority = $(if ($null -eq $Rule.Priority) { -1 } else { [int]$Rule.Priority }) + recipientDomainIs = @(@($Rule.RecipientDomainIs) | Where-Object { $_ } | Sort-Object) + } + } + + $Current | Add-Member -NotePropertyName 'policyName' -NotePropertyValue $PolicyName + $Current | Add-Member -NotePropertyName 'ruleName' -NotePropertyValue $RuleName + $Current | Add-Member -NotePropertyName 'policyExists' -NotePropertyValue ([bool]$Policy) + $Current | Add-Member -NotePropertyName 'ruleExists' -NotePropertyValue ([bool]$Rule) + $Current | Add-Member -NotePropertyName 'ruleLinkedPolicy' -NotePropertyValue "$($Rule.SafeLinksPolicy)" + $Current | Add-Member -NotePropertyName 'acceptedDomains' -NotePropertyValue @($AcceptedDomains) + + @{ Expected = $Expected; Current = $Current } +} diff --git a/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineSpamFilterPolicyState.ps1 b/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineSpamFilterPolicyState.ps1 new file mode 100644 index 0000000000..bf44c9094b --- /dev/null +++ b/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineSpamFilterPolicyState.ps1 @@ -0,0 +1,166 @@ +function Get-CIPPBaselineSpamFilterPolicyState { + <# + .SYNOPSIS + Prepare hook for SpamFilterPolicy: the content filter policy and its rule. + .DESCRIPTION + The largest of the Defender families - thirty graded properties, several of them + derived rather than configured. Three things carried verbatim from the classic + standard: + + Derived On/Off values. Eight settings are switches in the UI but 'On'/'Off' strings in + Exchange, and a further nine are hardcoded constants the standard always enforces. + + The Default policy has no rule. When the adopted policy is the built-in 'Default', + Exchange owns its scoping and rejects a rule pointing at it, so the rule is neither + graded nor written - the classic guarded its rule block with '-and -not + $IsDefaultPolicy'. That is signalled to the executor as skipRule. + + Conditional list grading. The language and region block lists are only compared when + their Enable switch is on, and allowed-sender domains treat 'both empty' as equal - + the classic's long null-and-count expression. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Item, + $TenantFilter + ) + + $Policies = @(Get-CIPPBaselineCacheRows -TenantFilter $TenantFilter -Type 'ExoHostedContentFilterPolicy') + if ($Policies.Count -eq 0) { return @{ Current = $null } } + $Rules = @(Get-CIPPBaselineCacheRows -TenantFilter $TenantFilter -Type 'ExoHostedContentFilterRule') + $AcceptedDomains = @((Get-CIPPBaselineCacheRows -TenantFilter $TenantFilter -Type 'ExoAcceptedDomains').Name | Where-Object { $_ } | Sort-Object) + + $V = $Item.Variables + $Configured = if ([string]::IsNullOrWhiteSpace("$($V.name)")) { 'CIPP Default Spam Filter Policy' } else { "$($V.name)" } + $PolicyCandidates = @($Configured, 'Default Spam Filter Policy', 'Default') + $ExistingPolicy = @($Policies | Where-Object { $PolicyCandidates -contains "$($_.Name)" }) | Select-Object -First 1 + $PolicyName = if ($ExistingPolicy.Name) { "$($ExistingPolicy.Name)" } else { $Configured } + $IsDefaultPolicy = $PolicyName -eq 'Default' + + $Policy = @($Policies | Where-Object { "$($_.Name)" -eq $PolicyName }) | Select-Object -First 1 + # The classic keys the rule on the POLICY name, not a " Rule" name. + $Rule = @($Rules | Where-Object { "$($_.Name)" -eq $PolicyName }) | Select-Object -First 1 + + $OnOff = { param($Value) if ($Value -eq $true) { 'On' } else { 'Off' } } + $SplitList = { param($Value, $Case) + $Items = @(@($Value) | ForEach-Object { "$_" -split ',' } | ForEach-Object { $_.Trim() } | Where-Object { $_ }) + if ($Case -eq 'lower') { @($Items | ForEach-Object { $_.ToLower() } | Sort-Object) } + elseif ($Case -eq 'upper') { @($Items | ForEach-Object { $_.ToUpper() } | Sort-Object) } + else { @($Items | Sort-Object) } + } + + $AllowedSenderDomains = & $SplitList $V.AllowedSenderDomains 'none' + $CurrentAllowed = @(@($Policy.AllowedSenderDomains) | Where-Object { $_ } | Sort-Object) + + $Expected = [PSCustomObject]@{ + name = $PolicyName + spamAction = "$($V.SpamAction)" + spamQuarantineTag = "$($V.SpamQuarantineTag)" + highConfidenceSpamAction = "$($V.HighConfidenceSpamAction)" + highConfidenceSpamQuarantineTag = "$($V.HighConfidenceSpamQuarantineTag)" + bulkSpamAction = "$($V.BulkSpamAction)" + bulkQuarantineTag = "$($V.BulkQuarantineTag)" + phishSpamAction = "$($V.PhishSpamAction)" + phishQuarantineTag = "$($V.PhishQuarantineTag)" + highConfidencePhishAction = 'Quarantine' + highConfidencePhishQuarantineTag = "$($V.HighConfidencePhishQuarantineTag)" + bulkThreshold = [int]"$($V.BulkThreshold)" + quarantineRetentionPeriod = 30 + increaseScoreWithImageLinks = (& $OnOff $V.IncreaseScoreWithImageLinks) + increaseScoreWithNumericIps = 'Off' + increaseScoreWithRedirectToOtherPort = 'Off' + increaseScoreWithBizOrInfoUrls = (& $OnOff $V.IncreaseScoreWithBizOrInfoUrls) + markAsSpamEmptyMessages = 'Off' + markAsSpamJavaScriptInHtml = 'Off' + markAsSpamFramesInHtml = (& $OnOff $V.MarkAsSpamFramesInHtml) + markAsSpamObjectTagsInHtml = (& $OnOff $V.MarkAsSpamObjectTagsInHtml) + markAsSpamEmbedTagsInHtml = (& $OnOff $V.MarkAsSpamEmbedTagsInHtml) + markAsSpamFormTagsInHtml = (& $OnOff $V.MarkAsSpamFormTagsInHtml) + markAsSpamWebBugsInHtml = (& $OnOff $V.MarkAsSpamWebBugsInHtml) + markAsSpamSensitiveWordList = (& $OnOff $V.MarkAsSpamSensitiveWordList) + markAsSpamSpfRecordHardFail = 'Off' + markAsSpamFromAddressAuthFail = 'Off' + markAsSpamNdrBackscatter = 'Off' + markAsSpamBulkMail = 'On' + inlineSafetyTipsEnabled = $true + phishZapEnabled = $true + spamZapEnabled = $true + enableLanguageBlockList = [bool]($V.EnableLanguageBlockList -eq $true) + enableRegionBlockList = [bool]($V.EnableRegionBlockList -eq $true) + allowedSenderDomains = @($AllowedSenderDomains) + } + $Current = [PSCustomObject]@{ + name = "$($Policy.Name)" + spamAction = "$($Policy.SpamAction)" + spamQuarantineTag = "$($Policy.SpamQuarantineTag)" + highConfidenceSpamAction = "$($Policy.HighConfidenceSpamAction)" + highConfidenceSpamQuarantineTag = "$($Policy.HighConfidenceSpamQuarantineTag)" + bulkSpamAction = "$($Policy.BulkSpamAction)" + bulkQuarantineTag = "$($Policy.BulkQuarantineTag)" + phishSpamAction = "$($Policy.PhishSpamAction)" + phishQuarantineTag = "$($Policy.PhishQuarantineTag)" + highConfidencePhishAction = "$($Policy.HighConfidencePhishAction)" + highConfidencePhishQuarantineTag = "$($Policy.HighConfidencePhishQuarantineTag)" + bulkThreshold = $(if ($null -eq $Policy.BulkThreshold) { -1 } else { [int]$Policy.BulkThreshold }) + quarantineRetentionPeriod = $(if ($null -eq $Policy.QuarantineRetentionPeriod) { -1 } else { [int]$Policy.QuarantineRetentionPeriod }) + increaseScoreWithImageLinks = "$($Policy.IncreaseScoreWithImageLinks)" + increaseScoreWithNumericIps = "$($Policy.IncreaseScoreWithNumericIps)" + increaseScoreWithRedirectToOtherPort = "$($Policy.IncreaseScoreWithRedirectToOtherPort)" + increaseScoreWithBizOrInfoUrls = "$($Policy.IncreaseScoreWithBizOrInfoUrls)" + markAsSpamEmptyMessages = "$($Policy.MarkAsSpamEmptyMessages)" + markAsSpamJavaScriptInHtml = "$($Policy.MarkAsSpamJavaScriptInHtml)" + markAsSpamFramesInHtml = "$($Policy.MarkAsSpamFramesInHtml)" + markAsSpamObjectTagsInHtml = "$($Policy.MarkAsSpamObjectTagsInHtml)" + markAsSpamEmbedTagsInHtml = "$($Policy.MarkAsSpamEmbedTagsInHtml)" + markAsSpamFormTagsInHtml = "$($Policy.MarkAsSpamFormTagsInHtml)" + markAsSpamWebBugsInHtml = "$($Policy.MarkAsSpamWebBugsInHtml)" + markAsSpamSensitiveWordList = "$($Policy.MarkAsSpamSensitiveWordList)" + markAsSpamSpfRecordHardFail = "$($Policy.MarkAsSpamSpfRecordHardFail)" + markAsSpamFromAddressAuthFail = "$($Policy.MarkAsSpamFromAddressAuthFail)" + markAsSpamNdrBackscatter = "$($Policy.MarkAsSpamNdrBackscatter)" + markAsSpamBulkMail = "$($Policy.MarkAsSpamBulkMail)" + inlineSafetyTipsEnabled = [bool]$Policy.InlineSafetyTipsEnabled + phishZapEnabled = [bool]$Policy.PhishZapEnabled + spamZapEnabled = [bool]$Policy.SpamZapEnabled + enableLanguageBlockList = [bool]$Policy.EnableLanguageBlockList + enableRegionBlockList = [bool]$Policy.EnableRegionBlockList + # Both-empty counts as equal, matching the classic's null-and-count expression. + allowedSenderDomains = $(if ($CurrentAllowed.Count -eq 0 -and $AllowedSenderDomains.Count -eq 0) { @($AllowedSenderDomains) } else { @($CurrentAllowed) }) + } + + # The block lists are only graded when their switch is on. + if ($V.EnableLanguageBlockList -eq $true) { + $Expected | Add-Member -NotePropertyName 'languageBlockList' -NotePropertyValue (& $SplitList $V.LanguageBlockList 'lower') + $Current | Add-Member -NotePropertyName 'languageBlockList' -NotePropertyValue @(@($Policy.LanguageBlockList) | Where-Object { $_ } | ForEach-Object { "$_".ToLower() } | Sort-Object) + } + if ($V.EnableRegionBlockList -eq $true) { + $Expected | Add-Member -NotePropertyName 'regionBlockList' -NotePropertyValue (& $SplitList $V.RegionBlockList 'upper') + $Current | Add-Member -NotePropertyName 'regionBlockList' -NotePropertyValue @(@($Policy.RegionBlockList) | Where-Object { $_ } | ForEach-Object { "$_".ToUpper() } | Sort-Object) + } + + # The built-in Default policy cannot carry a rule. + if (-not $IsDefaultPolicy) { + $Expected | Add-Member -NotePropertyName 'rule' -NotePropertyValue ([PSCustomObject]@{ + name = $PolicyName; policy = $PolicyName; state = 'Enabled'; priority = 0; recipientDomainIs = @($AcceptedDomains) + }) + $Current | Add-Member -NotePropertyName 'rule' -NotePropertyValue ([PSCustomObject]@{ + name = "$($Rule.Name)" + policy = "$($Rule.HostedContentFilterPolicy)" + state = "$($Rule.State)" + priority = $(if ($null -eq $Rule.Priority) { -1 } else { [int]$Rule.Priority }) + recipientDomainIs = @(@($Rule.RecipientDomainIs) | Where-Object { $_ } | Sort-Object) + }) + } + + $Current | Add-Member -NotePropertyName 'policyName' -NotePropertyValue $PolicyName + $Current | Add-Member -NotePropertyName 'ruleName' -NotePropertyValue $PolicyName + $Current | Add-Member -NotePropertyName 'policyExists' -NotePropertyValue ([bool]$Policy) + $Current | Add-Member -NotePropertyName 'ruleExists' -NotePropertyValue ([bool]$Rule) + $Current | Add-Member -NotePropertyName 'ruleLinkedPolicy' -NotePropertyValue "$($Rule.HostedContentFilterPolicy)" + $Current | Add-Member -NotePropertyName 'acceptedDomains' -NotePropertyValue @($AcceptedDomains) + $Current | Add-Member -NotePropertyName 'skipRule' -NotePropertyValue $IsDefaultPolicy + + @{ Expected = $Expected; Current = $Current } +} diff --git a/backend/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineExoPolicyRule.ps1 b/backend/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineExoPolicyRule.ps1 new file mode 100644 index 0000000000..2d6f945aea --- /dev/null +++ b/backend/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineExoPolicyRule.ps1 @@ -0,0 +1,75 @@ +function Invoke-CIPPBaselineExoPolicyRule { + <# + .SYNOPSIS + ExoPolicyRule executor: upserts a Defender policy and the rule that scopes it. + .DESCRIPTION + The Defender for Office families - Safe Attachments, Safe Links, Malware, Spam, + Anti-Phish - are all one policy plus one rule that points at it, and every one of them + has to decide New- versus Set- per object at run time. A rendered ExoRequest spec is + fixed before it sees the tenant, so it cannot make that choice. + + The prepare hook has already resolved BOTH names, adopting whatever legacy name the + tenant actually carries, and has computed the accepted-domain list the rule must scope + to. Those arrive on -Current so the write targets exactly what the compare graded: + policyName / ruleName - the resolved names + policyExists / ruleExists - which verb to use + Nothing here re-derives them, because a second resolution could disagree with the + graded one and write to a different object than the row reports. + + Spec (fully rendered): + policyCmdlet / ruleCmdlet - the noun pair, e.g. SafeAttachmentPolicy and + SafeAttachmentRule. New- and Set- are prefixed here. + policyParams / ruleParams - parameters minus the identity, which is added per verb. + skipRule - set when the policy is a built-in that cannot carry a + rule (the Spam family's Default policy). + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Remediate, + $TenantFilter, + $Current + ) + + if (-not $Current) { throw 'ExoPolicyRule: the prepare hook produced no state to write from.' } + + $ToHashtable = { + param($Object) + $Table = @{} + foreach ($Property in ($Object ?? [PSCustomObject]@{}).PSObject.Properties) { $Table[$Property.Name] = $Property.Value } + $Table + } + + $PolicyName = "$($Current.policyName)" + if ([string]::IsNullOrWhiteSpace($PolicyName)) { throw 'ExoPolicyRule: the prepare hook resolved no policy name.' } + + $PolicyParams = & $ToHashtable $Remediate.policyParams + if ($Current.policyExists -eq $true) { + $PolicyParams['Identity'] = $PolicyName + $null = New-ExoRequest -tenantid $TenantFilter -cmdlet "Set-$($Remediate.policyCmdlet)" -cmdParams $PolicyParams -UseSystemMailbox $true + } else { + $PolicyParams['Name'] = $PolicyName + $null = New-ExoRequest -tenantid $TenantFilter -cmdlet "New-$($Remediate.policyCmdlet)" -cmdParams $PolicyParams -UseSystemMailbox $true + } + + # A built-in policy owns its own scoping and rejects a rule pointing at it. + if ($Remediate.skipRule -eq $true -or $Current.skipRule -eq $true) { return } + + $RuleName = "$($Current.ruleName)" + if ([string]::IsNullOrWhiteSpace($RuleName)) { throw 'ExoPolicyRule: the prepare hook resolved no rule name.' } + + $RuleParams = & $ToHashtable $Remediate.ruleParams + $RuleParams['RecipientDomainIs'] = @($Current.acceptedDomains) + # Only re-point the rule when it is not already on the right policy - the classic + # standards omitted the parameter otherwise. + if ("$($Current.ruleLinkedPolicy)" -ne $PolicyName) { $RuleParams[$Remediate.policyCmdlet] = $PolicyName } + + if ($Current.ruleExists -eq $true) { + $RuleParams['Identity'] = $RuleName + $null = New-ExoRequest -tenantid $TenantFilter -cmdlet "Set-$($Remediate.ruleCmdlet)" -cmdParams $RuleParams -UseSystemMailbox $true + } else { + $RuleParams['Name'] = $RuleName + $null = New-ExoRequest -tenantid $TenantFilter -cmdlet "New-$($Remediate.ruleCmdlet)" -cmdParams $RuleParams -UseSystemMailbox $true + } +} diff --git a/backend/Tests/Baselines/BaselinePrepareHooks.Tests.ps1 b/backend/Tests/Baselines/BaselinePrepareHooks.Tests.ps1 index 57544d1d27..ffe145e0cf 100644 --- a/backend/Tests/Baselines/BaselinePrepareHooks.Tests.ps1 +++ b/backend/Tests/Baselines/BaselinePrepareHooks.Tests.ps1 @@ -307,3 +307,63 @@ Describe 'Get-CIPPBaselineQuarantineRequestAlertState' { (Get-CIPPBaselineQuarantineRequestAlertState -Item $script:Item -TenantFilter $script:Tenant).Current | Should -BeNullOrEmpty } } + +Describe 'Get-CIPPBaselineSafeAttachmentPolicyState' { + # Legacy name adoption is the part that bites: if the hook does not adopt the name the + # tenant already carries, remediation creates a SECOND policy instead of updating the one + # that exists, and both then fight over the same rule. + BeforeAll { + . (Join-Path $Baselines 'Get-CIPPBaselineSafeAttachmentPolicyState.ps1') + $script:Domains = @([PSCustomObject]@{ Name = 'contoso.com' }, [PSCustomObject]@{ Name = 'contoso.mail.onmicrosoft.com' }) + function New-Item2 { param($Name, $Policy) [PSCustomObject]@{ Name = $Name; SafeAttachmentPolicy = $Policy; Priority = 0; RecipientDomainIs = @('contoso.com', 'contoso.mail.onmicrosoft.com') } } + $script:Item = [PSCustomObject]@{ Variables = [PSCustomObject]@{ + name = 'CIPP Default Safe Attachment Policy'; SafeAttachmentAction = 'Block' + QuarantineTag = 'AdminOnlyAccessPolicy'; Redirect = $false } } + } + BeforeEach { + Mock Get-CIPPBaselineCacheRows { + switch ($Type) { + 'ExoSafeAttachmentPolicies' { $script:Policies } + 'ExoSafeAttachmentRules' { $script:Rules } + 'ExoAcceptedDomains' { $script:Domains } + } + } + $script:Policies = @([PSCustomObject]@{ Name = 'CIPP Default Safe Attachment Policy'; Enable = $true; Action = 'Block'; QuarantineTag = 'AdminOnlyAccessPolicy'; Redirect = $false }) + $script:Rules = @(New-Item2 -Name 'CIPP Default Safe Attachment Policy Rule' -Policy 'CIPP Default Safe Attachment Policy') + } + + It 'adopts a legacy Microsoft default name instead of creating a second policy' { + $script:Policies = @([PSCustomObject]@{ Name = 'Default Safe Attachment Policy'; Enable = $true; Action = 'Block'; QuarantineTag = 'AdminOnlyAccessPolicy'; Redirect = $false }) + $script:Rules = @() + $Prepared = Get-CIPPBaselineSafeAttachmentPolicyState -Item $script:Item -TenantFilter $script:Tenant + $Prepared.Current.policyName | Should -Be 'Default Safe Attachment Policy' + $Prepared.Current.policyExists | Should -BeTrue + } + + It 'uses the configured name when the tenant has no policy at all' { + $script:Policies = @([PSCustomObject]@{ Name = 'Something unrelated' }) + $script:Rules = @() + $Prepared = Get-CIPPBaselineSafeAttachmentPolicyState -Item $script:Item -TenantFilter $script:Tenant + $Prepared.Current.policyName | Should -Be 'CIPP Default Safe Attachment Policy' + $Prepared.Current.policyExists | Should -BeFalse + } + + It 'is compliant when policy and rule both match' { + $Prepared = Get-CIPPBaselineSafeAttachmentPolicyState -Item $script:Item -TenantFilter $script:Tenant + (Get-Verdict -Expected $Prepared.Expected -Current $Prepared.Current).Count | Should -Be 0 + } + + It 'reports drift when only the RULE is wrong' { + # The whole reason the rule joins the compare: the classic remediated it independently, + # and a compare that ignored it would never trigger the write. + $script:Rules = @(New-Item2 -Name 'CIPP Default Safe Attachment Policy Rule' -Policy 'CIPP Default Safe Attachment Policy') + $script:Rules[0].RecipientDomainIs = @('contoso.com') + $Prepared = Get-CIPPBaselineSafeAttachmentPolicyState -Item $script:Item -TenantFilter $script:Tenant + (Get-Verdict -Expected $Prepared.Expected -Current $Prepared.Current).Count | Should -BeGreaterThan 0 + } + + It 'does not grade RedirectAddress when none is configured' { + $Prepared = Get-CIPPBaselineSafeAttachmentPolicyState -Item $script:Item -TenantFilter $script:Tenant + $Prepared.Expected.PSObject.Properties.Name | Should -Not -Contain 'redirectAddress' + } +} From 6f99ca52a628c81704e8ca6132e4861041819031 Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Sun, 16 Aug 2026 01:31:09 +0200 Subject: [PATCH 084/226] more converted baselines --- .../Intune Standards/AutopilotStatusPage.json | 89 +++++++ .../DefaultPlatformRestrictions.json | 110 +++++++++ ...tWindowsHelloForBusinessConfiguration.json | 220 ++++++++++++++++++ .../WindowsBackupRestore.json | 53 +++++ ...t-CIPPBaselineAutopilotStatusPageState.ps1 | 61 +++++ ...selineDefaultPlatformRestrictionsState.ps1 | 54 +++++ ...dowsHelloForBusinessConfigurationState.ps1 | 73 ++++++ ...-CIPPBaselineWindowsBackupRestoreState.ps1 | 31 +++ ...PBaselineDeviceEnrollmentConfiguration.ps1 | 41 ++++ 9 files changed, 732 insertions(+) create mode 100644 backend/Config/BaselineStandards/Intune Standards/AutopilotStatusPage.json create mode 100644 backend/Config/BaselineStandards/Intune Standards/DefaultPlatformRestrictions.json create mode 100644 backend/Config/BaselineStandards/Intune Standards/EnrollmentWindowsHelloForBusinessConfiguration.json create mode 100644 backend/Config/BaselineStandards/Intune Standards/WindowsBackupRestore.json create mode 100644 backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineAutopilotStatusPageState.ps1 create mode 100644 backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineDefaultPlatformRestrictionsState.ps1 create mode 100644 backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineEnrollmentWindowsHelloForBusinessConfigurationState.ps1 create mode 100644 backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineWindowsBackupRestoreState.ps1 create mode 100644 backend/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineDeviceEnrollmentConfiguration.ps1 diff --git a/backend/Config/BaselineStandards/Intune Standards/AutopilotStatusPage.json b/backend/Config/BaselineStandards/Intune Standards/AutopilotStatusPage.json new file mode 100644 index 0000000000..5be8f5ab75 --- /dev/null +++ b/backend/Config/BaselineStandards/Intune Standards/AutopilotStatusPage.json @@ -0,0 +1,89 @@ +{ + "name": "AutopilotStatusPage", + "label": "Enrollment Status Page settings", + "cat": "Intune Standards", + "tag": [], + "impact": "Medium Impact", + "helpText": "Configures the default Enrollment Status Page shown while a device is being set up.", + "executiveText": "Controls what employees see while a new device configures itself, including whether they can use the device before setup finishes and what happens if it fails. This sets expectations during onboarding and reduces support calls.", + "docsDescription": "Sets the default (priority 0) Windows Enrollment Status Page configuration.", + "impactColour": "warning", + "addedDate": "2023-12-20", + "powershellEquivalent": "Graph API", + "recommendedBy": [], + "requiredCapabilities": [ + "INTUNE_A", + "MDM_Services", + "EMS", + "SCCM", + "MICROSOFTINTUNEPLAN1" + ], + "secureScoreImpact": 0, + "compare": "subset", + "variables": { + "TimeOutInMinutes": { + "type": "number", + "label": "Timeout in minutes", + "required": true, + "default": 60 + }, + "ErrorMessage": { + "type": "textField", + "label": "Custom error message", + "default": "" + }, + "ShowProgress": { + "type": "switch", + "label": "Show installation progress", + "default": true + }, + "EnableLog": { + "type": "switch", + "label": "Allow log collection on failure", + "default": true + }, + "OBEEOnly": { + "type": "switch", + "label": "Only show during out-of-box experience (Autopilot only)", + "default": true + }, + "BlockDevice": { + "type": "switch", + "label": "Block device usage during setup", + "default": true + }, + "InstallWindowsUpdates": { + "type": "switch", + "label": "Install Windows quality updates", + "default": false + }, + "AllowReset": { + "type": "switch", + "label": "Allow device reset on failure", + "default": false + }, + "AllowFail": { + "type": "switch", + "label": "Allow device use on failure", + "default": false + } + }, + "read": { + "cacheType": "DeviceEnrollmentConfigurations" + }, + "prepare": "Get-CIPPBaselineAutopilotStatusPageState", + "remediate": { + "executor": "DeviceEnrollmentConfiguration", + "body": { + "@odata.type": "#microsoft.graph.windows10EnrollmentCompletionPageConfiguration", + "installProgressTimeoutInMinutes": "%TimeOutInMinutes%", + "customErrorMessage": "%ErrorMessage%", + "showInstallationProgress": "%ShowProgress%", + "allowLogCollectionOnInstallFailure": "%EnableLog%", + "trackInstallProgressForAutopilotOnly": "%OBEEOnly%", + "installQualityUpdates": "%InstallWindowsUpdates%", + "allowDeviceResetOnInstallFailure": "%AllowReset%", + "allowDeviceUseOnInstallFailure": "%AllowFail%" + } + } +} diff --git a/backend/Config/BaselineStandards/Intune Standards/DefaultPlatformRestrictions.json b/backend/Config/BaselineStandards/Intune Standards/DefaultPlatformRestrictions.json new file mode 100644 index 0000000000..fc6d8b5ac2 --- /dev/null +++ b/backend/Config/BaselineStandards/Intune Standards/DefaultPlatformRestrictions.json @@ -0,0 +1,110 @@ +{ + "name": "DefaultPlatformRestrictions", + "label": "Set Default Platform Restrictions", + "cat": "Intune Standards", + "tag": [], + "impact": "High Impact", + "helpText": "Sets the default enrollment platform restrictions, controlling which device platforms may enroll and whether personally-owned devices of each platform are allowed.", + "executiveText": "Controls which kinds of device can enrol into management, and whether employees may enrol personal devices. This keeps unmanaged or unsupported platforms off corporate resources.", + "docsDescription": "Sets the default device enrollment platform restrictions for Android, Android for Work, iOS, macOS and Windows.", + "impactColour": "warning", + "addedDate": "2024-07-15", + "powershellEquivalent": "Graph API", + "recommendedBy": [], + "requiredCapabilities": [ + "INTUNE_A", + "MDM_Services", + "EMS", + "SCCM", + "MICROSOFTINTUNEPLAN1" + ], + "secureScoreImpact": 0, + "compare": "subset", + "variables": { + "platformAndroidForWorkBlocked": { + "type": "switch", + "label": "Block Android (work profile) platform", + "default": false + }, + "personalAndroidForWorkBlocked": { + "type": "switch", + "label": "Block personally owned Android (work profile)", + "default": false + }, + "platformAndroidBlocked": { + "type": "switch", + "label": "Block Android (device administrator) platform", + "default": false + }, + "personalAndroidBlocked": { + "type": "switch", + "label": "Block personally owned Android (device administrator)", + "default": false + }, + "platformiOSBlocked": { + "type": "switch", + "label": "Block iOS platform", + "default": false + }, + "personaliOSBlocked": { + "type": "switch", + "label": "Block personally owned iOS", + "default": false + }, + "platformMacOSBlocked": { + "type": "switch", + "label": "Block macOS platform", + "default": false + }, + "personalMacOSBlocked": { + "type": "switch", + "label": "Block personally owned macOS", + "default": false + }, + "platformWindowsBlocked": { + "type": "switch", + "label": "Block Windows platform", + "default": false + }, + "personalWindowsBlocked": { + "type": "switch", + "label": "Block personally owned Windows", + "default": false + } + }, + "read": { + "cacheType": "DeviceEnrollmentConfigurations" + }, + "prepare": "Get-CIPPBaselineDefaultPlatformRestrictionsState", + "remediate": { + "executor": "DeviceEnrollmentConfiguration", + "body": { + "@odata.type": "#microsoft.graph.deviceEnrollmentPlatformRestrictionsConfiguration", + "androidForWorkRestriction": { + "@odata.type": "microsoft.graph.deviceEnrollmentPlatformRestriction", + "platformBlocked": "%platformAndroidForWorkBlocked%", + "personalDeviceEnrollmentBlocked": "%personalAndroidForWorkBlocked%" + }, + "androidRestriction": { + "@odata.type": "microsoft.graph.deviceEnrollmentPlatformRestriction", + "platformBlocked": "%platformAndroidBlocked%", + "personalDeviceEnrollmentBlocked": "%personalAndroidBlocked%" + }, + "iosRestriction": { + "@odata.type": "microsoft.graph.deviceEnrollmentPlatformRestriction", + "platformBlocked": "%platformiOSBlocked%", + "personalDeviceEnrollmentBlocked": "%personaliOSBlocked%" + }, + "macOSRestriction": { + "@odata.type": "microsoft.graph.deviceEnrollmentPlatformRestriction", + "platformBlocked": "%platformMacOSBlocked%", + "personalDeviceEnrollmentBlocked": "%personalMacOSBlocked%" + }, + "windowsRestriction": { + "@odata.type": "microsoft.graph.deviceEnrollmentPlatformRestriction", + "platformBlocked": "%platformWindowsBlocked%", + "personalDeviceEnrollmentBlocked": "%personalWindowsBlocked%" + } + } + } +} diff --git a/backend/Config/BaselineStandards/Intune Standards/EnrollmentWindowsHelloForBusinessConfiguration.json b/backend/Config/BaselineStandards/Intune Standards/EnrollmentWindowsHelloForBusinessConfiguration.json new file mode 100644 index 0000000000..711008ff7d --- /dev/null +++ b/backend/Config/BaselineStandards/Intune Standards/EnrollmentWindowsHelloForBusinessConfiguration.json @@ -0,0 +1,220 @@ +{ + "name": "EnrollmentWindowsHelloForBusinessConfiguration", + "label": "Enrollment Windows Hello for Business configuration", + "cat": "Intune Standards", + "tag": [], + "impact": "Medium Impact", + "helpText": "Configures the default Windows Hello for Business enrollment settings, including PIN complexity and biometric unlock.", + "executiveText": "Sets how employees sign in to Windows devices with a PIN or biometrics instead of a password, which is both faster for them and harder for an attacker to steal.", + "docsDescription": "Sets the default Windows Hello for Business device enrollment configuration.", + "impactColour": "warning", + "addedDate": "2024-06-05", + "powershellEquivalent": "Graph API", + "recommendedBy": [], + "requiredCapabilities": [ + "INTUNE_A", + "MDM_Services", + "EMS", + "SCCM", + "MICROSOFTINTUNEPLAN1" + ], + "secureScoreImpact": 0, + "compare": "subset", + "variables": { + "state": { + "type": "autoComplete", + "multiple": false, + "label": "Windows Hello for Business state", + "required": true, + "options": [ + { + "label": "enabled", + "value": "enabled" + }, + { + "label": "disabled", + "value": "disabled" + }, + { + "label": "notConfigured", + "value": "notConfigured" + } + ], + "default": "enabled" + }, + "pinMinimumLength": { + "type": "number", + "label": "PIN minimum length", + "required": true, + "default": 6 + }, + "pinMaximumLength": { + "type": "number", + "label": "PIN maximum length", + "required": true, + "default": 127 + }, + "pinUppercaseCharactersUsage": { + "type": "autoComplete", + "multiple": false, + "label": "PIN uppercase characters", + "required": true, + "options": [ + { + "label": "allowed", + "value": "allowed" + }, + { + "label": "required", + "value": "required" + }, + { + "label": "disallowed", + "value": "disallowed" + } + ], + "default": "disallowed" + }, + "pinLowercaseCharactersUsage": { + "type": "autoComplete", + "multiple": false, + "label": "PIN lowercase characters", + "required": true, + "options": [ + { + "label": "allowed", + "value": "allowed" + }, + { + "label": "required", + "value": "required" + }, + { + "label": "disallowed", + "value": "disallowed" + } + ], + "default": "disallowed" + }, + "pinSpecialCharactersUsage": { + "type": "autoComplete", + "multiple": false, + "label": "PIN special characters", + "required": true, + "options": [ + { + "label": "allowed", + "value": "allowed" + }, + { + "label": "required", + "value": "required" + }, + { + "label": "disallowed", + "value": "disallowed" + } + ], + "default": "disallowed" + }, + "securityDeviceRequired": { + "type": "switch", + "label": "Require a TPM", + "default": true + }, + "unlockWithBiometricsEnabled": { + "type": "switch", + "label": "Allow biometric unlock", + "default": true + }, + "remotePassportEnabled": { + "type": "switch", + "label": "Allow remote passport (phone sign-in)", + "default": true + }, + "pinPreviousBlockCount": { + "type": "number", + "label": "Remember PIN history", + "required": true, + "default": 0 + }, + "pinExpirationInDays": { + "type": "number", + "label": "PIN expiration in days", + "required": true, + "default": 0 + }, + "enhancedBiometricsState": { + "type": "autoComplete", + "multiple": false, + "label": "Enhanced anti-spoofing for facial recognition", + "required": true, + "options": [ + { + "label": "enabled", + "value": "enabled" + }, + { + "label": "disabled", + "value": "disabled" + }, + { + "label": "notConfigured", + "value": "notConfigured" + } + ], + "default": "notConfigured" + }, + "enhancedSignInSecurity": { + "type": "number", + "label": "Enhanced sign-in security", + "omitWhenBlank": true, + "default": "" + }, + "securityKeyForSignIn": { + "type": "autoComplete", + "multiple": false, + "label": "Security key for sign-in", + "omitWhenBlank": true, + "default": "", + "options": [ + { + "label": "Enabled", + "value": "enabled" + }, + { + "label": "Disabled", + "value": "disabled" + }, + { + "label": "Not configured", + "value": "notConfigured" + } + ] + } + }, + "read": { + "cacheType": "DeviceEnrollmentConfigurations" + }, + "prepare": "Get-CIPPBaselineEnrollmentWindowsHelloForBusinessConfigurationState", + "remediate": { + "executor": "DeviceEnrollmentConfiguration", + "body": { + "@odata.type": "#microsoft.graph.deviceEnrollmentWindowsHelloForBusinessConfiguration", + "state": "%state%", + "pinMinimumLength": "%pinMinimumLength%", + "pinMaximumLength": "%pinMaximumLength%", + "pinUppercaseCharactersUsage": "%pinUppercaseCharactersUsage%", + "pinLowercaseCharactersUsage": "%pinLowercaseCharactersUsage%", + "pinSpecialCharactersUsage": "%pinSpecialCharactersUsage%", + "securityDeviceRequired": "%securityDeviceRequired%", + "unlockWithBiometricsEnabled": "%unlockWithBiometricsEnabled%", + "remotePassportEnabled": "%remotePassportEnabled%", + "pinPreviousBlockCount": "%pinPreviousBlockCount%", + "pinExpirationInDays": "%pinExpirationInDays%", + "enhancedBiometricsState": "%enhancedBiometricsState%", + "enhancedSignInSecurity": "%enhancedSignInSecurity%", + "securityKeyForSignIn": "%securityKeyForSignIn%" + } + } +} diff --git a/backend/Config/BaselineStandards/Intune Standards/WindowsBackupRestore.json b/backend/Config/BaselineStandards/Intune Standards/WindowsBackupRestore.json new file mode 100644 index 0000000000..722b1946cc --- /dev/null +++ b/backend/Config/BaselineStandards/Intune Standards/WindowsBackupRestore.json @@ -0,0 +1,53 @@ +{ + "name": "WindowsBackupRestore", + "label": "Set Windows Backup and Restore state", + "cat": "Intune Standards", + "tag": [], + "impact": "Low Impact", + "helpText": "Enables or disables the Windows Backup and Restore experience during Windows setup.", + "executiveText": "Controls whether employees are offered to restore their previous Windows settings and files when setting up a new device, balancing convenience against a clean managed build.", + "docsDescription": "Sets the state of the Windows Restore device enrollment configuration.", + "impactColour": "info", + "addedDate": "2025-05-19", + "powershellEquivalent": "Graph API", + "recommendedBy": [], + "requiredCapabilities": [ + "INTUNE_A", + "MDM_Services", + "EMS", + "SCCM", + "MICROSOFTINTUNEPLAN1" + ], + "secureScoreImpact": 0, + "compare": "subset", + "variables": { + "state": { + "type": "autoComplete", + "multiple": false, + "label": "Windows Backup and Restore", + "required": true, + "options": [ + { + "label": "Enabled", + "value": "enabled" + }, + { + "label": "Disabled", + "value": "disabled" + } + ], + "default": "disabled" + } + }, + "read": { + "cacheType": "DeviceEnrollmentConfigurations" + }, + "prepare": "Get-CIPPBaselineWindowsBackupRestoreState", + "remediate": { + "executor": "DeviceEnrollmentConfiguration", + "body": { + "@odata.type": "#microsoft.graph.windowsRestoreDeviceEnrollmentConfiguration", + "state": "%state%" + } + } +} diff --git a/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineAutopilotStatusPageState.ps1 b/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineAutopilotStatusPageState.ps1 new file mode 100644 index 0000000000..2f42facaf2 --- /dev/null +++ b/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineAutopilotStatusPageState.ps1 @@ -0,0 +1,61 @@ +function Get-CIPPBaselineAutopilotStatusPageState { + <# + .SYNOPSIS + Prepare hook for AutopilotStatusPage: the default Enrollment Status Page. + .DESCRIPTION + Selected by type AND priority 0 - that pair identifies the default ESP, and any other + priority is a targeted page somebody created deliberately. + + Two values are computed rather than read straight through, both carried verbatim from + the classic standard: + + blockDeviceSetupRetryByUser is the INVERSE of the operator's 'Block device usage + during setup' switch. A %token% cannot negate, which is why this is a hook. + + installQualityUpdates falls back to false when unset, preserving the v8.3.0 + back-compat default for baselines saved before that field existed. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Item, + $TenantFilter + ) + + $Configurations = @(Get-CIPPBaselineCacheRows -TenantFilter $TenantFilter -Type 'DeviceEnrollmentConfigurations') + if ($Configurations.Count -eq 0) { return @{ Current = $null } } + + $Config = @($Configurations | Where-Object { + "$($_.deviceEnrollmentConfigurationType)" -eq 'windows10EnrollmentCompletionPageConfiguration' -and + [int]$_.priority -eq 0 + }) | Select-Object -First 1 + if (-not $Config) { return @{ Current = $null } } + + $V = $Item.Variables + $Expected = [PSCustomObject]@{ + installProgressTimeoutInMinutes = [int]"$($V.TimeOutInMinutes)" + customErrorMessage = "$($V.ErrorMessage)" + showInstallationProgress = [bool]($V.ShowProgress -eq $true) + allowLogCollectionOnInstallFailure = [bool]($V.EnableLog -eq $true) + trackInstallProgressForAutopilotOnly = [bool]($V.OBEEOnly -eq $true) + blockDeviceSetupRetryByUser = -not [bool]($V.BlockDevice -eq $true) + installQualityUpdates = [bool]($V.InstallWindowsUpdates -eq $true) + allowDeviceResetOnInstallFailure = [bool]($V.AllowReset -eq $true) + allowDeviceUseOnInstallFailure = [bool]($V.AllowFail -eq $true) + } + $Current = [PSCustomObject]@{ + installProgressTimeoutInMinutes = $(if ($null -eq $Config.installProgressTimeoutInMinutes) { -1 } else { [int]$Config.installProgressTimeoutInMinutes }) + customErrorMessage = "$($Config.customErrorMessage)" + showInstallationProgress = [bool]$Config.showInstallationProgress + allowLogCollectionOnInstallFailure = [bool]$Config.allowLogCollectionOnInstallFailure + trackInstallProgressForAutopilotOnly = [bool]$Config.trackInstallProgressForAutopilotOnly + blockDeviceSetupRetryByUser = [bool]$Config.blockDeviceSetupRetryByUser + installQualityUpdates = [bool]$Config.installQualityUpdates + allowDeviceResetOnInstallFailure = [bool]$Config.allowDeviceResetOnInstallFailure + allowDeviceUseOnInstallFailure = [bool]$Config.allowDeviceUseOnInstallFailure + configurationId = "$($Config.id)" + } + + @{ Expected = $Expected; Current = $Current } +} diff --git a/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineDefaultPlatformRestrictionsState.ps1 b/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineDefaultPlatformRestrictionsState.ps1 new file mode 100644 index 0000000000..d6f3d9d9d2 --- /dev/null +++ b/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineDefaultPlatformRestrictionsState.ps1 @@ -0,0 +1,54 @@ +function Get-CIPPBaselineDefaultPlatformRestrictionsState { + <# + .SYNOPSIS + Prepare hook for DefaultPlatformRestrictions: the default enrollment platform + restrictions. + .DESCRIPTION + Selected by an id SUFFIX rather than by type, and that is deliberate. The classic + standard's own comment records why: Graph reports this object's + deviceEnrollmentConfigurationType as either platformRestrictions or + singlePlatformRestriction for the SAME object depending on how it was queried, so the + type is not a reliable selector. The id always ends '_DefaultPlatformRestrictions'. + + Each platform contributes two booleans - whether the platform is blocked outright, and + whether personally-owned devices of that platform are blocked - flattened here so the + drift row names the platform that differs. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Item, + $TenantFilter + ) + + $Configurations = @(Get-CIPPBaselineCacheRows -TenantFilter $TenantFilter -Type 'DeviceEnrollmentConfigurations') + if ($Configurations.Count -eq 0) { return @{ Current = $null } } + + $Config = @($Configurations | Where-Object { "$($_.id)".EndsWith('_DefaultPlatformRestrictions') }) | Select-Object -First 1 + if (-not $Config) { return @{ Current = $null } } + + $V = $Item.Variables + $Map = @( + @{ e = 'platformAndroidForWorkBlocked'; c = 'androidForWorkRestriction'; p = 'platformBlocked' } + @{ e = 'personalAndroidForWorkBlocked'; c = 'androidForWorkRestriction'; p = 'personalDeviceEnrollmentBlocked' } + @{ e = 'platformAndroidBlocked'; c = 'androidRestriction'; p = 'platformBlocked' } + @{ e = 'personalAndroidBlocked'; c = 'androidRestriction'; p = 'personalDeviceEnrollmentBlocked' } + @{ e = 'platformiOSBlocked'; c = 'iosRestriction'; p = 'platformBlocked' } + @{ e = 'personaliOSBlocked'; c = 'iosRestriction'; p = 'personalDeviceEnrollmentBlocked' } + @{ e = 'platformMacOSBlocked'; c = 'macOSRestriction'; p = 'platformBlocked' } + @{ e = 'personalMacOSBlocked'; c = 'macOSRestriction'; p = 'personalDeviceEnrollmentBlocked' } + @{ e = 'platformWindowsBlocked'; c = 'windowsRestriction'; p = 'platformBlocked' } + @{ e = 'personalWindowsBlocked'; c = 'windowsRestriction'; p = 'personalDeviceEnrollmentBlocked' } + ) + + $Expected = [PSCustomObject]@{} + $Current = [PSCustomObject]@{} + foreach ($Entry in $Map) { + $Expected | Add-Member -NotePropertyName $Entry.e -NotePropertyValue ([bool]($V.($Entry.e) -eq $true)) + $Current | Add-Member -NotePropertyName $Entry.e -NotePropertyValue ([bool]$Config.($Entry.c).($Entry.p)) + } + $Current | Add-Member -NotePropertyName 'configurationId' -NotePropertyValue "$($Config.id)" + + @{ Expected = $Expected; Current = $Current } +} diff --git a/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineEnrollmentWindowsHelloForBusinessConfigurationState.ps1 b/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineEnrollmentWindowsHelloForBusinessConfigurationState.ps1 new file mode 100644 index 0000000000..be6526dd53 --- /dev/null +++ b/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineEnrollmentWindowsHelloForBusinessConfigurationState.ps1 @@ -0,0 +1,73 @@ +function Get-CIPPBaselineEnrollmentWindowsHelloForBusinessConfigurationState { + <# + .SYNOPSIS + Prepare hook for EnrollmentWindowsHelloForBusinessConfiguration: the default WHfB + enrollment configuration. + .DESCRIPTION + The classic standard ordered by priority and took the first row - the default WHfB + configuration - so the same ordering is applied here rather than trusting cache order. + + Two settings are graded only when the operator supplied them, matching the classic + '($null -eq $Settings.X) -or ...' tests: enhancedSignInSecurity and securityKeyForSignIn + are newer fields that older baselines will not carry, and grading them unset would + report drift against a value the baseline never expressed an opinion on. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Item, + $TenantFilter + ) + + $Configurations = @(Get-CIPPBaselineCacheRows -TenantFilter $TenantFilter -Type 'DeviceEnrollmentConfigurations') + if ($Configurations.Count -eq 0) { return @{ Current = $null } } + + $Config = @($Configurations | + Where-Object { "$($_.deviceEnrollmentConfigurationType)" -eq 'windowsHelloForBusiness' } | + Sort-Object -Property { [int]$_.priority }) | Select-Object -First 1 + if (-not $Config) { return @{ Current = $null } } + + $V = $Item.Variables + $Expected = [PSCustomObject]@{ + pinMinimumLength = [int]"$($V.pinMinimumLength)" + pinMaximumLength = [int]"$($V.pinMaximumLength)" + pinUppercaseCharactersUsage = "$($V.pinUppercaseCharactersUsage)" + pinLowercaseCharactersUsage = "$($V.pinLowercaseCharactersUsage)" + pinSpecialCharactersUsage = "$($V.pinSpecialCharactersUsage)" + state = "$($V.state)" + securityDeviceRequired = [bool]($V.securityDeviceRequired -eq $true) + unlockWithBiometricsEnabled = [bool]($V.unlockWithBiometricsEnabled -eq $true) + remotePassportEnabled = [bool]($V.remotePassportEnabled -eq $true) + pinPreviousBlockCount = [int]"$($V.pinPreviousBlockCount)" + pinExpirationInDays = [int]"$($V.pinExpirationInDays)" + enhancedBiometricsState = "$($V.enhancedBiometricsState)" + } + $Current = [PSCustomObject]@{ + pinMinimumLength = $(if ($null -eq $Config.pinMinimumLength) { -1 } else { [int]$Config.pinMinimumLength }) + pinMaximumLength = $(if ($null -eq $Config.pinMaximumLength) { -1 } else { [int]$Config.pinMaximumLength }) + pinUppercaseCharactersUsage = "$($Config.pinUppercaseCharactersUsage)" + pinLowercaseCharactersUsage = "$($Config.pinLowercaseCharactersUsage)" + pinSpecialCharactersUsage = "$($Config.pinSpecialCharactersUsage)" + state = "$($Config.state)" + securityDeviceRequired = [bool]$Config.securityDeviceRequired + unlockWithBiometricsEnabled = [bool]$Config.unlockWithBiometricsEnabled + remotePassportEnabled = [bool]$Config.remotePassportEnabled + pinPreviousBlockCount = $(if ($null -eq $Config.pinPreviousBlockCount) { -1 } else { [int]$Config.pinPreviousBlockCount }) + pinExpirationInDays = $(if ($null -eq $Config.pinExpirationInDays) { -1 } else { [int]$Config.pinExpirationInDays }) + enhancedBiometricsState = "$($Config.enhancedBiometricsState)" + } + + if (-not [string]::IsNullOrWhiteSpace("$($V.enhancedSignInSecurity)")) { + $Expected | Add-Member -NotePropertyName 'enhancedSignInSecurity' -NotePropertyValue ([int]"$($V.enhancedSignInSecurity)") + $Current | Add-Member -NotePropertyName 'enhancedSignInSecurity' -NotePropertyValue $(if ($null -eq $Config.enhancedSignInSecurity) { -1 } else { [int]$Config.enhancedSignInSecurity }) + } + if (-not [string]::IsNullOrWhiteSpace("$($V.securityKeyForSignIn)")) { + $Expected | Add-Member -NotePropertyName 'securityKeyForSignIn' -NotePropertyValue "$($V.securityKeyForSignIn)" + $Current | Add-Member -NotePropertyName 'securityKeyForSignIn' -NotePropertyValue "$($Config.securityKeyForSignIn)" + } + + $Current | Add-Member -NotePropertyName 'configurationId' -NotePropertyValue "$($Config.id)" + + @{ Expected = $Expected; Current = $Current } +} diff --git a/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineWindowsBackupRestoreState.ps1 b/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineWindowsBackupRestoreState.ps1 new file mode 100644 index 0000000000..cb0583f716 --- /dev/null +++ b/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineWindowsBackupRestoreState.ps1 @@ -0,0 +1,31 @@ +function Get-CIPPBaselineWindowsBackupRestoreState { + <# + .SYNOPSIS + Prepare hook for WindowsBackupRestore: the Windows Restore enrollment configuration. + .DESCRIPTION + Selected by deviceEnrollmentConfigurationType rather than by a fixed id, because the + id carries the tenant's Intune account GUID. The row's id rides along on Current for + the executor. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Item, + $TenantFilter + ) + + $Configurations = @(Get-CIPPBaselineCacheRows -TenantFilter $TenantFilter -Type 'DeviceEnrollmentConfigurations') + if ($Configurations.Count -eq 0) { return @{ Current = $null } } + + $Config = @($Configurations | Where-Object { "$($_.deviceEnrollmentConfigurationType)" -eq 'windowsRestore' }) | Select-Object -First 1 + if (-not $Config) { return @{ Current = $null } } + + @{ + Expected = [PSCustomObject]@{ state = "$($Item.Variables.state)" } + Current = [PSCustomObject]@{ + state = "$($Config.state)" + configurationId = "$($Config.id)" + } + } +} diff --git a/backend/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineDeviceEnrollmentConfiguration.ps1 b/backend/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineDeviceEnrollmentConfiguration.ps1 new file mode 100644 index 0000000000..6e867a6166 --- /dev/null +++ b/backend/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineDeviceEnrollmentConfiguration.ps1 @@ -0,0 +1,41 @@ +function Invoke-CIPPBaselineDeviceEnrollmentConfiguration { + <# + .SYNOPSIS + DeviceEnrollmentConfiguration executor: PATCHes one enrollment configuration by the id + its prepare hook discovered. + .DESCRIPTION + Every configuration in this family lives at + deviceManagement/deviceEnrollmentConfigurations/{id}, and the id is per tenant - it + carries the Intune account GUID as a prefix, so no token can render it and no static + uri can address it. The prepare hook has already found the right row while reading the + cache, and passes it on -Current as configurationId. Nothing is re-resolved here: a + second lookup could pick a different row than the one the compare graded. + + Writes are DELEGATED by default, matching every standard in this family - the classic + code either omitted -AsApp or set it to $false explicitly. A definition can override + per write with asApp, but none currently needs to. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Remediate, + $TenantFilter, + $Current + ) + + $ConfigurationId = "$($Current.configurationId)" + if ([string]::IsNullOrWhiteSpace($ConfigurationId)) { + throw 'DeviceEnrollmentConfiguration: the prepare hook found no configuration to write to on this tenant.' + } + if (@(($Remediate.body ?? [PSCustomObject]@{}).PSObject.Properties).Count -eq 0) { + throw 'DeviceEnrollmentConfiguration: nothing configured to write.' + } + + $null = New-GraphPostRequest -tenantid $TenantFilter ` + -uri "https://graph.microsoft.com/beta/deviceManagement/deviceEnrollmentConfigurations/$ConfigurationId" ` + -Type PATCH ` + -Body (ConvertTo-Json -Compress -Depth 20 -InputObject $Remediate.body) ` + -ContentType 'application/json; charset=utf-8' ` + -AsApp ([bool]($Remediate.asApp -eq $true)) +} From c2ba5a96bda50f8de31f63086b041f6f716bde61 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Sat, 15 Aug 2026 19:39:04 -0400 Subject: [PATCH 085/226] fix(ui): prevent mobile horizontal overflow Fix multiple components that caused horizontal page scroll on narrow viewports: - CippChartCard: add minWidth: 0 to flex legend rows so long API labels (URLs, email addresses) shrink instead of overflowing - CippImageCard: switch to responsive column/row direction and cap skeleton/image widths - CippMap: replace fixed 600px width with 100%/maxWidth so the map fits its grid cell - CippMessageViewer: wrap rendered HTML in an overflowX: auto box to contain fixed-width marketing table layouts - CippVariableAutocomplete: clamp popper min/max width to viewport using CSS min() - CippSSOSettings: wrap permission Table in TableContainer and add overflowWrap: anywhere to monospace names Adds Storybook play-test stories for each fix. --- .../components/CippCards/CippChartCard.jsx | 19 +- .../components/CippCards/CippImageCard.jsx | 15 +- .../src/components/CippComponents/CippMap.jsx | 4 +- .../CippComponents/CippMessageViewer.jsx | 12 +- .../CippVariableAutocomplete.jsx | 8 +- .../CippSettings/CippSSOSettings.jsx | 60 ++++--- .../CippCards/mobile-overflow.stories.jsx | 167 ++++++++++++++++++ 7 files changed, 248 insertions(+), 37 deletions(-) create mode 100644 frontend/tests/components/CippCards/mobile-overflow.stories.jsx diff --git a/frontend/src/components/CippCards/CippChartCard.jsx b/frontend/src/components/CippCards/CippChartCard.jsx index 1782b9e9ac..36a0dd670a 100644 --- a/frontend/src/components/CippCards/CippChartCard.jsx +++ b/frontend/src/components/CippCards/CippChartCard.jsx @@ -197,7 +197,15 @@ export const CippChartCard = ({ spacing={1} sx={{ py: 1 }} > - + {/* minWidth: 0 both here and on the label: labels are API free text + (recipient addresses, SharePoint URLs), and flexbox's min-width: + auto otherwise refuses to shrink them, pushing rows out of the card */} + - + {labels[index]} - + {item} diff --git a/frontend/src/components/CippCards/CippImageCard.jsx b/frontend/src/components/CippCards/CippImageCard.jsx index 3ea40dfb6a..2eb2d21230 100644 --- a/frontend/src/components/CippCards/CippImageCard.jsx +++ b/frontend/src/components/CippCards/CippImageCard.jsx @@ -15,22 +15,24 @@ export const CippImageCard = ({ }) => ( -
    + {title} - {isFetching ? : text} + {isFetching ? : text} {step && maxstep && ( @@ -77,11 +79,14 @@ export const CippImageCard = ({ {linkText} )} -
    + { {darkMode ? : } - {messageHtml} + {/* Sanitized but untrusted layout: marketing mail ships fixed + s, so the message scrolls inside its own + card instead of widening the page body. */} + + {messageHtml} + diff --git a/frontend/src/components/CippComponents/CippVariableAutocomplete.jsx b/frontend/src/components/CippComponents/CippVariableAutocomplete.jsx index 39d1b49c40..07ba551b21 100644 --- a/frontend/src/components/CippComponents/CippVariableAutocomplete.jsx +++ b/frontend/src/components/CippComponents/CippVariableAutocomplete.jsx @@ -277,8 +277,12 @@ export const CippVariableAutocomplete = React.memo( borderRadius: 1, maxHeight: 240, overflow: "auto", - minWidth: 300, - maxWidth: 500, + // Clamped to the viewport: the Paper shrink-to-fits against unclamped variable + // descriptions, and popper.js can only shift a too-wide popper, not shrink it — + // at the 500px cap a phone got ~110px hanging off the right edge, scrolling the + // whole document sideways. + minWidth: "min(300px, calc(100vw - 32px))", + maxWidth: "min(500px, calc(100vw - 32px))", }} onClick={(e) => { e.stopPropagation(); diff --git a/frontend/src/components/CippSettings/CippSSOSettings.jsx b/frontend/src/components/CippSettings/CippSSOSettings.jsx index 8d7413f34f..a4ae583542 100644 --- a/frontend/src/components/CippSettings/CippSSOSettings.jsx +++ b/frontend/src/components/CippSettings/CippSSOSettings.jsx @@ -15,6 +15,7 @@ import { Table, TableBody, TableCell, + TableContainer, TableHead, TableRow, Typography, @@ -67,32 +68,41 @@ const samPermissionsUsed = [ }, ]; -const PermissionTable = ({ rows, typeLabel }) => ( -
    - - - Permission - Why it is needed - - - - {rows.map((row) => ( - - - - {row.name} - - - {typeLabel} - - - - {row.reason} - +// Exported for the phone-width overflow story: readable consent text is this table's job. +export const PermissionTable = ({ rows, typeLabel }) => ( + // TableContainer: the surrounding Card sets overflow: hidden, which cut this table off + // with no scroll path — an admin could not read the permission they were asked to approve. + // The monospace names also break, so a phone rarely needs the scrollbar at all. + +
    + + + Permission + Why it is needed - ))} - -
    + + + {rows.map((row) => ( + + + + {row.name} + + + {typeLabel} + + + + {row.reason} + + + ))} + + + ); const statusLabels = { diff --git a/frontend/tests/components/CippCards/mobile-overflow.stories.jsx b/frontend/tests/components/CippCards/mobile-overflow.stories.jsx new file mode 100644 index 0000000000..4809afc86e --- /dev/null +++ b/frontend/tests/components/CippCards/mobile-overflow.stories.jsx @@ -0,0 +1,167 @@ +import React, { useRef, useState, useEffect } from 'react' +import { Box, Card } from '@mui/material' +import { within, waitFor, expect } from 'storybook/test' +import { CippChartCard } from '../../../src/components/CippCards/CippChartCard' +import { CippImageCard } from '../../../src/components/CippCards/CippImageCard' +import { CippVariableAutocomplete } from '../../../src/components/CippComponents/CippVariableAutocomplete' +import { PermissionTable } from '../../../src/components/CippSettings/CippSSOSettings' +import { shrinkToPhoneViewport } from '../../viewport' + +/** + * Phone-width overflow checks for the shared components the mobile audit found spilling out + * of the viewport. Each story renders the component with the hostile content class that + * broke it — API free text, fixed-width caps — and asserts the page body gained no sideways + * scroll at 390px. + */ +export default { + title: 'Components/MobileOverflow', + tags: ['autodocs'], +} + +const noBodyOverflow = () => { + const doc = document.documentElement + expect(doc.scrollWidth).toBeLessThanOrEqual(doc.clientWidth) +} + +// Legend labels are API free text — recipient addresses, SharePoint library URLs. Without +// minWidth: 0 flexbox refuses to shrink them and the rows push out of the card. +export const ChartLegendWithUrlLabels = { + render: () => ( + + ), + play: async ({ canvasElement }) => { + const onAPhone = await shrinkToPhoneViewport() + if (!onAPhone) return + const canvas = within(canvasElement) + const label = await canvas.findByText(/finance/, { exact: false }) + await waitFor(() => { + // the count is the row's right-hand cell: an unshrinkable label pushed it past the + // card's clipped edge, where MUI's overflow: hidden ate it without a trace + const card = label.closest('.MuiCard-root') + const count = canvas.getByText('12') + expect(count.getBoundingClientRect().right).toBeLessThanOrEqual( + card.getBoundingClientRect().right + 1 + ) + noBodyOverflow() + }) + }, +} + +// The headline/illustration pair had no breakpoint and no minWidth: 0 — at 390px the text +// column collapsed against the image's intrinsic width. This is the AllTenants interstitial. +export const ImageCardAtPhoneWidth = { + render: () => ( + + ), + play: async ({ canvasElement }) => { + const onAPhone = await shrinkToPhoneViewport() + if (!onAPhone) return + const canvas = within(canvasElement) + const title = await canvas.findByText(/does not support/, { exact: false }) + await waitFor(() => { + // stacked, not squeezed: the old row layout let flexbox settle the fight by + // collapsing the illustration to zero width — "no overflow" while showing nothing + const img = canvasElement.querySelector('img') + const imgBox = img.getBoundingClientRect() + expect(imgBox.top).toBeGreaterThanOrEqual(title.getBoundingClientRect().bottom) + expect(imgBox.width).toBeGreaterThanOrEqual(200) + noBodyOverflow() + }) + }, +} + +const LONG_DESCRIPTION = + 'The primary tenant domain name used for routing and identification across all portals, ' + + 'reports and scheduled tasks — substituted at execution time from the tenant record.' + +const PopperHost = () => { + const anchorRef = useRef(null) + const [anchorEl, setAnchorEl] = useState(null) + useEffect(() => setAnchorEl(anchorRef.current), []) + return ( + +
    + {anchorEl && ( + {}} + onSelect={() => {}} + customVariables={[ + { variable: 'tenantfilter', description: LONG_DESCRIPTION }, + { variable: 'defaultdomainname', description: LONG_DESCRIPTION }, + ]} + /> + )} + + ) +} + +// Sentinel, not a repro: in this browser the absolutely-positioned Paper shrink-to-fits +// inside the viewport even pre-fix, so this story also passed before the clamp. It stands +// guard against a future fixed `width` here. The popper is portaled, so the assertion +// measures against the viewport, not the canvas. +export const VariablePopperStaysOnScreen = { + render: () => , + play: async () => { + const onAPhone = await shrinkToPhoneViewport() + if (!onAPhone) return + await waitFor(() => { + const paper = document.querySelector('[data-cipp-autocomplete="true"]') + expect(paper).not.toBeNull() + const { right, left } = paper.getBoundingClientRect() + expect(left).toBeGreaterThanOrEqual(0) + expect(right).toBeLessThanOrEqual(document.documentElement.clientWidth) + }) + noBodyOverflow() + }, +} + +// Sentinel: in this browser the longest name happens to fit a full-width card even without +// the fix (the audited clip came from the settings page's narrower column and other font +// metrics). Guards the invariant that matters — the permission being consented to is +// readable inside the card, whatever this table is later wrapped in. +export const SsoPermissionTableReadable = { + render: () => ( + + + + ), + play: async ({ canvasElement }) => { + const onAPhone = await shrinkToPhoneViewport() + if (!onAPhone) return + const canvas = within(canvasElement) + const name = await canvas.findByText(/ApplicationConfiguration/, { exact: false }) + await waitFor(() => { + // reachable: the name's box ends inside the card, not under its clipped edge + const card = name.closest('.MuiCard-root') + expect(name.getBoundingClientRect().right).toBeLessThanOrEqual( + card.getBoundingClientRect().right + 1 + ) + noBodyOverflow() + }) + }, +} From 0e6fe3528935c7eb8da318bd3a5d786f98b12fe9 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Sat, 15 Aug 2026 22:23:37 -0400 Subject: [PATCH 086/226] refactor(ui): replace Alert with CippExpandableAlert Swap static MUI Alert components for CippExpandableAlert across multiple pages to allow users to collapse verbose informational and warning messages. Also tightens horizontal padding on settings page containers and CippPageCard to match table page card rhythm on small viewports. --- frontend/src/components/CippCards/CippPageCard.jsx | 6 ++++-- .../src/pages/cipp/advanced/authentication/cipp-users.js | 7 ++++--- .../src/pages/cipp/advanced/container-management/logs.js | 5 +++-- .../pages/cipp/advanced/super-admin/jit-admin-settings.js | 5 +++-- .../src/pages/cipp/custom-data/schema-extensions/index.js | 5 +++-- frontend/src/pages/cipp/settings/backend.js | 2 +- frontend/src/pages/cipp/settings/branding.js | 2 +- frontend/src/pages/cipp/settings/index.js | 2 +- frontend/src/pages/cipp/settings/permissions.js | 2 +- frontend/src/pages/cipp/settings/siem.js | 2 +- frontend/src/pages/teams-share/permissions-report/index.js | 5 +++-- frontend/src/pages/tenant/gdap-management/invites/add.js | 5 +++-- frontend/src/pages/tenant/gdap-management/roles/add.js | 5 +++-- 13 files changed, 31 insertions(+), 22 deletions(-) diff --git a/frontend/src/components/CippCards/CippPageCard.jsx b/frontend/src/components/CippCards/CippPageCard.jsx index 4a35c2b8a0..dd6250a6d7 100644 --- a/frontend/src/components/CippCards/CippPageCard.jsx +++ b/frontend/src/components/CippCards/CippPageCard.jsx @@ -33,8 +33,10 @@ const CippPageCard = (props) => { }} > {/* MUI's Container widens its gutters at sm; every layout in this app switches at - md, so a 600-900px viewport got 24px here and 16px everywhere else. */} - + md, so a 600-900px viewport got 24px here and 16px everywhere else. xs matches + the table pages' card rhythm (12px to a card edge, not 16+16 before any text) — + the card's own CardContent still pays 16 inside. */} + {hideTitleText !== true && !titleClaimed && ( diff --git a/frontend/src/pages/cipp/advanced/authentication/cipp-users.js b/frontend/src/pages/cipp/advanced/authentication/cipp-users.js index ad3b6097c1..54047c0c11 100644 --- a/frontend/src/pages/cipp/advanced/authentication/cipp-users.js +++ b/frontend/src/pages/cipp/advanced/authentication/cipp-users.js @@ -3,14 +3,15 @@ import { Layout as DashboardLayout } from "../../../../layouts/index.js"; import tabOptions from "./tabOptions"; import CippPageCard from "../../../../components/CippCards/CippPageCard"; import { CippUserManagement } from "../../../../components/CippSettings/CippUserManagement"; -import { CardContent, Stack, Alert } from "@mui/material"; +import { CippExpandableAlert } from "../../../../components/CippComponents/CippExpandableAlert"; +import { CardContent, Stack } from "@mui/material"; const Page = () => { return ( - + Manage users who can access CIPP. Users are automatically synced from your partner tenant every 15 minutes based on Entra group memberships configured on the CIPP Roles page. You can also manually add users or assign additional roles — manual assignments @@ -23,7 +24,7 @@ const Page = () => { to access CIPP, you can add them as guest users in your partner tenant and assign them the appropriate roles in CIPP or enable the multi tenant mode in the CIPP SSO tab and add the users to the list below without needing to add them as guest users in your tenant. - + diff --git a/frontend/src/pages/cipp/advanced/container-management/logs.js b/frontend/src/pages/cipp/advanced/container-management/logs.js index ce12cbea87..44f7745e53 100644 --- a/frontend/src/pages/cipp/advanced/container-management/logs.js +++ b/frontend/src/pages/cipp/advanced/container-management/logs.js @@ -23,6 +23,7 @@ import { CippTablePage } from "../../../../components/CippComponents/CippTablePa import { ApiGetCall } from "../../../../api/ApiCall"; import defaultPresets from "../../../../data/ContainerLogPresets.json"; import tabOptions from "./tabOptions"; +import { CippExpandableAlert } from "../../../../components/CippComponents/CippExpandableAlert"; const levelOptions = [ { label: "All Levels", value: "" }, @@ -249,7 +250,7 @@ const ContainerLogsFilter = ({ onSubmitFilter }) => { {tabValue === 0 && ( - + Query Syntax Use a KQL-inspired pipe syntax to filter container logs. Separate clauses with{" "} @@ -279,7 +280,7 @@ const ContainerLogsFilter = ({ onSubmitFilter }) => {
    search all files — include rotated logs
    -
    + diff --git a/frontend/src/pages/cipp/advanced/super-admin/jit-admin-settings.js b/frontend/src/pages/cipp/advanced/super-admin/jit-admin-settings.js index fa6401e9b7..514b5b097c 100644 --- a/frontend/src/pages/cipp/advanced/super-admin/jit-admin-settings.js +++ b/frontend/src/pages/cipp/advanced/super-admin/jit-admin-settings.js @@ -7,6 +7,7 @@ import { Typography, Alert } from "@mui/material"; import { Grid } from "@mui/system"; import CippFormComponent from "../../../../components/CippComponents/CippFormComponent"; import { ApiGetCall } from "../../../../api/ApiCall"; +import { CippExpandableAlert } from "../../../../components/CippComponents/CippExpandableAlert"; import { useEffect } from "react"; const Page = () => { @@ -103,7 +104,7 @@ const Page = () => { - + Important Notes: @@ -121,7 +122,7 @@ const Page = () => {
  • This setting applies globally to all tenants and all JIT admin creations
  • -
    +
    diff --git a/frontend/src/pages/cipp/custom-data/schema-extensions/index.js b/frontend/src/pages/cipp/custom-data/schema-extensions/index.js index fab26e70f5..17faba908d 100644 --- a/frontend/src/pages/cipp/custom-data/schema-extensions/index.js +++ b/frontend/src/pages/cipp/custom-data/schema-extensions/index.js @@ -6,6 +6,7 @@ import { Add, Block, CheckCircleOutline } from "@mui/icons-material"; import tabOptions from "../tabOptions"; import { TrashIcon } from "@heroicons/react/24/outline"; import NextLink from "next/link"; +import { CippExpandableAlert } from "../../../../components/CippComponents/CippExpandableAlert"; const Page = () => { const pageTitle = "Schema Extensions"; @@ -107,7 +108,7 @@ const Page = () => { + {
  • There is a limit of 5 total schema extensions.
  • - + } cardButton={ + + + + } title="Version" isFetching={cippVersion.isFetching} @@ -73,7 +136,23 @@ const CippVersionProperties = () => { cippVersion?.data?.OutOfDateCIPPAPI ), }, - ]} + { + label: "Hosting", + value: hosting?.HostingType ?? "Unknown", + }, + { + label: "App Service SKU", + value: hosting?.SKU ?? "Unknown", + }, + { + label: "Runtime Stack", + value: hosting?.RuntimeStack ?? "Unknown", + }, + { + label: "Last Updated", + value: lastUpdateText, + }, + ].map((item) => ({ ...item, sx: { py: 0.5, px: { xs: 2, md: 3 } } }))} /> ); }; From bccbf572f11ffad9fd281d57885d4971b65acf4c Mon Sep 17 00:00:00 2001 From: John Duprey Date: Mon, 17 Aug 2026 12:05:41 -0400 Subject: [PATCH 114/226] feat: add entity switcher to detail pages Introduce a generic CippEntitySwitcher component that renders the page title as a searchable dropdown to navigate between sibling entities without returning to the list view. Add preset wrappers for users, groups, devices, app registrations, enterprise apps, and GDAP relationships. Refactor CippUserSwitcher to delegate to CippEntitySwitcher. --- .../CippAppRegistrationSwitcher.jsx | 29 +++ .../CippEnterpriseAppSwitcher.jsx | 28 +++ .../CippComponents/CippEntitySwitcher.jsx | 221 ++++++++++++++++++ .../CippGdapRelationshipSwitcher.jsx | 22 ++ .../CippComponents/CippUserSwitcher.jsx | 201 ++-------------- .../endpoint/MEM/devices/device/index.jsx | 23 ++ .../administration/groups/group/index.jsx | 22 ++ .../applications/app-registration/index.jsx | 8 + .../app-registration/permissions.jsx | 8 + .../applications/enterprise-app/index.jsx | 8 + .../enterprise-app/permissions.jsx | 8 + .../relationships/relationship/index.js | 2 + .../relationships/relationship/mappings.js | 2 + 13 files changed, 405 insertions(+), 177 deletions(-) create mode 100644 frontend/src/components/CippComponents/CippAppRegistrationSwitcher.jsx create mode 100644 frontend/src/components/CippComponents/CippEnterpriseAppSwitcher.jsx create mode 100644 frontend/src/components/CippComponents/CippEntitySwitcher.jsx create mode 100644 frontend/src/components/CippComponents/CippGdapRelationshipSwitcher.jsx diff --git a/frontend/src/components/CippComponents/CippAppRegistrationSwitcher.jsx b/frontend/src/components/CippComponents/CippAppRegistrationSwitcher.jsx new file mode 100644 index 0000000000..a0798c18ff --- /dev/null +++ b/frontend/src/components/CippComponents/CippAppRegistrationSwitcher.jsx @@ -0,0 +1,29 @@ +import { CippEntitySwitcher } from "./CippEntitySwitcher"; + +/** + * The app registration pages' title-as-switcher: CippEntitySwitcher preset over the + * tenant's applications, swapping appId (the client ID, matching the table links) so the + * current tab (Overview, API permissions) is preserved. + */ +export const CippAppRegistrationSwitcher = ({ title, currentAppId, tenantFilter }) => ( + app.appId} + getSecondary={(app) => app.appId} + /> +); diff --git a/frontend/src/components/CippComponents/CippEnterpriseAppSwitcher.jsx b/frontend/src/components/CippComponents/CippEnterpriseAppSwitcher.jsx new file mode 100644 index 0000000000..99f4ec6f0e --- /dev/null +++ b/frontend/src/components/CippComponents/CippEnterpriseAppSwitcher.jsx @@ -0,0 +1,28 @@ +import { CippEntitySwitcher } from "./CippEntitySwitcher"; + +/** + * The enterprise app pages' title-as-switcher: CippEntitySwitcher preset over the tenant's + * service principals, swapping spId (the SP object ID, matching the table links) so the + * current tab (Overview, API permissions) is preserved. + */ +export const CippEnterpriseAppSwitcher = ({ title, currentSpId, tenantFilter }) => ( + app.appId} + /> +); diff --git a/frontend/src/components/CippComponents/CippEntitySwitcher.jsx b/frontend/src/components/CippComponents/CippEntitySwitcher.jsx new file mode 100644 index 0000000000..215e7563da --- /dev/null +++ b/frontend/src/components/CippComponents/CippEntitySwitcher.jsx @@ -0,0 +1,221 @@ +import { useMemo, useRef, useState } from "react"; +import { useRouter } from "next/router"; +import { + Box, + ButtonBase, + InputAdornment, + List, + ListItemButton, + ListItemText, + Popover, + Skeleton, + TextField, + Typography, +} from "@mui/material"; +import { visuallyHidden } from "@mui/utils"; +import { Check, KeyboardArrowDown, Search } from "@mui/icons-material"; +import { ApiGetCall } from "../../api/ApiCall"; +import { CippBottomSheet } from "./CippBottomSheet"; +import { useIsMobileLayout } from "../../hooks/use-breakpoint"; + +/** + * A detail page's title as a switcher: the entity's name in heading clothes with a chevron, + * opening a searchable list of sibling entities to jump straight to another one without going + * back through the table. Selection swaps only `queryParamKey` in the current route, so + * whatever tab you are on stays the tab you land on. Mount via HeaderedTabbedLayout's + * titleControl slot; per-entity presets (CippUserSwitcher and friends) wrap this. + * + * Same trigger both breakpoints; the list rides in a Popover on desktop and the house + * bottom sheet on phones. The list loads when first opened, not with the page — pass + * `eager` only when the query is already cached app-wide (e.g. the tenant selector's). + */ +export const CippEntitySwitcher = ({ + title, + currentId, + queryParamKey, + api, + entityName, + entityNamePlural = `${entityName}s`, + getOptions = (data) => data?.Results ?? [], + getId = (row) => row.id, + getPrimary = (row) => row.displayName, + getSecondary, + // For endpoints without server-side ordering (Intune, ListGDAPRelationships). + sortByPrimary = false, + eager = false, +}) => { + const router = useRouter(); + const isMobile = useIsMobileLayout(); + const [open, setOpen] = useState(false); + const [search, setSearch] = useState(""); + const anchorRef = useRef(null); + + const listRequest = ApiGetCall({ + ...api, + waiting: open || eager, + }); + + const filtered = useMemo(() => { + let rows = getOptions(listRequest.data) ?? []; + if (sortByPrimary) { + rows = [...rows].sort((a, b) => + String(getPrimary(a) ?? "").localeCompare(String(getPrimary(b) ?? ""), undefined, { + sensitivity: "base", + }) + ); + } + const needle = search.trim().toLowerCase(); + if (!needle) return rows; + return rows.filter( + (row) => + String(getPrimary(row) ?? "").toLowerCase().includes(needle) || + String(getSecondary?.(row) ?? "").toLowerCase().includes(needle) + ); + }, [listRequest.data, search, sortByPrimary, getOptions, getId, getPrimary, getSecondary]); + + const handleClose = () => { + setOpen(false); + setSearch(""); + }; + + const handleSelect = (row) => { + handleClose(); + if (getId(row) === currentId) return; + router.push({ + pathname: router.pathname, + query: { ...router.query, [queryParamKey]: getId(row) }, + }); + }; + + const sheetTitle = entityNamePlural.charAt(0).toUpperCase() + entityNamePlural.slice(1); + + const listBody = ( + <> + + setSearch(event.target.value)} + InputProps={{ + startAdornment: ( + + + + ), + }} + /> + + {/* Dense two-line rows in the tenant selector's clothes — the first cut used the + default List metrics and read as a page of loosely scattered names. */} + + {listRequest.isFetching && + [...Array(6)].map((_, index) => ( + + + + + ))} + {!listRequest.isFetching && filtered.length === 0 && ( + + No {entityNamePlural} match. + + )} + {!listRequest.isFetching && + filtered.map((row) => ( + handleSelect(row)} + sx={{ minHeight: 44, py: 0.5, px: 2, gap: 1 }} + > + + {getId(row) === currentId && ( + + )} + + ))} + + + ); + + return ( + <> + setOpen(true)} + aria-haspopup="dialog" + sx={{ + minWidth: 0, + maxWidth: "100%", + display: "flex", + alignItems: "center", + gap: 0.75, + borderRadius: 1, + textAlign: "left", + justifyContent: "flex-start", + }} + > + {/* Same wrap rule as the layout's plain title: truncate on mobile, wrap on desktop. + When the title wraps, its box fills the row, so a sibling chevron ends up + stranded at the far edge — on desktop the chevron rides inline after the last + word instead. Mobile keeps the sibling: inline would be clipped by noWrap. */} + {isMobile ? ( + <> + + {title} + + {/* Extends the accessible name instead of replacing it, so voice control can + still activate the trigger by the visible name (same rule as CippTabPicker). */} + + switch {entityName} + + + + ) : ( + <> + + {title} + + + {/* Sibling of the heading, not inside it: inline nodes concatenate without a + space in the accessible name, gluing the title to "switch". */} + + switch {entityName} + + + )} + + {isMobile ? ( + + {listBody} + + ) : ( + + {listBody} + + )} + + ); +}; diff --git a/frontend/src/components/CippComponents/CippGdapRelationshipSwitcher.jsx b/frontend/src/components/CippComponents/CippGdapRelationshipSwitcher.jsx new file mode 100644 index 0000000000..312db6eae7 --- /dev/null +++ b/frontend/src/components/CippComponents/CippGdapRelationshipSwitcher.jsx @@ -0,0 +1,22 @@ +import { CippEntitySwitcher } from "./CippEntitySwitcher"; + +/** + * The GDAP relationship pages' title-as-switcher: CippEntitySwitcher preset over all + * relationships (partner-level, no tenantFilter), swapping id so the current tab + * (Details, Role Mappings) is preserved while reviewing relationship after relationship. + */ +export const CippGdapRelationshipSwitcher = ({ title, currentRelationshipId }) => ( + relationship.customer?.displayName ?? "No Customer Set"} + getSecondary={(relationship) => relationship.displayName} + sortByPrimary + /> +); diff --git a/frontend/src/components/CippComponents/CippUserSwitcher.jsx b/frontend/src/components/CippComponents/CippUserSwitcher.jsx index 102db4be58..444de74ef8 100644 --- a/frontend/src/components/CippComponents/CippUserSwitcher.jsx +++ b/frontend/src/components/CippComponents/CippUserSwitcher.jsx @@ -1,180 +1,27 @@ -import { useMemo, useRef, useState } from "react"; -import { useRouter } from "next/router"; -import { - Box, - ButtonBase, - InputAdornment, - List, - ListItemButton, - ListItemText, - Popover, - Skeleton, - TextField, - Typography, -} from "@mui/material"; -import { visuallyHidden } from "@mui/utils"; -import { Check, KeyboardArrowDown, Search } from "@mui/icons-material"; -import { ApiGetCall } from "../../api/ApiCall"; -import { CippBottomSheet } from "./CippBottomSheet"; -import { useIsMobileLayout } from "../../hooks/use-breakpoint"; +import { CippEntitySwitcher } from "./CippEntitySwitcher"; /** - * The View User header's title as a switcher: the user's name in heading clothes with a - * chevron, opening the tenant's user list to jump straight to another user without going - * back through the table. Selection swaps only the userId in the current route, so whatever - * tab you are on (View, Edit, Exchange…) stays the tab you land on. - * - * Same trigger both breakpoints; the list rides in a Popover on desktop and the house - * bottom sheet on phones. The user list loads when first opened, not with the page. + * The View User pages' title-as-switcher: CippEntitySwitcher preset over the tenant's + * user list, swapping userId so the current tab (View, Edit, Exchange…) is preserved. */ -export const CippUserSwitcher = ({ title, currentUserId, tenantFilter }) => { - const router = useRouter(); - const isMobile = useIsMobileLayout(); - const [open, setOpen] = useState(false); - const [search, setSearch] = useState(""); - const anchorRef = useRef(null); - - const usersRequest = ApiGetCall({ - url: "/api/ListGraphRequest", - data: { - Endpoint: "users", - tenantFilter: tenantFilter, - $select: "id,displayName,userPrincipalName", - $count: true, - $orderby: "displayName", - $top: 999, - }, - queryKey: `UserSwitcher-${tenantFilter}`, - waiting: open, - }); - - const filtered = useMemo(() => { - const users = usersRequest.data?.Results ?? []; - const needle = search.trim().toLowerCase(); - if (!needle) return users; - return users.filter( - (user) => - user.displayName?.toLowerCase().includes(needle) || - user.userPrincipalName?.toLowerCase().includes(needle) - ); - }, [usersRequest.data, search]); - - const handleClose = () => { - setOpen(false); - setSearch(""); - }; - - const handleSelect = (user) => { - handleClose(); - if (user.id === currentUserId) return; - router.push({ pathname: router.pathname, query: { ...router.query, userId: user.id } }); - }; - - const listBody = ( - <> - - setSearch(event.target.value)} - InputProps={{ - startAdornment: ( - - - - ), - }} - /> - - {/* Dense two-line rows in the tenant selector's clothes — the first cut used the - default List metrics and read as a page of loosely scattered names. */} - - {usersRequest.isFetching && - [...Array(6)].map((_, index) => ( - - - - - ))} - {!usersRequest.isFetching && filtered.length === 0 && ( - - No users match. - - )} - {!usersRequest.isFetching && - filtered.map((user) => ( - handleSelect(user)} - sx={{ minHeight: 44, py: 0.5, px: 2, gap: 1 }} - > - - {user.id === currentUserId && ( - - )} - - ))} - - - ); - - return ( - <> - setOpen(true)} - aria-haspopup="dialog" - sx={{ - minWidth: 0, - maxWidth: "100%", - display: "flex", - alignItems: "center", - gap: 0.75, - borderRadius: 1, - textAlign: "left", - justifyContent: "flex-start", - }} - > - - {title} - - {/* Extends the accessible name instead of replacing it, so voice control can still - activate the trigger by the visible name (same rule as CippTabPicker). */} - - switch user - - - - {isMobile ? ( - - {listBody} - - ) : ( - - {listBody} - - )} - - ); -}; +export const CippUserSwitcher = ({ title, currentUserId, tenantFilter }) => ( + user.userPrincipalName} + /> +); diff --git a/frontend/src/pages/endpoint/MEM/devices/device/index.jsx b/frontend/src/pages/endpoint/MEM/devices/device/index.jsx index d765644d61..25fdbcff7b 100644 --- a/frontend/src/pages/endpoint/MEM/devices/device/index.jsx +++ b/frontend/src/pages/endpoint/MEM/devices/device/index.jsx @@ -18,6 +18,7 @@ import { Group, } from '@mui/icons-material' import { HeaderedTabbedLayout } from '../../../../../layouts/HeaderedTabbedLayout' +import { CippEntitySwitcher } from '../../../../../components/CippComponents/CippEntitySwitcher' import tabOptions from './tabOptions' import { CippCopyToClipBoard } from '../../../../../components/CippComponents/CippCopyToClipboard' import { getIntuneDeviceActions } from '../../../../../components/CippComponents/CippIntuneDeviceActions.jsx' @@ -477,6 +478,28 @@ const Page = () => { device.deviceName} + getSecondary={(device) => device.userPrincipalName} + sortByPrimary + /> + } actions={deviceActions} actionsData={data} subtitle={subtitle} diff --git a/frontend/src/pages/identity/administration/groups/group/index.jsx b/frontend/src/pages/identity/administration/groups/group/index.jsx index 97d7cff453..69f1ebaa62 100644 --- a/frontend/src/pages/identity/administration/groups/group/index.jsx +++ b/frontend/src/pages/identity/administration/groups/group/index.jsx @@ -19,6 +19,7 @@ import { GroupAdd, } from "@mui/icons-material"; import { HeaderedTabbedLayout } from "../../../../../layouts/HeaderedTabbedLayout"; +import { CippEntitySwitcher } from "../../../../../components/CippComponents/CippEntitySwitcher"; import tabOptions from "./tabOptions"; import { CippCopyToClipBoard } from "../../../../../components/CippComponents/CippCopyToClipboard"; import { Box, Stack } from "@mui/system"; @@ -684,6 +685,27 @@ const Page = () => { group.mail} + /> + } actions={groupActions} actionsData={data} subtitle={subtitle} diff --git a/frontend/src/pages/tenant/administration/applications/app-registration/index.jsx b/frontend/src/pages/tenant/administration/applications/app-registration/index.jsx index d110192e6e..8c5e257076 100644 --- a/frontend/src/pages/tenant/administration/applications/app-registration/index.jsx +++ b/frontend/src/pages/tenant/administration/applications/app-registration/index.jsx @@ -14,6 +14,7 @@ import { Badge, } from '@mui/icons-material' import { HeaderedTabbedLayout } from '../../../../../layouts/HeaderedTabbedLayout' +import { CippAppRegistrationSwitcher } from '../../../../../components/CippComponents/CippAppRegistrationSwitcher' import tabOptions from './tabOptions' import { CippCopyToClipBoard } from '../../../../../components/CippComponents/CippCopyToClipboard' import { Box, Stack } from '@mui/system' @@ -368,6 +369,13 @@ const Page = () => { + } subtitle={subtitle} actions={appData ? appActions : []} actionsData={actionsData} diff --git a/frontend/src/pages/tenant/administration/applications/app-registration/permissions.jsx b/frontend/src/pages/tenant/administration/applications/app-registration/permissions.jsx index 7143aa375c..5386f82b63 100644 --- a/frontend/src/pages/tenant/administration/applications/app-registration/permissions.jsx +++ b/frontend/src/pages/tenant/administration/applications/app-registration/permissions.jsx @@ -6,6 +6,7 @@ import CippFormSkeleton from '../../../../../components/CippFormPages/CippFormSk import CalendarIcon from '@heroicons/react/24/outline/CalendarIcon' import { Fingerprint, Launch, Badge } from '@mui/icons-material' import { HeaderedTabbedLayout } from '../../../../../layouts/HeaderedTabbedLayout' +import { CippAppRegistrationSwitcher } from '../../../../../components/CippComponents/CippAppRegistrationSwitcher' import tabOptions from './tabOptions' import { CippCopyToClipBoard } from '../../../../../components/CippComponents/CippCopyToClipboard' import { Box } from '@mui/system' @@ -113,6 +114,13 @@ const Page = () => { + } subtitle={subtitle} actions={appData ? appActions : []} actionsData={actionsData} diff --git a/frontend/src/pages/tenant/administration/applications/enterprise-app/index.jsx b/frontend/src/pages/tenant/administration/applications/enterprise-app/index.jsx index 00bd19a53f..48d5116cf5 100644 --- a/frontend/src/pages/tenant/administration/applications/enterprise-app/index.jsx +++ b/frontend/src/pages/tenant/administration/applications/enterprise-app/index.jsx @@ -6,6 +6,7 @@ import CippFormSkeleton from '../../../../../components/CippFormPages/CippFormSk import CalendarIcon from '@heroicons/react/24/outline/CalendarIcon' import { Fingerprint, Launch, Apps, Group, CheckCircle, Warning, Badge } from '@mui/icons-material' import { HeaderedTabbedLayout } from '../../../../../layouts/HeaderedTabbedLayout' +import { CippEnterpriseAppSwitcher } from '../../../../../components/CippComponents/CippEnterpriseAppSwitcher' import tabOptions from './tabOptions' import { CippCopyToClipBoard } from '../../../../../components/CippComponents/CippCopyToClipboard' import { Box, Stack } from '@mui/system' @@ -288,6 +289,13 @@ const Page = () => { + } subtitle={subtitle} actions={spData ? appActions : []} actionsData={actionsData} diff --git a/frontend/src/pages/tenant/administration/applications/enterprise-app/permissions.jsx b/frontend/src/pages/tenant/administration/applications/enterprise-app/permissions.jsx index 62607ad6d4..4aae9812cb 100644 --- a/frontend/src/pages/tenant/administration/applications/enterprise-app/permissions.jsx +++ b/frontend/src/pages/tenant/administration/applications/enterprise-app/permissions.jsx @@ -6,6 +6,7 @@ import CippFormSkeleton from '../../../../../components/CippFormPages/CippFormSk import CalendarIcon from '@heroicons/react/24/outline/CalendarIcon' import { Fingerprint, Launch, Badge } from '@mui/icons-material' import { HeaderedTabbedLayout } from '../../../../../layouts/HeaderedTabbedLayout' +import { CippEnterpriseAppSwitcher } from '../../../../../components/CippComponents/CippEnterpriseAppSwitcher' import tabOptions from './tabOptions' import { CippCopyToClipBoard } from '../../../../../components/CippComponents/CippCopyToClipboard' import { Box } from '@mui/system' @@ -117,6 +118,13 @@ const Page = () => { + } subtitle={subtitle} actions={spData ? appActions : []} actionsData={actionsData} diff --git a/frontend/src/pages/tenant/gdap-management/relationships/relationship/index.js b/frontend/src/pages/tenant/gdap-management/relationships/relationship/index.js index 7a93d35548..df4c3f3004 100644 --- a/frontend/src/pages/tenant/gdap-management/relationships/relationship/index.js +++ b/frontend/src/pages/tenant/gdap-management/relationships/relationship/index.js @@ -3,6 +3,7 @@ import { useRouter } from "next/router"; import { ApiGetCall } from "../../../../../api/ApiCall"; import CippFormSkeleton from "../../../../../components/CippFormPages/CippFormSkeleton"; import { HeaderedTabbedLayout } from "../../../../../layouts/HeaderedTabbedLayout"; +import { CippGdapRelationshipSwitcher } from "../../../../../components/CippComponents/CippGdapRelationshipSwitcher"; import tabOptions from "./tabOptions.json"; import { Box, Grid, Stack } from "@mui/system"; import { CippTimeAgo } from "../../../../../components/CippComponents/CippTimeAgo"; @@ -135,6 +136,7 @@ const Page = () => { } subtitle={subtitle} isFetching={relationshipRequest.isLoading} actions={CippGdapActions()} diff --git a/frontend/src/pages/tenant/gdap-management/relationships/relationship/mappings.js b/frontend/src/pages/tenant/gdap-management/relationships/relationship/mappings.js index b9669e3f72..383dfc2794 100644 --- a/frontend/src/pages/tenant/gdap-management/relationships/relationship/mappings.js +++ b/frontend/src/pages/tenant/gdap-management/relationships/relationship/mappings.js @@ -2,6 +2,7 @@ import { Layout as DashboardLayout } from "../../../../../layouts/index.js"; import { useRouter } from "next/router"; import { ApiGetCall } from "../../../../../api/ApiCall"; import { HeaderedTabbedLayout } from "../../../../../layouts/HeaderedTabbedLayout"; +import { CippGdapRelationshipSwitcher } from "../../../../../components/CippComponents/CippGdapRelationshipSwitcher"; import tabOptions from "./tabOptions.json"; import { CippTimeAgo } from "../../../../../components/CippComponents/CippTimeAgo"; import { CippDataTable } from "../../../../../components/CippTable/CippDataTable"; @@ -45,6 +46,7 @@ const Page = () => { } subtitle={subtitle} isFetching={relationshipRequest.isLoading} backUrl="/tenant/gdap-management/relationships" From b8df60c08aac7751be3c673e55115fc390de789e Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Tue, 18 Aug 2026 00:16:15 +0800 Subject: [PATCH 115/226] fix(pwpush): force re-initialization so config changes reach every worker Initialize-PassPushPosh is a no-op once a session is initialized, and CRAFT workers are long-lived and shared - each worker kept the auth headers and base URL from whatever configuration it saw first, so config changes and key rotations never applied until a container restart. Pass -Force so every push runs with the currently saved configuration. Also redact APIKey/Bearer in the logged initialization parameters instead of writing the raw key to the information stream. --- .../CippExtensions/Public/PwPush/Set-PwPushConfig.ps1 | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/backend/Modules/CippExtensions/Public/PwPush/Set-PwPushConfig.ps1 b/backend/Modules/CippExtensions/Public/PwPush/Set-PwPushConfig.ps1 index 4d403f0d9a..b42e35ad26 100644 --- a/backend/Modules/CippExtensions/Public/PwPush/Set-PwPushConfig.ps1 +++ b/backend/Modules/CippExtensions/Public/PwPush/Set-PwPushConfig.ps1 @@ -36,8 +36,15 @@ function Set-PwPushConfig { $Module = Get-Module PassPushPosh -ListAvailable Write-Information "PWPush Version: $($Module.Version)" if ($PSCmdlet.ShouldProcess('Initialize-PassPushPosh')) { - Write-Information ($InitParams | ConvertTo-Json) - Initialize-PassPushPosh @InitParams + $LogParams = @{} + $InitParams + foreach ($Secret in 'APIKey', 'Bearer') { + if ($LogParams.ContainsKey($Secret)) { $LogParams[$Secret] = 'REDACTED' } + } + Write-Information ($LogParams | ConvertTo-Json) + # -Force: workers are long-lived and shared, and without it Initialize-PassPushPosh is a + # no-op after a worker's first call - the worker then keeps the auth headers and base URL + # from whatever configuration it saw first, so config changes and key rotations never land. + Initialize-PassPushPosh @InitParams -Force } if ($Configuration.CFEnabled -eq $true -and $FullConfiguration.CFZTNA.Enabled -eq $true) { From 4e53fc8ca682342b62e7666ddd23b8283725fe14 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Tue, 18 Aug 2026 00:43:14 +0800 Subject: [PATCH 116/226] feat(support): add support bundle generator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a 'Generate Support File' speed dial action that captures the current page's API traffic, instance version/hosting details, and signed-in user identity into a downloadable JSON bundle. - New `CippSupportBundleDialog` component handles the collection lifecycle (options → collecting → ready/error) - New `support-bundle.js` utility: axios interceptor for recording, redaction of emails/GUIDs/domains with consistent tokenisation, and JSON download - `GetVersion` endpoint extended with `ResourceGroup`, `Domains`, and `DomainsAuthoritative` hosting fields via ARM helpers - Redaction keeps the instance hostname intact so support can still identify the installation --- .../CIPP/Core/Invoke-GetVersion.ps1 | 19 +- .../CippSupportBundleDialog.jsx | 241 ++++++++++++++++++ frontend/src/pages/_app.js | 13 + frontend/src/utils/support-bundle.js | 195 ++++++++++++++ 4 files changed, 463 insertions(+), 5 deletions(-) create mode 100644 frontend/src/components/CippComponents/CippSupportBundleDialog.jsx create mode 100644 frontend/src/utils/support-bundle.js diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-GetVersion.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-GetVersion.ps1 index 696e807766..f61d8776be 100644 --- a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-GetVersion.ps1 +++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-GetVersion.ps1 @@ -27,14 +27,23 @@ function Invoke-GetVersion { } } - # Hosting shape for support tickets. Environment only - no ARM call - so this stays fast - # and works without a managed identity; anything unset degrades to 'Unknown'. + # Hosting shape for support tickets. Type, SKU and stack come from the environment; + # the bound domains and resource group come from ARM via the shared helpers. All of it + # is best effort - a failed lookup degrades to Unknown/empty rather than failing the + # endpoint, so a ticket paste still tells us what we could not read. $SKU = [string]::IsNullOrWhiteSpace($env:WEBSITE_SKU) ? 'Unknown' : $env:WEBSITE_SKU $RuntimeStack = if ($env:WEBSITE_SKU -eq 'FlexConsumption') { 'Flex Consumption' } elseif ($IsLinux) { 'Linux' } else { 'Windows' } + try { $ResourceGroup = Get-CIPPFunctionAppResourceGroup } catch { $ResourceGroup = $null } + try { $SiteState = Get-CIPPSiteHostname -IncludeStatus } catch { $SiteState = $null } $Hosting = [PSCustomObject]@{ - HostingType = $env:CIPP_HOSTED -eq 'true' ? 'CyberDrain-hosted' : 'Self-hosted' - SKU = $SKU - RuntimeStack = $RuntimeStack + HostingType = $env:CIPP_HOSTED -eq 'true' ? 'CyberDrain-hosted' : 'Self-hosted' + SKU = $SKU + RuntimeStack = $RuntimeStack + ResourceGroup = [string]::IsNullOrWhiteSpace($ResourceGroup) ? 'Unknown' : $ResourceGroup + Domains = @($SiteState.Hostnames) + # False when ARM could not be queried and the list is a best-effort fallback + # (or empty in local dev) rather than the site's full binding list. + DomainsAuthoritative = [bool]$SiteState.Discovered } # Update history recorded at warmup by Update-CIPPVersionHistory; empty until the diff --git a/frontend/src/components/CippComponents/CippSupportBundleDialog.jsx b/frontend/src/components/CippComponents/CippSupportBundleDialog.jsx new file mode 100644 index 0000000000..33f8757f8d --- /dev/null +++ b/frontend/src/components/CippComponents/CippSupportBundleDialog.jsx @@ -0,0 +1,241 @@ +import { useEffect, useRef, useState } from 'react' +import { + Alert, + Button, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + FormControlLabel, + Stack, + Switch, + Typography, +} from '@mui/material' +import { Download, PlayArrow } from '@mui/icons-material' +import { useQueryClient } from '@tanstack/react-query' +import { useSettings } from '../../hooks/use-settings' +import { + armSupportRecorder, + disarmSupportRecorder, + downloadSupportBundle, + getSupportRecording, + getSupportRecordingCount, + redactBundle, +} from '../../utils/support-bundle' + +// The fixed sections go through fetch() rather than axios on purpose: the armed recorder +// captures all axios traffic, and the network section should contain only what the page +// itself requested. +const fetchJson = async (url) => { + try { + const response = await fetch(url, { credentials: 'include' }) + const parsed = await response.json().catch(() => null) + return response.ok ? parsed : { unavailable: response.status, body: parsed } + } catch (error) { + return { unavailable: String(error?.message ?? error) } + } +} + +const CippSupportBundleDialog = ({ open, onClose }) => { + const queryClient = useQueryClient() + const settings = useSettings() + const [phase, setPhase] = useState('options') + const [redact, setRedact] = useState(true) + const [bundle, setBundle] = useState(null) + const [redactionSummary, setRedactionSummary] = useState(null) + const [progress, setProgress] = useState(0) + const [errorMessage, setErrorMessage] = useState(null) + // Invalidates a run when the dialog closes mid-collection, so a stale run cannot + // finish later and overwrite the state of a newer one. + const runToken = useRef(0) + const pollRef = useRef(null) + + const stopCollecting = () => { + disarmSupportRecorder() + if (pollRef.current) { + clearInterval(pollRef.current) + pollRef.current = null + } + } + + // Each open starts back at the options screen. State is adjusted during render on the + // open transition (the React-sanctioned alternative to setState-in-effect); the close + // effect below only cancels the run and disarms the recorder — external side effects, + // no state updates. + const [prevOpen, setPrevOpen] = useState(open) + if (open !== prevOpen) { + setPrevOpen(open) + if (open) { + setPhase('options') + setBundle(null) + setRedactionSummary(null) + setErrorMessage(null) + setProgress(0) + } + } + + useEffect(() => { + if (!open) { + runToken.current++ + stopCollecting() + } + }, [open]) + + const handleStart = async () => { + const token = ++runToken.current + setPhase('collecting') + setProgress(0) + armSupportRecorder() + pollRef.current = setInterval( + () => setProgress(getSupportRecordingCount()), + 300 + ) + try { + // Force every query mounted on the current page to hit the API again — the + // recorder only sees axios traffic, so cache reads must become real requests. + const refetchPromise = queryClient.refetchQueries({ type: 'active' }) + const localVersion = await fetchJson('/version.json') + const [instance, me, authMe] = await Promise.all([ + fetchJson( + `/api/GetVersion?LocalVersion=${encodeURIComponent(localVersion?.version ?? '')}` + ), + fetchJson('/api/me'), + fetchJson('/.auth/me'), + ]) + await refetchPromise + if (token !== runToken.current) return + stopCollecting() + const network = getSupportRecording() + let assembled = { + schemaVersion: 1, + generatedAt: new Date().toISOString(), + instanceHostname: window.location.hostname, + redaction: { enabled: redact }, + client: { + path: window.location.pathname, + tenant: settings.currentTenant ?? null, + userAgent: navigator.userAgent, + frontendVersion: localVersion?.version ?? null, + }, + instance, + user: { me, authMe }, + network, + } + if (redact) { + // The instance's own hostname identifies the installation, not a customer + // tenant — support needs it, so it survives redaction. + const redacted = redactBundle(assembled, { + keepHostnames: [window.location.hostname], + }) + assembled = redacted.bundle + assembled.redaction = { enabled: true, ...redacted.summary } + setRedactionSummary(redacted.summary) + } + setBundle(assembled) + setProgress(network.length) + setPhase('ready') + } catch (error) { + if (token !== runToken.current) return + stopCollecting() + setErrorMessage(String(error?.message ?? error)) + setPhase('error') + } + } + + const failedCount = + bundle?.network?.filter((call) => !call.success).length ?? 0 + + return ( + + Generate Support File + + {phase === 'options' && ( + + + This refreshes the current page's data and captures the API + requests behind it, together with the instance version, hosting + and update details, and your signed-in identity and roles + + setRedact(event.target.checked)} + /> + } + label="Redact tenant IDs, domains and email addresses" + /> + + )} + {phase === 'collecting' && ( + + + + Refreshing the current page's data — {progress} request + {progress === 1 ? '' : 's'} captured... + + + )} + {phase === 'ready' && ( + + + Captured {bundle.network.length} request + {bundle.network.length === 1 ? '' : 's'} from this page + {failedCount > 0 ? `, of which ${failedCount} failed` : ''}, along + with the instance version, hosting and update details, and your + signed-in identity and roles. + + {redactionSummary ? ( + + Redacted {redactionSummary.emails} email address + {redactionSummary.emails === 1 ? '' : 'es'},{' '} + {redactionSummary.guids} GUID + {redactionSummary.guids === 1 ? '' : 's'} and{' '} + {redactionSummary.domains} domain + {redactionSummary.domains === 1 ? '' : 's'}. + + ) : ( + + The file contains unredacted data from the current page, your + user identity, and instance details. Only share it with support. + + )} + + )} + {phase === 'error' && ( + + Could not generate the support file: {errorMessage} + + )} + + + + {phase === 'options' && ( + + )} + {phase !== 'options' && ( + + )} + + + ) +} + +export default CippSupportBundleDialog diff --git a/frontend/src/pages/_app.js b/frontend/src/pages/_app.js index ce81b458eb..eebdd0115d 100644 --- a/frontend/src/pages/_app.js +++ b/frontend/src/pages/_app.js @@ -53,6 +53,7 @@ import { AutoStories, Gavel, ClearAll as ClearAllIcon, + SupportAgent, } from '@mui/icons-material' import { School as TutorialIcon } from '@mui/icons-material' import { getHelpLinks, clearCippCache } from '../utils/help-links' @@ -64,6 +65,7 @@ import { persistQueryClient } from '@tanstack/react-query-persist-client' import { createSyncStoragePersister } from '@tanstack/query-sync-storage-persister' import { TutorialProvider } from '../contexts/tutorial-context' import CippTutorialDialog from '../components/CippComponents/CippTutorialDialog' +import CippSupportBundleDialog from '../components/CippComponents/CippSupportBundleDialog' const ReactQueryDevtoolsProduction = React.lazy(() => import('@tanstack/react-query-devtools/build/modern/production.js').then((d) => ({ @@ -92,6 +94,7 @@ const App = (props) => { const route = useRouter() const [dateLocale, setDateLocale] = useState(enUS) const [tutorialDialogOpen, setTutorialDialogOpen] = useState(false) + const [supportBundleOpen, setSupportBundleOpen] = useState(false) useEffect(() => { if (typeof window === 'undefined') return @@ -219,6 +222,12 @@ const App = (props) => { href: '/license', onClick: () => route.push('/license'), }, + { + id: 'supportBundle', + icon: , + name: 'Generate Support File', + onClick: () => setSupportBundleOpen(true), + }, ...getHelpLinks(pathname).map((link) => ({ ...link, icon: helpLinkIcons[link.id], @@ -270,6 +279,10 @@ const App = (props) => { open={tutorialDialogOpen} onClose={() => setTutorialDialogOpen(false)} /> + setSupportBundleOpen(false)} + /> diff --git a/frontend/src/utils/support-bundle.js b/frontend/src/utils/support-bundle.js new file mode 100644 index 0000000000..b288fe7895 --- /dev/null +++ b/frontend/src/utils/support-bundle.js @@ -0,0 +1,195 @@ +import axios from 'axios' + +// Captures the API traffic behind the current page for the speed dial's support-file +// generator. The recorder is armed only while the support dialog is collecting: the dialog +// forces every active (mounted) query to refetch, so everything the page reads flows +// through axios inside the capture window and is recorded here — successes included, since +// support usually needs to see what the page DID get alongside what failed. + +// One oversized Graph list page must not balloon the bundle into something the user +// cannot email, so recorded bodies are capped and flagged instead of stored whole. +const MAX_BODY_CHARS = 262144 + +let armed = false +let seq = 0 +let calls = [] + +const serializeBody = (data, responseType) => { + if (data === null || data === undefined) return { body: null } + if ( + responseType === 'blob' || + (typeof Blob !== 'undefined' && data instanceof Blob) + ) { + return { + body: ``, + } + } + let text + try { + text = typeof data === 'string' ? data : JSON.stringify(data) + } catch { + text = String(data) + } + if (typeof text === 'string' && text.length > MAX_BODY_CHARS) { + return { body: text.slice(0, MAX_BODY_CHARS), bodyTruncated: true } + } + // Small bodies keep their shape so the bundle stays readable as plain JSON. + return { body: typeof data === 'string' ? data : data } +} + +const record = (config, response, error) => { + if (!config?.cippSupportMeta || config.cippSupportRecorded) return + // HMR in dev can register the interceptors more than once; the per-request flag + // keeps a call from being recorded twice. + config.cippSupportRecorded = true + const { start, seq: n } = config.cippSupportMeta + calls.push({ + seq: n, + startedAt: new Date(start).toISOString(), + durationMs: Date.now() - start, + method: (config.method || 'get').toUpperCase(), + url: config.url, + params: config.params ?? null, + status: response?.status ?? null, + success: !error, + ...(error ? { errorMessage: String(error.message ?? error) } : {}), + ...serializeBody(response?.data, config.responseType), + }) +} + +axios.interceptors.request.use((config) => { + if (armed) { + config.cippSupportMeta = { start: Date.now(), seq: ++seq } + } + return config +}) + +axios.interceptors.response.use( + (response) => { + record(response.config, response, null) + return response + }, + (error) => { + record(error?.config, error?.response, error ?? new Error('Request failed')) + return Promise.reject(error) + } +) + +export const armSupportRecorder = () => { + calls = [] + seq = 0 + armed = true +} + +export const disarmSupportRecorder = () => { + armed = false +} + +export const getSupportRecording = () => + [...calls].sort((a, b) => a.seq - b.seq) + +export const getSupportRecordingCount = () => calls.length + +const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + +const EMAIL_PATTERN = /[A-Za-z0-9._%+'-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g +const GUID_PATTERN = + /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi +const ONMICROSOFT_PATTERN = + /[A-Za-z0-9-]+\.(?:mail\.)?onmicrosoft\.(?:com|us|de)/g + +// Replaces every email address, GUID (tenant and object ids alike) and known tenant +// domain with a consistent token: the same original value always maps to the same token, +// so support can still correlate "user3 appears in the failing call and the roles list" +// without seeing who user3 is. Domains are redacted from a harvested set (email domains, +// *.onmicrosoft.* matches and the selected tenant) rather than a blind hostname regex, +// so Graph schema strings and infrastructure URLs are never mangled. +// Works on the serialized bundle: none of the matched values or tokens can contain a +// quote or backslash, so the JSON structure survives the substitution. +export const redactBundle = (bundle, { keepHostnames = [] } = {}) => { + let text = JSON.stringify(bundle) + const emailMap = new Map() + const guidMap = new Map() + const domainMap = new Map() + const domainSet = new Set() + // Kept hostnames must survive even when a harvested domain is their suffix — the + // instance hostname often shares the MSP's own mail domain. Swap them for inert + // placeholders first (nothing the email/domain/GUID patterns can match), and swap + // them back after every substitution has run. + const keepTokens = new Map() + for (const host of keepHostnames.filter(Boolean)) { + const token = `__CIPP_KEEP_${keepTokens.size}__` + keepTokens.set(token, host) + text = text.replace(new RegExp(escapeRegExp(host), 'gi'), token) + } + + // Harvest tenant domains before emails are replaced, so bare occurrences of an + // email's domain are caught too. + for (const match of text.matchAll(EMAIL_PATTERN)) { + domainSet.add(match[0].split('@')[1].toLowerCase()) + } + for (const match of text.matchAll(ONMICROSOFT_PATTERN)) { + domainSet.add(match[0].toLowerCase()) + } + const tenant = bundle?.client?.tenant + if (tenant && tenant !== 'AllTenants' && tenant.includes('.')) { + domainSet.add(tenant.toLowerCase()) + } + + text = text.replace(EMAIL_PATTERN, (value) => { + const key = value.toLowerCase() + if (!emailMap.has(key)) + emailMap.set(key, `user${emailMap.size + 1}@redacted.invalid`) + return emailMap.get(key) + }) + + for (const domain of domainSet) { + if (!domainMap.has(domain)) + domainMap.set(domain, `domain${domainMap.size + 1}.invalid`) + text = text.replace( + new RegExp(escapeRegExp(domain), 'gi'), + domainMap.get(domain) + ) + } + + text = text.replace(GUID_PATTERN, (value) => { + const key = value.toLowerCase() + if (!guidMap.has(key)) { + guidMap.set( + key, + `00000000-0000-0000-0000-${String(guidMap.size + 1).padStart(12, '0')}` + ) + } + return guidMap.get(key) + }) + + for (const [token, host] of keepTokens) { + text = text.replaceAll(token, host) + } + + return { + bundle: JSON.parse(text), + summary: { + emails: emailMap.size, + domains: domainMap.size, + guids: guidMap.size, + }, + } +} + +export const downloadSupportBundle = (bundle) => { + const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19) + const filename = `cipp-support-bundle_${window.location.hostname}_${timestamp}.json` + const blob = new Blob([JSON.stringify(bundle, null, 2)], { + type: 'application/json', + }) + const url = URL.createObjectURL(blob) + const anchor = document.createElement('a') + anchor.href = url + anchor.download = filename + document.body.appendChild(anchor) + anchor.click() + document.body.removeChild(anchor) + URL.revokeObjectURL(url) + return filename +} From 5fb911e5b76aa0c2dd10a3a84e4b8491c96eaafa Mon Sep 17 00:00:00 2001 From: Brian Simpson Date: Mon, 17 Aug 2026 18:28:16 +0000 Subject: [PATCH 117/226] GITBOOK-618: NG Note Clarification --- docs/.gitbook/includes/ng-note.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/.gitbook/includes/ng-note.md b/docs/.gitbook/includes/ng-note.md index 3c5cb7b0b4..25a63f276b 100644 --- a/docs/.gitbook/includes/ng-note.md +++ b/docs/.gitbook/includes/ng-note.md @@ -3,5 +3,5 @@ title: NG Note --- {% hint style="danger" %} -This page was removed with the upgrade to the new CIPP infrastructure. For more information on upgrading to the new infrastructure, see [migrating-to-the-latest-version-of-cipp.md](../../setup/maintaining-cipp/migrating-to-the-latest-version-of-cipp.md "mention"). +This page is removed with an upgrade to the new CIPP infrastructure. For more information on upgrading to the new infrastructure, see [migrating-to-the-latest-version-of-cipp.md](../../setup/maintaining-cipp/migrating-to-the-latest-version-of-cipp.md "mention"). For those remaining on the legacy infrastructure, the feature described below remains. {% endhint %} From 8329cebfac2df2bae8b96d11e220b8834f35644e Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Mon, 17 Aug 2026 22:22:49 +0200 Subject: [PATCH 118/226] baseline changes --- ...CIPPBaselineAuthenticationMethodsState.ps1 | 22 +++++++--- .../Get-CIPPBaselineSpamFilterPolicyState.ps1 | 36 ++++++++++----- .../Invoke-CIPPBaselineEnableFIDO2.ps1 | 22 ++++++++-- .../Public/Set-CIPPAuthenticationPolicy.ps1 | 18 +++++++- .../Baselines/BaselineEntraHeavies.Tests.ps1 | 34 ++++++++++++++ .../Baselines/BaselineExchangeBatch.Tests.ps1 | 39 ++++++++++++++++ .../Baselines/BaselineOneOffVerify.Tests.ps1 | 44 +++++++++++++++++++ .../Set-CIPPAuthenticationPolicy.Tests.ps1 | 41 +++++++++++++++++ 8 files changed, 233 insertions(+), 23 deletions(-) diff --git a/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineAuthenticationMethodsState.ps1 b/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineAuthenticationMethodsState.ps1 index ab47180db6..ffa1e606bc 100644 --- a/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineAuthenticationMethodsState.ps1 +++ b/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineAuthenticationMethodsState.ps1 @@ -119,11 +119,16 @@ function Get-CIPPBaselineAuthenticationMethodsState { } 'TemporaryAccessPass' { if ($Method.Enabled) { - $UsableOnce = [System.Convert]::ToBoolean("$($V.TAPUsableOnce.value ?? $V.TAPUsableOnce ?? 'true')") - $DefaultLifetime = [int]"$($V.TAPDefaultLifetime ?? 60)" - $MinLifetime = [int]"$($V.TAPMinLifetime ?? 60)" - $MaxLifetime = [int]"$($V.TAPMaxLifetime ?? 480)" - $DefaultLength = [int]"$($V.TAPDefaultLength ?? 8)" + # '' survives ?? - a blank lifetime graded AND wrote 0, which Graph + # rejects ("Accesspass minimum lifetime should be greater or equal to + # 10", proven live). Blank means the default, not zero. + $IntOrDefault = { param($Value, $Default) $Raw = "$($Value.value ?? $Value)"; if ([string]::IsNullOrWhiteSpace($Raw)) { [int]$Default } else { [int]$Raw } } + $UsableOnceRaw = "$($V.TAPUsableOnce.value ?? $V.TAPUsableOnce)" + $UsableOnce = [System.Convert]::ToBoolean("$(if ([string]::IsNullOrWhiteSpace($UsableOnceRaw)) { 'true' } else { $UsableOnceRaw })") + $DefaultLifetime = & $IntOrDefault $V.TAPDefaultLifetime 60 + $MinLifetime = & $IntOrDefault $V.TAPMinLifetime 60 + $MaxLifetime = & $IntOrDefault $V.TAPMaxLifetime 480 + $DefaultLength = & $IntOrDefault $V.TAPDefaultLength 8 if ([System.Convert]::ToBoolean("$($Config.isUsableOnce)") -ne $UsableOnce) { $Drifts.Add("$($Method.Label): isUsableOnce should be '$UsableOnce'") } if ([int]"$($Config.defaultLifetimeInMinutes)" -ne $DefaultLifetime) { $Drifts.Add("$($Method.Label): defaultLifetimeInMinutes '$($Config.defaultLifetimeInMinutes)' should be '$DefaultLifetime'") } if ([int]"$($Config.minimumLifetimeInMinutes)" -ne $MinLifetime) { $Drifts.Add("$($Method.Label): minimumLifetimeInMinutes '$($Config.minimumLifetimeInMinutes)' should be '$MinLifetime'") } @@ -138,8 +143,11 @@ function Get-CIPPBaselineAuthenticationMethodsState { } 'QRCodePin' { if ($Method.Enabled) { - $Lifetime = [int]"$($V.QRCodeLifetimeInDays ?? 365)" - $PinLength = [int]"$($V.QRCodePinLength ?? 8)" + # Same '' trap as TAP: blank grades/writes 0 and the helper's + # ValidateRange refuses it before Graph even sees the write. + $IntOrDefault = { param($Value, $Default) $Raw = "$($Value.value ?? $Value)"; if ([string]::IsNullOrWhiteSpace($Raw)) { [int]$Default } else { [int]$Raw } } + $Lifetime = & $IntOrDefault $V.QRCodeLifetimeInDays 365 + $PinLength = & $IntOrDefault $V.QRCodePinLength 8 if ([int]"$($Config.standardQRCodeLifetimeInDays)" -ne $Lifetime) { $Drifts.Add("$($Method.Label): standardQRCodeLifetimeInDays should be '$Lifetime'") } if ([int]"$($Config.pinLength)" -ne $PinLength) { $Drifts.Add("$($Method.Label): pinLength should be '$PinLength'") } $Params['QRCodeLifetimeInDays'] = $Lifetime diff --git a/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineSpamFilterPolicyState.ps1 b/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineSpamFilterPolicyState.ps1 index 9d0d061014..dcf830c669 100644 --- a/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineSpamFilterPolicyState.ps1 +++ b/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineSpamFilterPolicyState.ps1 @@ -164,16 +164,32 @@ function Get-CIPPBaselineSpamFilterPolicyState { # DERIVED write params the static remediate spec cannot express (On/Off strings from # switches). Same derivation as the graded Expected above, so grade and write can # never disagree - these were graded but never written, drifting forever. - $Current | Add-Member -NotePropertyName 'extraPolicyParams' -NotePropertyValue ([PSCustomObject]@{ - IncreaseScoreWithImageLinks = (& $OnOff $V.IncreaseScoreWithImageLinks) - IncreaseScoreWithBizOrInfoUrls = (& $OnOff $V.IncreaseScoreWithBizOrInfoUrls) - MarkAsSpamFramesInHtml = (& $OnOff $V.MarkAsSpamFramesInHtml) - MarkAsSpamObjectTagsInHtml = (& $OnOff $V.MarkAsSpamObjectTagsInHtml) - MarkAsSpamEmbedTagsInHtml = (& $OnOff $V.MarkAsSpamEmbedTagsInHtml) - MarkAsSpamFormTagsInHtml = (& $OnOff $V.MarkAsSpamFormTagsInHtml) - MarkAsSpamWebBugsInHtml = (& $OnOff $V.MarkAsSpamWebBugsInHtml) - MarkAsSpamSensitiveWordList = (& $OnOff $V.MarkAsSpamSensitiveWordList) - }) + $ExtraPolicyParams = [ordered]@{ + IncreaseScoreWithImageLinks = (& $OnOff $V.IncreaseScoreWithImageLinks) + IncreaseScoreWithBizOrInfoUrls = (& $OnOff $V.IncreaseScoreWithBizOrInfoUrls) + MarkAsSpamFramesInHtml = (& $OnOff $V.MarkAsSpamFramesInHtml) + MarkAsSpamObjectTagsInHtml = (& $OnOff $V.MarkAsSpamObjectTagsInHtml) + MarkAsSpamEmbedTagsInHtml = (& $OnOff $V.MarkAsSpamEmbedTagsInHtml) + MarkAsSpamFormTagsInHtml = (& $OnOff $V.MarkAsSpamFormTagsInHtml) + MarkAsSpamWebBugsInHtml = (& $OnOff $V.MarkAsSpamWebBugsInHtml) + MarkAsSpamSensitiveWordList = (& $OnOff $V.MarkAsSpamSensitiveWordList) + } + # The block-list switches follow the classic exactly: enabled with entries writes the + # switch AND the list, anything else FORCES the switch off - omitting it left a + # tenant-side 'on' in place forever while the grade expected 'off' (proven live). + if ($V.EnableLanguageBlockList -eq $true -and @(& $SplitList $V.LanguageBlockList 'lower').Count -gt 0) { + $ExtraPolicyParams['EnableLanguageBlockList'] = $true + $ExtraPolicyParams['LanguageBlockList'] = @(& $SplitList $V.LanguageBlockList 'lower') + } else { + $ExtraPolicyParams['EnableLanguageBlockList'] = $false + } + if ($V.EnableRegionBlockList -eq $true -and @(& $SplitList $V.RegionBlockList 'upper').Count -gt 0) { + $ExtraPolicyParams['EnableRegionBlockList'] = $true + $ExtraPolicyParams['RegionBlockList'] = @(& $SplitList $V.RegionBlockList 'upper') + } else { + $ExtraPolicyParams['EnableRegionBlockList'] = $false + } + $Current | Add-Member -NotePropertyName 'extraPolicyParams' -NotePropertyValue ([PSCustomObject]$ExtraPolicyParams) @{ Expected = $Expected; Current = $Current } } diff --git a/backend/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineEnableFIDO2.ps1 b/backend/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineEnableFIDO2.ps1 index 25d867ed18..8200db458f 100644 --- a/backend/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineEnableFIDO2.ps1 +++ b/backend/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineEnableFIDO2.ps1 @@ -6,9 +6,12 @@ function Invoke-CIPPBaselineEnableFIDO2 { Graph now validates the WHOLE fido2 configuration on any write and requires keyRestrictions on every passkey profile - a tenant whose profiles predate that contract rejects even a plain state PATCH. So the write reads the live - configuration, sets the state (attestation enforced, self-service allowed - the - declarative spec's values), gives any profile missing keyRestrictions the neutral - block-nothing shape, and PATCHes the result back. + configuration, sets state=enabled and self-service allowed, gives any profile + missing keyRestrictions the neutral block-nothing shape, and PATCHes the result + back. Attestation: enforced on profile-less tenants (the classic's write), but + when passkey profiles exist the top-level flag must AGREE with the default + profile's attestationEnforcement or Graph rejects the whole write - so it is + aligned, not forced. Only state is graded. .FUNCTIONALITY Internal #> @@ -22,8 +25,19 @@ function Invoke-CIPPBaselineEnableFIDO2 { $Uri = 'https://graph.microsoft.com/beta/policies/authenticationMethodsPolicy/authenticationMethodConfigurations/Fido2' $Config = New-GraphGetRequest -uri $Uri -tenantid $TenantFilter -AsApp $true $Config.state = 'enabled' - $Config.isAttestationEnforced = $true $Config.isSelfServiceRegistrationAllowed = $true + $PasskeyProfiles = @($Config.passkeyProfiles | Where-Object { $_ }) + if ($PasskeyProfiles.Count -gt 0) { + # With passkey profiles present attestation is governed per-profile, and Graph + # rejects a top-level flag that disagrees with the DEFAULT profile ("Attestation + # enforcement cannot be enabled when it is disabled in default passkey profile"). + # The standard's deliverable is state=enabled - align the legacy flag with the + # default profile instead of fighting a validation that cannot be won here. + $DefaultProfile = @($PasskeyProfiles | Where-Object { "$($_.id)" -eq "$($Config.defaultPasskeyProfile)" }) | Select-Object -First 1 + $Config.isAttestationEnforced = "$(($DefaultProfile ?? $PasskeyProfiles[0]).attestationEnforcement)" -ne 'disabled' + } else { + $Config.isAttestationEnforced = $true + } foreach ($PasskeyProfile in @($Config.passkeyProfiles)) { if (-not $PasskeyProfile) { continue } if (-not $PasskeyProfile.keyRestrictions) { diff --git a/backend/Modules/CIPPCore/Public/Set-CIPPAuthenticationPolicy.ps1 b/backend/Modules/CIPPCore/Public/Set-CIPPAuthenticationPolicy.ps1 index 879796ff93..7eb6edbd41 100644 --- a/backend/Modules/CIPPCore/Public/Set-CIPPAuthenticationPolicy.ps1 +++ b/backend/Modules/CIPPCore/Public/Set-CIPPAuthenticationPolicy.ps1 @@ -45,9 +45,23 @@ function Set-CIPPAuthenticationPolicy { # FIDO2 'FIDO2' { if ($State -eq 'enabled') { - # Honor passed values; otherwise default to enforced/allowed to preserve previous enable behavior - $CurrentInfo.isAttestationEnforced = if ($PSBoundParameters.ContainsKey('FIDO2AttestationEnforced')) { $FIDO2AttestationEnforced } else { $true } + # Honor passed values; otherwise default to enforced/allowed to preserve previous enable behavior. + # With passkey profiles present attestation is governed per-profile, and Graph rejects a + # top-level flag that disagrees with the DEFAULT profile ("Attestation enforcement cannot + # be enabled when it is disabled in default passkey profile") - align instead of forcing. + $PasskeyProfiles = @($CurrentInfo.passkeyProfiles | Where-Object { $_ }) + $CurrentInfo.isAttestationEnforced = if ($PSBoundParameters.ContainsKey('FIDO2AttestationEnforced')) { $FIDO2AttestationEnforced } + elseif ($PasskeyProfiles.Count -gt 0) { + $DefaultProfile = @($PasskeyProfiles | Where-Object { "$($_.id)" -eq "$($CurrentInfo.defaultPasskeyProfile)" }) | Select-Object -First 1 + "$(($DefaultProfile ?? $PasskeyProfiles[0]).attestationEnforcement)" -ne 'disabled' + } else { $true } $CurrentInfo.isSelfServiceRegistrationAllowed = if ($PSBoundParameters.ContainsKey('FIDO2SelfServiceRegistration')) { $FIDO2SelfServiceRegistration } else { $true } + # Graph validates the whole config on write and requires keyRestrictions on every profile. + foreach ($PasskeyProfile in $PasskeyProfiles) { + if (-not $PasskeyProfile.keyRestrictions) { + $PasskeyProfile | Add-Member -NotePropertyName 'keyRestrictions' -NotePropertyValue ([PSCustomObject]@{ isEnforced = $false; enforcementType = 'block'; aaGuids = @() }) -Force + } + } $OptionalLogMessage = "with attestation enforced set to $($CurrentInfo.isAttestationEnforced) and self-service registration set to $($CurrentInfo.isSelfServiceRegistrationAllowed)" } } diff --git a/backend/Tests/Baselines/BaselineEntraHeavies.Tests.ps1 b/backend/Tests/Baselines/BaselineEntraHeavies.Tests.ps1 index 1b0d6c9209..772f763b27 100644 --- a/backend/Tests/Baselines/BaselineEntraHeavies.Tests.ps1 +++ b/backend/Tests/Baselines/BaselineEntraHeavies.Tests.ps1 @@ -107,6 +107,40 @@ Describe 'Get-CIPPBaselineAuthenticationMethodsState' { Invoke-CIPPBaselineAuthenticationMethods -Remediate $null -TenantFilter $script:Tenant -Current $Current Should -Invoke Set-CIPPAuthenticationPolicy -Times 1 -Exactly -ParameterFilter { $AuthenticationMethodId -eq 'SMS' -and $Enabled -eq $false } } + + It 'grades blank TAP lifetimes as the defaults, never 0 - Graph refuses lifetimes under 10' { + # '' survives ?? - blank TAP fields graded AND wrote 0, which Graph rejected live + # ("Accesspass minimum lifetime should be greater or equal to 10"). + $TapPolicy = @{ authenticationMethodConfigurations = @( + @{ id = 'TemporaryAccessPass'; state = 'enabled'; isUsableOnce = $true; defaultLifetimeInMinutes = 60; minimumLifetimeInMinutes = 60; maximumLifetimeInMinutes = 480; defaultLength = 8; includeTargets = @(@{ id = 'all_users'; targetType = 'group' }) } + ) } + Mock New-CIPPDbRequest { @($TapPolicy | ConvertTo-Cached) } + $Item = [PSCustomObject]@{ Variables = [PSCustomObject]@{ TAPEnabled = $true; TAPDefaultLifetime = ''; TAPMinLifetime = ''; TAPMaxLifetime = ''; TAPDefaultLength = '' } } + $Prepared = Get-CIPPBaselineAuthenticationMethodsState -Item $Item -TenantFilter $script:Tenant + @($Prepared.Current.methodsOutOfPolicy).Count | Should -Be 0 + } + + It 'carries the default TAP lifetimes in the remediation set when the state drifts with blank config' { + $TapPolicy = @{ authenticationMethodConfigurations = @( + @{ id = 'TemporaryAccessPass'; state = 'disabled'; isUsableOnce = $true; defaultLifetimeInMinutes = 60; minimumLifetimeInMinutes = 60; maximumLifetimeInMinutes = 480; defaultLength = 8; includeTargets = @(@{ id = 'all_users'; targetType = 'group' }) } + ) } + Mock New-CIPPDbRequest { @($TapPolicy | ConvertTo-Cached) } + $Item = [PSCustomObject]@{ Variables = [PSCustomObject]@{ TAPEnabled = $true; TAPMinLifetime = '' } } + $Prepared = Get-CIPPBaselineAuthenticationMethodsState -Item $Item -TenantFilter $script:Tenant + $Prepared.Current.methodsOutOfPolicy | Should -Match 'state' + $Prepared.Current.remediationSets[0].Params.TAPMinimumLifetime | Should -Be 60 + $Prepared.Current.remediationSets[0].Params.TAPDefaultLength | Should -Be 8 + } + + It 'grades blank QRCodePin settings as the defaults, never 0' { + $QrPolicy = @{ authenticationMethodConfigurations = @( + @{ id = 'QRCodePin'; state = 'enabled'; standardQRCodeLifetimeInDays = 365; pinLength = 8; includeTargets = @(@{ id = 'all_users'; targetType = 'group' }) } + ) } + Mock New-CIPPDbRequest { @($QrPolicy | ConvertTo-Cached) } + $Item = [PSCustomObject]@{ Variables = [PSCustomObject]@{ QRCodePinEnabled = $true; QRCodeLifetimeInDays = ''; QRCodePinLength = '' } } + $Prepared = Get-CIPPBaselineAuthenticationMethodsState -Item $Item -TenantFilter $script:Tenant + @($Prepared.Current.methodsOutOfPolicy).Count | Should -Be 0 + } } Describe 'Get-CIPPBaselineFIDO2PasskeyProfilesState' { diff --git a/backend/Tests/Baselines/BaselineExchangeBatch.Tests.ps1 b/backend/Tests/Baselines/BaselineExchangeBatch.Tests.ps1 index ba30abe7b6..c3fabe6f1b 100644 --- a/backend/Tests/Baselines/BaselineExchangeBatch.Tests.ps1 +++ b/backend/Tests/Baselines/BaselineExchangeBatch.Tests.ps1 @@ -261,3 +261,42 @@ Describe 'Get-CIPPBaselinePhishingSimulationsState' { } } } + +Describe 'Get-CIPPBaselineSpamFilterPolicyState block-list write params' { + BeforeAll { + . (Join-Path (Join-Path $script:RepoRoot 'Modules/CIPPCore/Public/Baselines') 'Get-CIPPBaselineSpamFilterPolicyState.ps1') + $script:SpamPolicy = @{ Name = 'CIPP Default Spam Filter Policy'; EnableRegionBlockList = $true; EnableLanguageBlockList = $false } + $script:SpamRule = @{ Name = 'CIPP Default Spam Filter Policy'; State = 'Enabled'; Priority = 0; HostedContentFilterPolicy = 'CIPP Default Spam Filter Policy'; RecipientDomainIs = @('contoso.com') } + } + BeforeEach { + Mock New-CIPPDbRequest { + switch ($Type) { + 'ExoHostedContentFilterPolicy' { @($script:SpamPolicy | ConvertTo-Cached) } + 'ExoHostedContentFilterRule' { @($script:SpamRule | ConvertTo-Cached) } + 'ExoAcceptedDomains' { @(@{ Name = 'contoso.com' } | ConvertTo-Cached) } + } + } + } + + It 'forces the block-list switches OFF in the write when disabled - omitting them left a tenant-side On in place forever' { + # The classic explicitly wrote EnableRegionBlockList=$false when disabled; the + # rendered spec omitted it, so grade said Off while the tenant stayed On (proven + # live: enableRegionBlockList exp=false got=true after a clean remediation). + $Item = [PSCustomObject]@{ Variables = [PSCustomObject]@{ EnableRegionBlockList = $false } } + $Prepared = Get-CIPPBaselineSpamFilterPolicyState -Item $Item -TenantFilter $script:Tenant + $Prepared.Current.extraPolicyParams.PSObject.Properties.Name | Should -Contain 'EnableRegionBlockList' + $Prepared.Current.extraPolicyParams.PSObject.Properties.Name | Should -Contain 'EnableLanguageBlockList' + $Prepared.Current.extraPolicyParams.EnableRegionBlockList | Should -BeFalse + $Prepared.Current.extraPolicyParams.EnableLanguageBlockList | Should -BeFalse + $Prepared.Current.extraPolicyParams.PSObject.Properties.Name | Should -Not -Contain 'RegionBlockList' + $Prepared.Expected.enableRegionBlockList | Should -BeFalse + } + + It 'writes the switch AND the normalized list when enabled with entries, exactly as graded' { + $Item = [PSCustomObject]@{ Variables = [PSCustomObject]@{ EnableRegionBlockList = $true; RegionBlockList = @('ru', 'kp') } } + $Prepared = Get-CIPPBaselineSpamFilterPolicyState -Item $Item -TenantFilter $script:Tenant + $Prepared.Current.extraPolicyParams.EnableRegionBlockList | Should -BeTrue + @($Prepared.Current.extraPolicyParams.RegionBlockList) | Should -BeExactly @('KP', 'RU') + $Prepared.Expected.enableRegionBlockList | Should -BeTrue + } +} diff --git a/backend/Tests/Baselines/BaselineOneOffVerify.Tests.ps1 b/backend/Tests/Baselines/BaselineOneOffVerify.Tests.ps1 index b9b2207010..1d3f9f95fb 100644 --- a/backend/Tests/Baselines/BaselineOneOffVerify.Tests.ps1 +++ b/backend/Tests/Baselines/BaselineOneOffVerify.Tests.ps1 @@ -264,6 +264,50 @@ Describe 'Invoke-CIPPBaselineEnableFIDO2 passkey profile normalization' { @(($body | ConvertFrom-Json).passkeyProfiles)[1].keyRestrictions.enforcementType -eq 'allow' } } + + It 'aligns the top-level attestation flag with a default profile that DISABLES attestation - Graph rejects disagreement' { + Mock New-GraphGetRequest { [PSCustomObject]@{ + state = 'disabled'; isAttestationEnforced = $false; isSelfServiceRegistrationAllowed = $true + defaultPasskeyProfile = 'p-default' + passkeyProfiles = @( + [PSCustomObject]@{ id = 'p-default'; name = 'Default'; attestationEnforcement = 'disabled'; keyRestrictions = [PSCustomObject]@{ isEnforced = $false; enforcementType = 'allow'; aaGuids = @() } } + ) + } } + Mock New-GraphPostRequest { } + Invoke-CIPPBaselineEnableFIDO2 -Remediate ([PSCustomObject]@{}) -TenantFilter 'contoso.onmicrosoft.com' -Current $null + Should -Invoke New-GraphPostRequest -Times 1 -Exactly -ParameterFilter { + ($body | ConvertFrom-Json).state -eq 'enabled' -and + ($body | ConvertFrom-Json).isAttestationEnforced -eq $false + } + } + + It 'aligns with the DEFAULT profile, not the first one in the list' { + Mock New-GraphGetRequest { [PSCustomObject]@{ + state = 'disabled'; isAttestationEnforced = $false; isSelfServiceRegistrationAllowed = $true + defaultPasskeyProfile = 'p-2' + passkeyProfiles = @( + [PSCustomObject]@{ id = 'p-1'; attestationEnforcement = 'disabled'; keyRestrictions = [PSCustomObject]@{ isEnforced = $false; enforcementType = 'allow'; aaGuids = @() } } + [PSCustomObject]@{ id = 'p-2'; attestationEnforcement = 'enforced'; keyRestrictions = [PSCustomObject]@{ isEnforced = $false; enforcementType = 'allow'; aaGuids = @() } } + ) + } } + Mock New-GraphPostRequest { } + Invoke-CIPPBaselineEnableFIDO2 -Remediate ([PSCustomObject]@{}) -TenantFilter 'contoso.onmicrosoft.com' -Current $null + Should -Invoke New-GraphPostRequest -Times 1 -Exactly -ParameterFilter { + ($body | ConvertFrom-Json).isAttestationEnforced -eq $true + } + } + + It 'keeps the classic write on a profile-less tenant: attestation enforced' { + Mock New-GraphGetRequest { [PSCustomObject]@{ + state = 'disabled'; isAttestationEnforced = $false; isSelfServiceRegistrationAllowed = $false + passkeyProfiles = @() + } } + Mock New-GraphPostRequest { } + Invoke-CIPPBaselineEnableFIDO2 -Remediate ([PSCustomObject]@{}) -TenantFilter 'contoso.onmicrosoft.com' -Current $null + Should -Invoke New-GraphPostRequest -Times 1 -Exactly -ParameterFilter { + ($body | ConvertFrom-Json).isAttestationEnforced -eq $true + } + } } Describe 'Get-CIPPBaselineDetectCADriftState SharePoint side-effect policies' { diff --git a/backend/Tests/Private/Set-CIPPAuthenticationPolicy.Tests.ps1 b/backend/Tests/Private/Set-CIPPAuthenticationPolicy.Tests.ps1 index c5402c1796..8448d5155d 100644 --- a/backend/Tests/Private/Set-CIPPAuthenticationPolicy.Tests.ps1 +++ b/backend/Tests/Private/Set-CIPPAuthenticationPolicy.Tests.ps1 @@ -72,6 +72,47 @@ Describe 'Set-CIPPAuthenticationPolicy' { $body.isSelfServiceRegistrationAllowed | Should -Be $true } + It 'aligns FIDO2 attestation with the DEFAULT passkey profile when no parameter is passed' { + # Graph rejects a top-level attestation flag that disagrees with the default + # profile ("Attestation enforcement cannot be enabled when it is disabled in + # default passkey profile", proven live) - the enable must align, not force. + $script:mockCurrentInfo = [pscustomobject]@{ + state = 'disabled' + isAttestationEnforced = $false + isSelfServiceRegistrationAllowed = $true + defaultPasskeyProfile = 'p-default' + passkeyProfiles = @( + [pscustomobject]@{ id = 'p-other'; attestationEnforcement = 'enforced'; keyRestrictions = [pscustomobject]@{ isEnforced = $false; enforcementType = 'allow'; aaGuids = @() } } + [pscustomobject]@{ id = 'p-default'; attestationEnforcement = 'disabled'; keyRestrictions = [pscustomobject]@{ isEnforced = $false; enforcementType = 'allow'; aaGuids = @() } } + ) + } + + Set-CIPPAuthenticationPolicy -Tenant 'contoso.onmicrosoft.com' -AuthenticationMethodId 'FIDO2' -Enabled $true + + $body = $script:lastBody | ConvertFrom-Json + $body.state | Should -Be 'enabled' + $body.isAttestationEnforced | Should -Be $false + } + + It 'gives FIDO2 passkey profiles missing keyRestrictions the neutral shape - Graph validates the whole config' { + $script:mockCurrentInfo = [pscustomobject]@{ + state = 'disabled' + isAttestationEnforced = $false + isSelfServiceRegistrationAllowed = $true + defaultPasskeyProfile = 'p-1' + passkeyProfiles = @( + [pscustomobject]@{ id = 'p-1'; attestationEnforcement = 'enforced' } + ) + } + + Set-CIPPAuthenticationPolicy -Tenant 'contoso.onmicrosoft.com' -AuthenticationMethodId 'FIDO2' -Enabled $true + + $body = $script:lastBody | ConvertFrom-Json + $body.isAttestationEnforced | Should -Be $true + @($body.passkeyProfiles)[0].keyRestrictions.enforcementType | Should -Be 'block' + @($body.passkeyProfiles)[0].keyRestrictions.isEnforced | Should -Be $false + } + It 'scopes the method to all users when GroupIds contains all_users' { $script:mockCurrentInfo = [pscustomobject]@{ state = 'disabled' From a79aaf87d2e735f4f8bc527ceb49c4e045313a7e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 18 Aug 2026 04:12:29 +0000 Subject: [PATCH 119/226] chore(deps): bump github/codeql-action from 4.37.6 to 4.37.7 Bumps [github/codeql-action](https://github.com/github/codeql-action) from 4.37.6 to 4.37.7. - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/v4.37.6...v4.37.7) --- updated-dependencies: - dependency-name: github/codeql-action dependency-version: 4.37.7 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- .github/workflows/CodeQL_Analyser.yml | 6 +++--- .github/workflows/codeql.yml | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/CodeQL_Analyser.yml b/.github/workflows/CodeQL_Analyser.yml index d108362151..9449888dcf 100644 --- a/.github/workflows/CodeQL_Analyser.yml +++ b/.github/workflows/CodeQL_Analyser.yml @@ -26,11 +26,11 @@ jobs: - name: Checkout Repository uses: actions/checkout@v6 - name: Initialize CodeQL - uses: github/codeql-action/init@v4.37.6 + uses: github/codeql-action/init@v4.37.7 with: languages: ${{ matrix.language }} queries: security-extended - name: Autobuild - uses: github/codeql-action/autobuild@v4.37.6 + uses: github/codeql-action/autobuild@v4.37.7 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v4.37.6 + uses: github/codeql-action/analyze@v4.37.7 diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 4e65d6cdfb..4b8dc8e907 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -24,11 +24,11 @@ jobs: - name: Checkout Repository uses: actions/checkout@v4 - name: Initialize CodeQL - uses: github/codeql-action/init@v4.37.6 + uses: github/codeql-action/init@v4.37.7 with: languages: ${{ matrix.language }} source-root: frontend - name: Autobuild - uses: github/codeql-action/autobuild@v4.37.6 + uses: github/codeql-action/autobuild@v4.37.7 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v4.37.6 + uses: github/codeql-action/analyze@v4.37.7 From b92e56013fbde641661b1e94d1ce7b3f93a55d86 Mon Sep 17 00:00:00 2001 From: k-grube Date: Tue, 18 Aug 2026 00:35:02 -0700 Subject: [PATCH 120/226] fix: keep universal search and theme toggle reachable at 900-1199px top-nav drops both bar icons at useIsMobileLayout (down lg) while the account popover offered them only below md, so the band had no entry point for either. popover entries now read the same hook; the identity row stays on mdDown, it pairs with the avatar-row details block, not the nav pivot. --- frontend/src/layouts/account-popover.js | 21 ++-- .../tests/layouts/AccountPopover.test.jsx | 99 +++++++++++++++++++ 2 files changed, 112 insertions(+), 8 deletions(-) create mode 100644 frontend/tests/layouts/AccountPopover.test.jsx diff --git a/frontend/src/layouts/account-popover.js b/frontend/src/layouts/account-popover.js index eb6f140692..23b7fc3f70 100644 --- a/frontend/src/layouts/account-popover.js +++ b/frontend/src/layouts/account-popover.js @@ -23,6 +23,7 @@ import { useMediaQuery, } from "@mui/material"; import { usePopover } from "../hooks/use-popover"; +import { useIsMobileLayout } from "../hooks/use-breakpoint"; import { paths } from "../paths"; import { ApiGetCall } from "../api/ApiCall"; import { CogIcon, DocumentTextIcon, LifebuoyIcon, TrashIcon } from "@heroicons/react/24/outline"; @@ -45,6 +46,7 @@ export const AccountPopover = (props) => { const router = useRouter(); const pathname = usePathname(); const mdDown = useMediaQuery((theme) => theme.breakpoints.down("md")); + const navCollapsed = useIsMobileLayout(); const popover = usePopover(); const queryClient = useQueryClient(); const { openReleaseNotes } = useReleaseNotes(); @@ -145,16 +147,19 @@ export const AccountPopover = (props) => { PaperProps={{ sx: { width: 260 } }} > + {/* Pairs with the trigger above: the identity is either beside the avatar or here. */} {mdDown && ( + + + + )} + {/* Home for the two bar icons top-nav drops at navCollapsed (useIsMobileLayout), + so they stay reachable wherever the bar isn't showing them. */} + {navCollapsed && ( <> - - - - {/* Universal search's mobile home — the top bar gives its width to the - tenant chip instead of a search icon. */} {onOpenSearch && ( { diff --git a/frontend/tests/layouts/AccountPopover.test.jsx b/frontend/tests/layouts/AccountPopover.test.jsx new file mode 100644 index 0000000000..ebf14be9b6 --- /dev/null +++ b/frontend/tests/layouts/AccountPopover.test.jsx @@ -0,0 +1,99 @@ +import React from "react"; +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { renderWithProviders } from "../test-utils"; +import { cippPrincipal } from "../mocks/fixtures"; + +vi.mock("next/navigation", () => ({ + usePathname: () => "/", + useRouter: () => ({ push: vi.fn() }), +})); + +// jsdom has no width-based matchMedia, so the nav pivot is driven by mocking the hook, and +// MUI's own useMediaQuery answers false there, i.e. the >= md side of the popover's mdDown +// gate. that pairing is the 900-1199 band: nav collapsed, still above md. +const layoutState = vi.hoisted(() => ({ isMobile: false })); +vi.mock("../../src/hooks/use-breakpoint", async (importOriginal) => ({ + ...(await importOriginal()), + useIsMobileLayout: () => layoutState.isMobile, +})); + +// stable identities, a fresh object per call re-renders forever +const idle = vi.hoisted(() => ({ + isSuccess: false, + isFetching: false, + isPending: false, + isError: false, + data: undefined, + mutate: () => {}, + reset: () => {}, + refetch: () => {}, +})); +const meResult = vi.hoisted(() => ({ + isSuccess: true, + isFetching: false, + isPending: false, + isError: false, + data: undefined, + refetch: () => {}, +})); +vi.mock("../../src/api/ApiCall", () => ({ + ApiGetCall: ({ url }) => (url === "/api/me" ? meResult : idle), + ApiPostCall: () => idle, + ApiGetCallWithPagination: () => ({ ...idle, fetchNextPage: () => {} }), +})); + +import { AccountPopover } from "../../src/layouts/account-popover"; + +const renderPopover = () => { + const onThemeSwitch = vi.fn(); + const onOpenSearch = vi.fn(); + renderWithProviders( + + ); + return { onThemeSwitch, onOpenSearch }; +}; + +// avatar fallback glyph for john@contoso.com, the popover's only trigger +const openPopover = async () => userEvent.click(await screen.findByText("J")); + +describe("AccountPopover", () => { + beforeEach(() => { + layoutState.isMobile = false; + meResult.data = cippPrincipal(["editor"]); + }); + + it("offers universal search and the theme toggle whenever the top bar hides their icons", async () => { + layoutState.isMobile = true; + const { onThemeSwitch, onOpenSearch } = renderPopover(); + + await openPopover(); + await userEvent.click(screen.getByText("Universal Search")); + expect(onOpenSearch).toHaveBeenCalled(); + + await openPopover(); + await userEvent.click(screen.getByText("Dark Mode")); + expect(onThemeSwitch).toHaveBeenCalled(); + }); + + it("leaves search and theme to the top bar while it still renders their icons", async () => { + renderPopover(); + + await openPopover(); + expect(screen.queryByText("Universal Search")).toBeNull(); + expect(screen.queryByText("Dark Mode")).toBeNull(); + }); + + it("does not repeat the signed-in identity that the trigger is already showing", async () => { + layoutState.isMobile = true; + renderPopover(); + + await openPopover(); + expect(screen.getAllByText("john@contoso.com")).toHaveLength(1); + }); +}); From 786a35ba3c7ec3adb60bd0e3e5aefd6215223c03 Mon Sep 17 00:00:00 2001 From: k-grube Date: Tue, 18 Aug 2026 00:51:39 -0700 Subject: [PATCH 121/226] fix: stop the mobile nav drawer scrolling past its content simplebar's wrapper is height:inherit, which resolves to auto under flex-grow, so it laid out at the full list height and that height escaped into the drawer paper's own overflow-y:auto. paper.scrollHeight 2461 vs clientHeight 818 with CIPP > Advanced expanded, so the paper dragged the sticky header off the top and carried the pinned sponsor up. --- frontend/src/layouts/mobile-nav.js | 5 ++ frontend/tests/layouts/MobileNav.stories.jsx | 60 ++++++++++++++++++++ 2 files changed, 65 insertions(+) diff --git a/frontend/src/layouts/mobile-nav.js b/frontend/src/layouts/mobile-nav.js index 1e7e0e0315..264b7cd97a 100644 --- a/frontend/src/layouts/mobile-nav.js +++ b/frontend/src/layouts/mobile-nav.js @@ -182,6 +182,11 @@ export const MobileNav = (props) => { sx={{ flexGrow: 1, minHeight: 0, + // wrapper is height:inherit, auto under flex-grow, and the escaped list height + // scrolls the drawer paper itself + "& .simplebar-wrapper": { + height: "100%", + }, "& .simplebar-content": { height: "100%", }, diff --git a/frontend/tests/layouts/MobileNav.stories.jsx b/frontend/tests/layouts/MobileNav.stories.jsx index 9bb0198e39..1c4977bbc1 100644 --- a/frontend/tests/layouts/MobileNav.stories.jsx +++ b/frontend/tests/layouts/MobileNav.stories.jsx @@ -127,3 +127,63 @@ export const DragClosesFromWhereItWasLeft = { ) }, } + +// enough rows to overflow a phone-height drawer once the group is expanded +const tallItems = [ + { title: 'Dashboard', path: '/' }, + { + title: 'CIPP', + path: '/cipp', + items: [ + { title: 'Custom Data', path: '/cipp/custom-data' }, + { + title: 'Advanced', + path: '/cipp/advanced', + items: [ + { title: 'Super Admin', path: '/cipp/advanced/super-admin/tenant-mode' }, + { title: 'Container Management', path: '/cipp/advanced/container-management/status' }, + { title: 'Authentication', path: '/cipp/advanced/authentication' }, + { title: 'Timers', path: '/cipp/advanced/timers' }, + ], + }, + { title: 'Settings', path: '/cipp/settings' }, + { title: 'Preferences', path: '/cipp/preferences' }, + ], + }, + ...Array.from({ length: 14 }, (_, index) => ({ + title: `Section ${index + 1}`, + path: `/section-${index + 1}`, + })), +] + +export const NavListIsTheOnlyScroller = { + render: () => , + play: async ({ canvasElement }) => { + const onAPhone = await shrinkToPhoneViewport() + const canvas = within(canvasElement) + + await userEvent.click(canvas.getByTestId('open-nav')) + const paper = await waitFor(() => { + const node = document.querySelector('.MuiDrawer-paper') + expect(node).not.toBeNull() + return node + }) + if (!onAPhone) { + return + } + await waitFor(() => + expect(new DOMMatrixReadOnly(getComputedStyle(paper).transform).m41).toBe(0) + ) + + await userEvent.click(await within(paper).findByText('CIPP')) + + // the list has to overflow, or the paper assertion below would pass for the wrong reason + const scroller = paper.querySelector('.simplebar-content-wrapper') + await waitFor(() => + expect(scroller.scrollHeight).toBeGreaterThan(scroller.clientHeight + 200) + ) + + // a scrollable paper carries the pinned sponsor up with it and leaves blank drawer below + expect(paper.scrollHeight).toBeLessThanOrEqual(paper.clientHeight + 1) + }, +} From 34377cb371e1c6627e02da335a8fedee2bcee02f Mon Sep 17 00:00:00 2001 From: k-grube Date: Tue, 18 Aug 2026 00:51:54 -0700 Subject: [PATCH 122/226] fix: indent nested mobile nav items by depth mobile-nav-item hardcoded px:6px at every depth, so CIPP > Advanced > Super Admin read as three siblings. same step side-nav-item already uses. --- frontend/src/layouts/mobile-nav-item.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/frontend/src/layouts/mobile-nav-item.js b/frontend/src/layouts/mobile-nav-item.js index 961d01ee33..4367198a7a 100644 --- a/frontend/src/layouts/mobile-nav-item.js +++ b/frontend/src/layouts/mobile-nav-item.js @@ -24,6 +24,9 @@ export const MobileNavItem = (props) => { const isGlobal = scope === "global"; const [open, setOpen] = useState(openImmediately); + // same step as side-nav-item, nesting reads the same in both navs + const indent = depth > 0 ? depth * 1.5 : 1; + const handleToggle = useCallback(() => { setOpen((prevOpen) => !prevOpen); }, []); @@ -43,7 +46,7 @@ export const MobileNavItem = (props) => { fontSize: 14, fontWeight: 500, justifyContent: 'flex-start', - px: '6px', + px: `${indent * 6}px`, py: '12px', textAlign: 'left', whiteSpace: 'nowrap', @@ -119,7 +122,7 @@ export const MobileNavItem = (props) => { fontSize: 14, fontWeight: 500, justifyContent: 'flex-start', - px: '6px', + px: `${indent * 6}px`, py: '12px', textAlign: 'left', whiteSpace: 'nowrap', From e2a6e577d34c311dde8cc1c91963008849c5d675 Mon Sep 17 00:00:00 2001 From: Bobby <31723128+kris6673@users.noreply.github.com> Date: Tue, 18 Aug 2026 10:08:55 +0200 Subject: [PATCH 123/226] feat(quarantine): add message details, actions and MIME parsing to quarantine Ports the quarantine overhaul previously filed in KelvinTegelaar/CIPP#6175 and KelvinTegelaar/CIPP-API#2094, adapted to the monorepo: - backend: read/parse MIME contents (Read-CippMimeMessage), enrichment via Add-CIPPQuarantineMessageProperties, details/headers endpoints, submit-to- Microsoft endpoint, per-entity-type fan-out in the all-tenants trigger - frontend: CippQuarantineTable with preview, headers, download, trace, submit and Defender deep-link actions plus CippQuarantineDetails flyout; CippOffCanvas gains an actionsPosition prop for bottom action placement - permissions: SecurityAnalyzedMessage.Read.All/ReadWrite.All and ThreatSubmission.ReadWrite.All granted to the SAM app, RBAC entries for the new endpoints, function-permissions refreshed - docs: quarantine page updated with tabs, filters, details flyout and the full action table; MIME parsing covered by Pester tests, new components by vitest tests --- backend/Config/PermissionsTranslator.json | 14 + backend/Config/openapi.json | 343 ++++++++++- .../Push-ListMailQuarantineAllTenants.ps1 | 25 +- .../Add-CIPPQuarantineMessageProperties.ps1 | 21 + .../CIPPCore/Public/Read-CippMimeMessage.ps1 | 363 ++++++++++++ .../Invoke-ExecMailQuarantineSubmit.ps1 | 52 ++ .../Invoke-ExecQuarantineManagement.ps1 | 54 +- .../Spamfilter/Invoke-ListMailQuarantine.ps1 | 18 +- .../Invoke-ListMailQuarantineMessage.ps1 | 5 +- ...nvoke-ListMailQuarantineMessageDetails.ps1 | 296 ++++++++++ ...Invoke-ListMailQuarantineMessageHeader.ps1 | 34 ++ .../Private/Read-CippMimeMessage.Tests.ps1 | 183 ++++++ .../email/administration/quarantine.md | 29 +- .../CippComponents/CippOffCanvas.jsx | 28 +- .../CippComponents/CippQuarantineDetails.jsx | 424 +++++++++++++ .../CippComponents/CippQuarantineTable.jsx | 555 ++++++++++++++++++ .../email/administration/quarantine/files.js | 14 + .../email/administration/quarantine/index.js | 258 +------- .../administration/quarantine/tabOptions.json | 17 + .../email/administration/quarantine/teams.js | 14 + .../CippComponents/CippOffCanvas.test.jsx | 46 +- .../CippQuarantineDetails.test.jsx | 180 ++++++ .../CippQuarantineTable.test.jsx | 98 ++++ 23 files changed, 2769 insertions(+), 302 deletions(-) create mode 100644 backend/Modules/CIPPCore/Public/Functions/Add-CIPPQuarantineMessageProperties.ps1 create mode 100644 backend/Modules/CIPPCore/Public/Read-CippMimeMessage.ps1 create mode 100644 backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Spamfilter/Invoke-ExecMailQuarantineSubmit.ps1 create mode 100644 backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Spamfilter/Invoke-ListMailQuarantineMessageDetails.ps1 create mode 100644 backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Spamfilter/Invoke-ListMailQuarantineMessageHeader.ps1 create mode 100644 backend/Tests/Private/Read-CippMimeMessage.Tests.ps1 create mode 100644 frontend/src/components/CippComponents/CippQuarantineDetails.jsx create mode 100644 frontend/src/components/CippComponents/CippQuarantineTable.jsx create mode 100644 frontend/src/pages/email/administration/quarantine/files.js create mode 100644 frontend/src/pages/email/administration/quarantine/tabOptions.json create mode 100644 frontend/src/pages/email/administration/quarantine/teams.js create mode 100644 frontend/tests/components/CippComponents/CippQuarantineDetails.test.jsx create mode 100644 frontend/tests/components/CippComponents/CippQuarantineTable.test.jsx diff --git a/backend/Config/PermissionsTranslator.json b/backend/Config/PermissionsTranslator.json index c8a297b6e5..30c15bbb5b 100644 --- a/backend/Config/PermissionsTranslator.json +++ b/backend/Config/PermissionsTranslator.json @@ -1,4 +1,18 @@ [ + { + "description": "Allows the app to read email metadata and security detection details for all emails in your organization, without a signed-in user.", + "displayName": "Read metadata and detection details for all emails in your organization", + "id": "b48f7ac2-044d-4281-b02f-75db744d6f5f", + "origin": "Application", + "value": "SecurityAnalyzedMessage.Read.All" + }, + { + "description": "Allows the app to read email metadata and security detection details, and execute remediation actions like deleting an email, for all emails in your organization, without a signed-in user.", + "displayName": "Read metadata, detection details, and execute remediation actions on all emails in your organization", + "id": "04c55753-2244-4c25-87fc-704ab82a4f69", + "origin": "Application", + "value": "SecurityAnalyzedMessage.ReadWrite.All" + }, { "description": "Allows the app to impersonate the signed-in user to access the Partner Center API.", "displayName": "Partner Center as User", diff --git a/backend/Config/openapi.json b/backend/Config/openapi.json index a0b69d81ce..dc26ac35e9 100644 --- a/backend/Config/openapi.json +++ b/backend/Config/openapi.json @@ -24120,6 +24120,157 @@ "x-cipp-role": "Exchange.Mailbox.ReadWrite" } }, + "/api/ExecMailQuarantineSubmit": { + "post": { + "summary": "ExecMailQuarantineSubmit", + "operationId": "ExecMailQuarantineSubmit", + "tags": [ + "Email-Exchange > Spamfilter" + ], + "description": "Submits a quarantined email message to Microsoft for review (threat submission) via the Graph API.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "category": { + "$ref": "#/components/schemas/LabelValue" + }, + "Identity": { + "type": "string" + }, + "RecipientAddress": { + "type": "string" + }, + "tenantFilter": { + "type": "string" + } + }, + "required": [ + "Identity", + "tenantFilter" + ] + } + } + } + }, + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Derived from the Microsoft Graph entity it queries, and the fields the endpoint selects onto each record. This endpoint returns the Graph response as-is without selecting fields, so these are the properties the entity CAN carry (x-cipp-field-source: graph-entity) rather than a proven projection - Graph returns a default subset unless asked otherwise.", + "properties": { + "adminReview": { + "type": "object", + "x-cipp-field-source": "graph-entity" + }, + "attackSimulationInfo": { + "type": "object", + "x-cipp-field-source": "graph-entity" + }, + "category": { + "type": "object", + "x-cipp-field-source": "graph-entity" + }, + "clientSource": { + "type": "object", + "x-cipp-field-source": "graph-entity" + }, + "contentType": { + "type": "object", + "x-cipp-field-source": "graph-entity" + }, + "createdBy": { + "type": "object", + "x-cipp-field-source": "graph-entity" + }, + "createdDateTime": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "id": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "internetMessageId": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "originalCategory": { + "type": "object", + "x-cipp-field-source": "graph-entity" + }, + "receivedDateTime": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "recipientEmailAddress": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "result": { + "type": "object", + "x-cipp-field-source": "graph-entity" + }, + "Results": { + "x-cipp-field-source": "backend" + }, + "sender": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "senderIP": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "source": { + "type": "object", + "x-cipp-field-source": "graph-entity" + }, + "status": { + "type": "object", + "x-cipp-field-source": "graph-entity" + }, + "subject": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "tenantAllowOrBlockListAction": { + "type": "object", + "x-cipp-field-source": "graph-entity" + }, + "tenantId": { + "type": "string", + "x-cipp-field-source": "graph-entity" + } + } + } + } + } + }, + "401": { + "description": "Unauthorized - invalid or missing bearer token" + }, + "403": { + "description": "Forbidden - caller lacks the required RBAC role" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "x-cipp-role": "Exchange.SpamFilter.ReadWrite" + } + }, "/api/ExecMailTest": { "get": { "summary": "ExecMailTest", @@ -27852,6 +28003,9 @@ } } }, + "400": { + "description": "Bad request - missing required field or invalid input" + }, "401": { "description": "Unauthorized - invalid or missing bearer token" }, @@ -48136,6 +48290,15 @@ ], "description": "Lists quarantined email messages in Exchange Online Protection for a tenant.", "parameters": [ + { + "name": "EntityType", + "in": "query", + "description": "Entity type: Email (default), SharePointOnline (files) or Teams (Teams messages)", + "required": false, + "schema": { + "type": "string" + } + }, { "name": "manualPagination", "in": "query", @@ -48172,13 +48335,16 @@ "type": "string", "x-cipp-field-source": "storage" }, + "Expires": { + "x-cipp-field-source": "frontend" + }, "Metadata": { "x-cipp-field-source": "backend" }, "PartitionKey": { "x-cipp-field-source": "storage" }, - "PolicyName": { + "PolicyType": { "x-cipp-field-source": "frontend" }, "QuarantineMessage": { @@ -48191,6 +48357,9 @@ "RecipientAddress": { "x-cipp-field-source": "frontend" }, + "ReleasedUser": { + "x-cipp-field-source": "frontend" + }, "ReleaseStatus": { "x-cipp-field-source": "frontend" }, @@ -48290,6 +48459,178 @@ "x-cipp-role": "Exchange.SpamFilter.Read" } }, + "/api/ListMailQuarantineMessageDetails": { + "get": { + "summary": "ListMailQuarantineMessageDetails", + "operationId": "ListMailQuarantineMessageDetails", + "tags": [ + "Email-Exchange > Spamfilter" + ], + "description": "Retrieves Defender analyzed email details (threats, delivery, authentication, URLs, attachments)\nfor a quarantined message via the Graph beta security/collaboration/analyzedEmails API.\nFalls back to parsing the message headers (Authentication-Results and X-Forefront-Antispam-Report)\nfor tenants without Defender for Office 365 Plan 2.", + "parameters": [ + { + "name": "Identity", + "in": "query", + "description": "Only the quarantine Identity is trusted from the caller. NetworkMessageId, RecipientAddress and ReceivedTime are derived server-side from the quarantine message itself (see below) so this endpoint cannot be used to pull Defender analyzedEmail data for arbitrary, non-quarantined messages in the tenant.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "$ref": "#/components/parameters/tenantFilter" + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "description": "Derived from the Microsoft Graph entity it queries, and the fields the endpoint selects onto each record. The fields taken from Graph are the ones this endpoint selects, so they are what the response actually carries.", + "properties": { + "authenticationDetails": { + "type": "object", + "x-cipp-field-source": "graph,backend" + }, + "bulkComplaintLevel": { + "type": "string", + "x-cipp-field-source": "graph,backend" + }, + "compositeAuthentication": { + "x-cipp-field-source": "backend" + }, + "directionality": { + "type": "object", + "x-cipp-field-source": "graph,backend" + }, + "displayName": { + "x-cipp-field-source": "backend" + }, + "dkim": { + "x-cipp-field-source": "backend" + }, + "dmarc": { + "x-cipp-field-source": "backend" + }, + "internetMessageId": { + "type": "string", + "x-cipp-field-source": "graph,backend" + }, + "ipv4": { + "x-cipp-field-source": "backend" + }, + "language": { + "type": "string", + "x-cipp-field-source": "graph,backend" + }, + "location": { + "x-cipp-field-source": "backend" + }, + "recipientEmailAddress": { + "type": "string", + "x-cipp-field-source": "graph,backend" + }, + "returnPath": { + "type": "string", + "x-cipp-field-source": "graph,backend" + }, + "senderDetail": { + "type": "object", + "x-cipp-field-source": "graph,backend" + }, + "senderPolicyFramework": { + "x-cipp-field-source": "backend" + }, + "spamConfidenceLevel": { + "type": "string", + "x-cipp-field-source": "graph,backend" + }, + "threatTypes": { + "type": "array", + "x-cipp-field-source": "graph,backend" + } + } + } + } + } + } + }, + "400": { + "description": "Bad request - missing required field or invalid input" + }, + "401": { + "description": "Unauthorized - invalid or missing bearer token" + }, + "403": { + "description": "Forbidden - caller lacks the required RBAC role" + }, + "404": { + "description": "Not found" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "x-cipp-role": "Exchange.SpamFilter.Read" + } + }, + "/api/ListMailQuarantineMessageHeader": { + "get": { + "summary": "ListMailQuarantineMessageHeader", + "operationId": "ListMailQuarantineMessageHeader", + "tags": [ + "Email-Exchange > Spamfilter" + ], + "description": "Retrieves the message headers of a specific quarantined email message by its Identity.", + "parameters": [ + { + "name": "Identity", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "$ref": "#/components/parameters/tenantFilter" + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "description": "Not described statically: this endpoint returns the upstream response as-is, so its fields are determined by the upstream API rather than by CIPP. Call the endpoint to see the actual shape, or add a response schema in backend/Config/openapi-overrides." + } + } + } + } + }, + "401": { + "description": "Unauthorized - invalid or missing bearer token" + }, + "403": { + "description": "Forbidden - caller lacks the required RBAC role" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "x-cipp-role": "Exchange.SpamFilter.Read" + } + }, "/api/ListMalwareFilters": { "get": { "summary": "ListMalwareFilters", diff --git a/backend/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-ListMailQuarantineAllTenants.ps1 b/backend/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-ListMailQuarantineAllTenants.ps1 index 515d2e0dd0..dbd3138340 100644 --- a/backend/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-ListMailQuarantineAllTenants.ps1 +++ b/backend/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-ListMailQuarantineAllTenants.ps1 @@ -11,15 +11,28 @@ Write-Host "PowerShell queue trigger function processed work item: $($Tenant.defaultDomainName)" try { - $Page = 1 $PageSize = 1000 $quarantineMessages = [System.Collections.Generic.List[object]]::new() - do { - $Results = New-ExoRequest -tenantid $domainName -cmdlet 'Get-QuarantineMessage' -cmdParams @{ PageSize = $PageSize; Page = $Page } | Select-Object -ExcludeProperty *data.type* - if ($Results) { $quarantineMessages.AddRange(@($Results)) } - $Page++ - } while (@($Results).Count -eq $PageSize) + # Email is available everywhere; SharePointOnline/Teams quarantine requires Defender for Office 365, + # so fetch each entity type separately and tolerate per-type failures on unlicensed tenants. + # EXO REST silently ignores -EntityType SharePointOnline; the documented filter for Safe Attachments + # files is -QuarantineTypes SPOMalware. Email/Teams work fine via -EntityType. + foreach ($EntityType in @('Email', 'SharePointOnline', 'Teams')) { + $EntityTypeParams = if ($EntityType -eq 'SharePointOnline') { @{ QuarantineTypes = 'SPOMalware' } } else { @{ EntityType = $EntityType } } + try { + $Page = 1 + do { + $Results = New-ExoRequest -tenantid $domainName -cmdlet 'Get-QuarantineMessage' -cmdParams (@{ PageSize = $PageSize; Page = $Page } + $EntityTypeParams) | Select-Object -ExcludeProperty *data.type* + if ($Results) { $quarantineMessages.AddRange(@($Results)) } + $Page++ + } while (@($Results).Count -eq $PageSize) + } catch { + if ($EntityType -eq 'Email') { throw } + Write-Host "Could not get $EntityType quarantine messages for $domainName : $($_.Exception.Message)" + } + } foreach ($message in $quarantineMessages) { + Add-CIPPQuarantineMessageProperties -Message $message -Tenant $domainName -CustomerId $Tenant.customerId $messageData = @{ QuarantineMessage = [string]($message | ConvertTo-Json -Depth 10 -Compress) RowKey = [string](New-Guid).Guid diff --git a/backend/Modules/CIPPCore/Public/Functions/Add-CIPPQuarantineMessageProperties.ps1 b/backend/Modules/CIPPCore/Public/Functions/Add-CIPPQuarantineMessageProperties.ps1 new file mode 100644 index 0000000000..f039777f00 --- /dev/null +++ b/backend/Modules/CIPPCore/Public/Functions/Add-CIPPQuarantineMessageProperties.ps1 @@ -0,0 +1,21 @@ +function Add-CIPPQuarantineMessageProperties { + <# + .SYNOPSIS + Adds CIPP computed properties to a quarantine message object. + .DESCRIPTION + Enriches Get-QuarantineMessage output with Tenant, CustomerId and NetworkMessageId. + NetworkMessageId is the first half of the quarantine Identity ({NetworkMessageId}\{RecipientGuid}) + and is used by the frontend to build Microsoft Defender email entity deep links. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)]$Message, + [Parameter(Mandatory = $true)][string]$Tenant, + [string]$CustomerId + ) + $Message | Add-Member -NotePropertyName 'Tenant' -NotePropertyValue $Tenant -Force + if ($CustomerId) { + $Message | Add-Member -NotePropertyName 'CustomerId' -NotePropertyValue $CustomerId -Force + } + $Message | Add-Member -NotePropertyName 'NetworkMessageId' -NotePropertyValue ([string]($Message.Identity -split '\\')[0]) -Force +} diff --git a/backend/Modules/CIPPCore/Public/Read-CippMimeMessage.ps1 b/backend/Modules/CIPPCore/Public/Read-CippMimeMessage.ps1 new file mode 100644 index 0000000000..f9028a7f84 --- /dev/null +++ b/backend/Modules/CIPPCore/Public/Read-CippMimeMessage.ps1 @@ -0,0 +1,363 @@ +function Read-CippMimeMessage { + <# + .SYNOPSIS + Extract URLs and attachments from a raw MIME message. + .DESCRIPTION + Pure PowerShell MIME parser for common quarantine EML structures. Handles nested + multipart messages, base64 and quoted-printable bodies, and common filename + parameters. RFC 2231 split filenames, exotic charset conversions, and TNEF + winmail.dat payloads are not fully handled. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [AllowEmptyString()] + [string]$Message + ) + + $Urls = [System.Collections.Generic.List[object]]::new() + $Attachments = [System.Collections.Generic.List[object]]::new() + $UrlKeys = @{} + + function Split-CippMimeEntity { + param([AllowEmptyString()][string]$EntityText) + + $Match = [regex]::Match($EntityText, "\r?\n\r?\n") + if ($Match.Success) { + $HeaderText = $EntityText.Substring(0, $Match.Index) + $BodyText = $EntityText.Substring($Match.Index + $Match.Length) + } else { + $HeaderText = $EntityText + $BodyText = '' + } + + $Headers = @{} + $UnfoldedHeaders = $HeaderText -replace "(?m)\r?\n[ \t]+", ' ' + foreach ($Line in ($UnfoldedHeaders -split "\r?\n")) { + if ($Line -match '^\s*([^:]+):\s*(.*)$') { + $Name = $Matches[1].Trim() + $Value = $Matches[2].Trim() + if ($Headers.ContainsKey($Name)) { + $Headers[$Name] = @($Headers[$Name], $Value) -join ', ' + } else { + $Headers[$Name] = $Value + } + } + } + + [PSCustomObject]@{ + Headers = $Headers + Body = $BodyText + } + } + + function ConvertFrom-CippMimeQuotedString { + param([AllowEmptyString()][string]$Value) + + $Trimmed = $Value.Trim() + if ($Trimmed.Length -ge 2 -and $Trimmed.StartsWith('"') -and $Trimmed.EndsWith('"')) { + $Trimmed = $Trimmed.Substring(1, $Trimmed.Length - 2) + $Trimmed = $Trimmed -replace '\\(.)', '$1' + } + + $Trimmed + } + + function ConvertFrom-CippMimeExtendedParameter { + param([AllowEmptyString()][string]$Value) + + $Decoded = ConvertFrom-CippMimeQuotedString -Value $Value + if ($Decoded -match "^([^']*)'[^']*'(.*)$") { + $Charset = $Matches[1] + $EncodedValue = $Matches[2] + try { + if (![string]::IsNullOrWhiteSpace($Charset)) { + # Decode percent-encoded bytes with the declared charset instead of letting + # UnescapeDataString assume UTF-16; this avoids double-decoding UTF-8 values. + try { + $Encoding = [System.Text.Encoding]::GetEncoding($Charset) + $PercentBytes = [System.Collections.Generic.List[byte]]::new() + $i = 0 + while ($i -lt $EncodedValue.Length) { + if ($EncodedValue[$i] -eq '%' -and ($i + 2) -lt $EncodedValue.Length) { + $Hex = $EncodedValue.Substring($i + 1, 2) + if ($Hex -match '^[0-9A-Fa-f]{2}$') { + $PercentBytes.Add([Convert]::ToByte($Hex, 16)) + $i += 3 + continue + } + } + $PercentBytes.Add([byte][char]$EncodedValue[$i]) + $i++ + } + return $Encoding.GetString($PercentBytes.ToArray()) + } catch { + return [System.Uri]::UnescapeDataString($EncodedValue) + } + } + return [System.Uri]::UnescapeDataString($EncodedValue) + } catch { + return $Decoded + } + } + + $Decoded + } + + function Split-CippMimeHeaderParameters { + param([AllowEmptyString()][string]$HeaderValue) + + $Segments = [System.Collections.Generic.List[string]]::new() + $Current = [System.Text.StringBuilder]::new() + $InQuotes = $false + $Escaped = $false + + foreach ($Char in $HeaderValue.ToCharArray()) { + if ($Escaped) { + [void]$Current.Append($Char) + $Escaped = $false + continue + } + + if ($Char -eq '\' -and $InQuotes) { + [void]$Current.Append($Char) + $Escaped = $true + continue + } + + if ($Char -eq '"') { + [void]$Current.Append($Char) + $InQuotes = !$InQuotes + continue + } + + if ($Char -eq ';' -and !$InQuotes) { + $Segments.Add($Current.ToString().Trim()) + [void]$Current.Clear() + continue + } + + [void]$Current.Append($Char) + } + $Segments.Add($Current.ToString().Trim()) + + $Parameters = @{} + for ($Index = 1; $Index -lt $Segments.Count; $Index++) { + $Key, $Value = $Segments[$Index] -split '=', 2 + if ([string]::IsNullOrWhiteSpace($Key) -or $null -eq $Value) { continue } + + $ParameterName = $Key.Trim().ToLowerInvariant() + if ($ParameterName.EndsWith('*')) { + $Parameters[$ParameterName] = ConvertFrom-CippMimeExtendedParameter -Value $Value + } else { + $Parameters[$ParameterName] = ConvertFrom-CippMimeQuotedString -Value $Value + } + } + + [PSCustomObject]@{ + Value = ($Segments[0] ?? '').Trim().ToLowerInvariant() + Parameters = $Parameters + } + } + + function Split-CippMimeMultipartBody { + param( + [AllowEmptyString()][string]$Body, + [Parameter(Mandatory = $true)][string]$Boundary + ) + + $Parts = [System.Collections.Generic.List[string]]::new() + $BoundaryPattern = '^--' + [regex]::Escape($Boundary) + '(?--)?[ \t]*$' + $CurrentLines = [System.Collections.Generic.List[string]]::new() + $InPart = $false + + foreach ($Line in ($Body -split "\r?\n")) { + $BoundaryMatch = [regex]::Match($Line, $BoundaryPattern) + if ($BoundaryMatch.Success) { + if ($InPart) { + $Parts.Add(($CurrentLines -join "`r`n")) + $CurrentLines.Clear() + } + if ($BoundaryMatch.Groups['Closing'].Success) { + break + } + $InPart = $true + continue + } + + if ($InPart) { + $CurrentLines.Add($Line) + } + } + + @($Parts) + } + + function ConvertFrom-CippMimeQuotedPrintable { + param([AllowEmptyString()][string]$Body) + + $Stream = [System.IO.MemoryStream]::new() + try { + for ($Index = 0; $Index -lt $Body.Length; $Index++) { + $Char = $Body[$Index] + if ($Char -eq '=' -and ($Index + 1) -lt $Body.Length) { + if ($Body[$Index + 1] -eq "`r" -and ($Index + 2) -lt $Body.Length -and $Body[$Index + 2] -eq "`n") { + $Index += 2 + continue + } + if ($Body[$Index + 1] -eq "`n") { + $Index += 1 + continue + } + if (($Index + 2) -lt $Body.Length) { + $Hex = $Body.Substring($Index + 1, 2) + if ($Hex -match '^[0-9A-Fa-f]{2}$') { + $Stream.WriteByte([Convert]::ToByte($Hex, 16)) + $Index += 2 + continue + } + } + } + + $Bytes = [System.Text.Encoding]::Latin1.GetBytes([string]$Char) + $Stream.Write($Bytes, 0, $Bytes.Length) + } + + $Stream.ToArray() + } finally { + $Stream.Dispose() + } + } + + function ConvertTo-CippMimeBodyBytes { + param( + [AllowEmptyString()][string]$Body, + [AllowEmptyString()][string]$TransferEncoding + ) + + switch -Regex (($TransferEncoding ?? '').Trim().ToLowerInvariant()) { + '^base64$' { + return [Convert]::FromBase64String(($Body -replace '\s+', '')) + } + '^quoted-printable$' { + return ConvertFrom-CippMimeQuotedPrintable -Body $Body + } + default { + return [System.Text.Encoding]::Latin1.GetBytes($Body) + } + } + } + + function ConvertTo-CippMimeText { + param( + [byte[]]$Bytes, + [AllowEmptyString()][string]$Charset + ) + + if ($null -eq $Bytes) { return '' } + if (![string]::IsNullOrWhiteSpace($Charset)) { + try { + return [System.Text.Encoding]::GetEncoding($Charset).GetString($Bytes) + } catch { + return [System.Text.Encoding]::UTF8.GetString($Bytes) + } + } + + [System.Text.Encoding]::UTF8.GetString($Bytes) + } + + function Add-CippMimeUrl { + param([AllowEmptyString()][string]$Url) + + if ([string]::IsNullOrWhiteSpace($Url)) { return } + $CleanUrl = [System.Net.WebUtility]::HtmlDecode($Url).Trim() + $CleanUrl = $CleanUrl.TrimEnd('.', ',', ';', ':', '!', '?', ')', ']', '}') + if ($CleanUrl -notmatch '^https?://') { return } + if ($UrlKeys.ContainsKey($CleanUrl)) { return } + + $UrlKeys[$CleanUrl] = $true + $Urls.Add([PSCustomObject]@{ + url = $CleanUrl + threatType = $null + detectionMethod = $null + }) + } + + function Add-CippMimeUrlsFromText { + param([AllowEmptyString()][string]$Text) + + $HrefPattern = "(?i)\bhref\s*=\s*(?:""(?https?://[^""]+)""|'(?https?://[^']+)'|(?https?://[^\s>]+))" + foreach ($Match in [regex]::Matches($Text, $HrefPattern)) { + Add-CippMimeUrl -Url $Match.Groups['url'].Value + } + + $BarePattern = '(?i)\bhttps?://[^\s<>"'']+' + foreach ($Match in [regex]::Matches($Text, $BarePattern)) { + Add-CippMimeUrl -Url $Match.Value + } + } + + function Get-CippMimeSha256 { + param([byte[]]$Bytes) + + $Sha256 = [System.Security.Cryptography.SHA256]::Create() + try { + ([BitConverter]::ToString($Sha256.ComputeHash($Bytes)) -replace '-', '').ToLowerInvariant() + } finally { + $Sha256.Dispose() + } + } + + function Read-CippMimePart { + param([AllowEmptyString()][string]$EntityText) + + $Entity = Split-CippMimeEntity -EntityText $EntityText + $ContentType = Split-CippMimeHeaderParameters -HeaderValue ($Entity.Headers['Content-Type'] ?? 'text/plain') + $ContentDisposition = Split-CippMimeHeaderParameters -HeaderValue ($Entity.Headers['Content-Disposition'] ?? '') + $Boundary = $ContentType.Parameters['boundary'] + + if ($ContentType.Value -like 'multipart/*' -and ![string]::IsNullOrWhiteSpace($Boundary)) { + foreach ($Part in (Split-CippMimeMultipartBody -Body $Entity.Body -Boundary $Boundary)) { + Read-CippMimePart -EntityText $Part + } + return + } + + if ($ContentType.Value -eq 'message/rfc822') { + $Rfc822Bytes = ConvertTo-CippMimeBodyBytes -Body $Entity.Body -TransferEncoding $Entity.Headers['Content-Transfer-Encoding'] + $Rfc822Text = [System.Text.Encoding]::UTF8.GetString($Rfc822Bytes) + Read-CippMimePart -EntityText $Rfc822Text + return + } + + $FileName = $ContentDisposition.Parameters['filename*'] ?? + $ContentDisposition.Parameters['filename'] ?? + $ContentType.Parameters['name*'] ?? + $ContentType.Parameters['name'] + $IsAttachment = ![string]::IsNullOrWhiteSpace($FileName) -or $ContentDisposition.Value -eq 'attachment' + $Bytes = ConvertTo-CippMimeBodyBytes -Body $Entity.Body -TransferEncoding $Entity.Headers['Content-Transfer-Encoding'] + + if ($IsAttachment) { + $Attachments.Add([PSCustomObject]@{ + fileName = $FileName + contentType = $ContentType.Value + fileSize = $Bytes.Length + sha256 = Get-CippMimeSha256 -Bytes $Bytes + threatType = $null + }) + return + } + + if ($ContentType.Value -in @('text/plain', 'text/html')) { + $Text = ConvertTo-CippMimeText -Bytes $Bytes -Charset $ContentType.Parameters['charset'] + Add-CippMimeUrlsFromText -Text $Text + } + } + + Read-CippMimePart -EntityText $Message + + [PSCustomObject]@{ + urls = @($Urls) + attachments = @($Attachments) + } +} diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Spamfilter/Invoke-ExecMailQuarantineSubmit.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Spamfilter/Invoke-ExecMailQuarantineSubmit.ps1 new file mode 100644 index 0000000000..cc59181bbf --- /dev/null +++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Spamfilter/Invoke-ExecMailQuarantineSubmit.ps1 @@ -0,0 +1,52 @@ +function Invoke-ExecMailQuarantineSubmit { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Exchange.SpamFilter.ReadWrite + .DESCRIPTION + Submits a quarantined email message to Microsoft for review (threat submission) via the Graph API. + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers + try { + $TenantFilter = $Request.Body.tenantFilter | Select-Object -First 1 + $Identity = $Request.Body.Identity + $Category = $Request.Body.category.value ?? $Request.Body.category + $Recipient = @($Request.Body.RecipientAddress) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -First 1 + + if ([string]::IsNullOrEmpty($Identity)) { throw 'No quarantine message Identity provided' } + if ($Category -notin @('notJunk', 'spam', 'phishing', 'malware')) { throw "Invalid submission category '$Category'" } + if ([string]::IsNullOrEmpty($Recipient)) { throw 'No recipient address provided' } + + # Export the quarantined message and submit its content to Microsoft for analysis + $Export = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Export-QuarantineMessage' -cmdParams @{ 'Identity' = $Identity } + if ([string]::IsNullOrEmpty($Export.Eml)) { throw 'Could not export the quarantined message' } + + $GraphBody = ConvertTo-Json -Depth 5 -InputObject @{ + '@odata.type' = '#microsoft.graph.security.emailContentThreatSubmission' + category = $Category + recipientEmailAddress = $Recipient + fileContent = $Export.Eml + } + $null = New-GraphPostRequest -uri 'https://graph.microsoft.com/beta/security/threatSubmission/emailThreats' -tenantid $TenantFilter -AsApp $true -body $GraphBody + + $Message = "Successfully submitted quarantined message $Identity to Microsoft for review as '$Category'" + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message $Message -Sev 'Info' + $Results = [pscustomobject]@{'Results' = $Message } + $StatusCode = [HttpStatusCode]::OK + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message "Quarantine message submission failed: $($ErrorMessage.NormalizedError)" -Sev 'Error' -LogData $ErrorMessage + $Results = [pscustomobject]@{'Results' = "Failed to submit message for review. $($ErrorMessage.NormalizedError)" } + $StatusCode = [HttpStatusCode]::InternalServerError + } + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = $Results + }) + +} diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Spamfilter/Invoke-ExecQuarantineManagement.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Spamfilter/Invoke-ExecQuarantineManagement.ps1 index 08f15c3e27..c388fc5259 100644 --- a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Spamfilter/Invoke-ExecQuarantineManagement.ps1 +++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Spamfilter/Invoke-ExecQuarantineManagement.ps1 @@ -9,6 +9,7 @@ function Invoke-ExecQuarantineManagement { param($Request, $TriggerMetadata) $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers # Interact with query parameters or the body of the request. try { $TenantFilter = $Request.Body.tenantFilter | Select-Object -First 1 @@ -24,28 +25,29 @@ function Invoke-ExecQuarantineManagement { ) $params = @{} - if ($ActionType -eq 'Release') { - $params['ReleaseToAll'] = $true - if ($Request.Body.Identity -is [string]) { - $params['Identity'] = $Request.Body.Identity - } else { - $params['Identities'] = $Request.Body.Identity - $params['Identity'] = '000' - } + if ($Request.Body.Identity -is [string]) { + $params['Identity'] = $Request.Body.Identity } else { - $params['ActionType'] = $ActionType - if ($Request.Body.Identity -is [string]) { - $params['Identity'] = $Request.Body.Identity + $params['Identities'] = $Request.Body.Identity + # For -Identities, Exchange requires -Identity to be present, but ignores its value. + $params['Identity'] = '000' + } + + # Delete is a separate cmdlet; Release-QuarantineMessage only accepts Release/Request/Approve/Deny. + if ($ActionType -eq 'Delete') { + $Cmdlet = 'Delete-QuarantineMessage' + } else { + $Cmdlet = 'Release-QuarantineMessage' + if ($ActionType -eq 'Release') { + $params['ReleaseToAll'] = $true } else { - $params['Identities'] = $Request.Body.Identity - # For -Identities, Exchange requires -Identity to be present, but ignores its value. - $params['Identity'] = '000' - } - if ($ActionType -eq 'Deny' -and $UserRecipients.Count -gt 0) { - $params['User'] = $UserRecipients + $params['ActionType'] = $ActionType + if ($ActionType -eq 'Deny' -and $UserRecipients.Count -gt 0) { + $params['User'] = $UserRecipients + } } } - New-ExoRequest -tenantid $TenantFilter -cmdlet 'Release-QuarantineMessage' -cmdParams $params + New-ExoRequest -tenantid $TenantFilter -cmdlet $Cmdlet -cmdParams $params # AllowSender via HostedContentFilterPolicy since -AllowSender switch fails in REST API if ($AllowSender) { @@ -69,21 +71,25 @@ function Invoke-ExecQuarantineManagement { AllowedSenders = $UpdatedSenders } } - Write-LogMessage -headers $Request.Headers -API $APINAME -tenant $TenantFilter -message "Added $SenderAddress to allowed senders on policy $PolicyName" -Sev 'Info' + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message "Added $SenderAddress to allowed senders on policy $PolicyName" -Sev 'Info' } } catch { - Write-LogMessage -headers $Request.Headers -API $APINAME -tenant $TenantFilter -message "Failed to add sender to allow list: $($_.Exception.Message)" -Sev 'Error' -LogData $_ + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message "Failed to add sender to allow list: $($ErrorMessage.NormalizedError)" -Sev 'Error' -LogData $ErrorMessage } } $Results = [pscustomobject]@{'Results' = "Successfully processed $($Request.Body.Identity)" } - Write-LogMessage -headers $Request.Headers -API $APINAME -tenant $TenantFilter -message "Successfully processed Quarantine ID $($Request.Body.Identity)" -Sev 'Info' + $StatusCode = [HttpStatusCode]::OK + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message "Successfully processed Quarantine ID $($Request.Body.Identity)" -Sev 'Info' } catch { - Write-LogMessage -headers $Request.Headers -API $APINAME -tenant $TenantFilter -message "Quarantine Management failed: $($_.Exception.Message)" -Sev 'Error' -LogData $_ - $Results = [pscustomobject]@{'Results' = "Failed. $($_.Exception.Message)" } + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message "Quarantine Management failed: $($ErrorMessage.NormalizedError)" -Sev 'Error' -LogData $ErrorMessage + $Results = [pscustomobject]@{'Results' = "Failed. $($ErrorMessage.NormalizedError)" } + $StatusCode = [HttpStatusCode]::BadRequest } return ([HttpResponseContext]@{ - StatusCode = [HttpStatusCode]::OK + StatusCode = $StatusCode Body = $Results }) diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Spamfilter/Invoke-ListMailQuarantine.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Spamfilter/Invoke-ListMailQuarantine.ps1 index 973b5913d9..01d79ee71a 100644 --- a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Spamfilter/Invoke-ListMailQuarantine.ps1 +++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Spamfilter/Invoke-ListMailQuarantine.ps1 @@ -11,28 +11,40 @@ function Invoke-ListMailQuarantine { param($Request, $TriggerMetadata) # Interact with query parameters or the body of the request. $TenantFilter = $Request.Query.tenantFilter + # Entity type: Email (default), SharePointOnline (files) or Teams (Teams messages) + $EntityType = if ($Request.Query.EntityType -in @('Email', 'SharePointOnline', 'Teams')) { $Request.Query.EntityType } else { 'Email' } + # EXO REST silently ignores -EntityType SharePointOnline; the documented filter for Safe Attachments + # files is -QuarantineTypes SPOMalware. Email/Teams work fine via -EntityType. + $EntityTypeParams = if ($EntityType -eq 'SharePointOnline') { @{ QuarantineTypes = 'SPOMalware' } } else { @{ EntityType = $EntityType } } try { $GraphRequest = if ($TenantFilter -ne 'AllTenants') { + $CustomerId = (Get-Tenants -TenantFilter $TenantFilter).customerId $PageSize = 1000 if ($Request.Query.manualPagination -and [System.Convert]::ToBoolean($Request.Query.manualPagination)) { # Manual pagination: return one page per request. The frontend chains requests via # Metadata.nextLink, which for this endpoint is the next Get-QuarantineMessage page number. $Page = if ($Request.Query.nextLink -match '^\d+$') { [int]$Request.Query.nextLink } else { 1 } - $Results = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-QuarantineMessage' -cmdParams @{ PageSize = $PageSize; Page = $Page } | Select-Object -ExcludeProperty *data.type* + $Results = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-QuarantineMessage' -cmdParams (@{ PageSize = $PageSize; Page = $Page } + $EntityTypeParams) | Select-Object -ExcludeProperty *data.type* # Get-QuarantineMessage supports a maximum Page of 1000 if (@($Results).Count -eq $PageSize -and $Page -lt 1000) { $Metadata = [PSCustomObject]@{ nextLink = [string]($Page + 1) } } + foreach ($Message in @($Results)) { + Add-CIPPQuarantineMessageProperties -Message $Message -Tenant $TenantFilter -CustomerId $CustomerId + } $Results } else { $Page = 1 $AllMessages = [System.Collections.Generic.List[object]]::new() do { - $Results = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-QuarantineMessage' -cmdParams @{ PageSize = $PageSize; Page = $Page } | Select-Object -ExcludeProperty *data.type* + $Results = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-QuarantineMessage' -cmdParams (@{ PageSize = $PageSize; Page = $Page } + $EntityTypeParams) | Select-Object -ExcludeProperty *data.type* if ($Results) { $AllMessages.AddRange(@($Results)) } $Page++ } while (@($Results).Count -eq $PageSize) + foreach ($Message in $AllMessages) { + Add-CIPPQuarantineMessageProperties -Message $Message -Tenant $TenantFilter -CustomerId $CustomerId + } $AllMessages } } else { @@ -77,6 +89,8 @@ function Invoke-ListMailQuarantine { $Messages = $Rows | Select-CippAllowedTenantData -TenantProperty 'Tenant' foreach ($message in $Messages) { $messageObj = $message.QuarantineMessage | ConvertFrom-Json + # Older cache rows predate EntityType support and only contain Email entries + if (($messageObj.EntityType ?? 'Email') -ne $EntityType) { continue } $messageObj | Add-Member -NotePropertyName 'Tenant' -NotePropertyValue $message.Tenant -Force $messageObj } diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Spamfilter/Invoke-ListMailQuarantineMessage.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Spamfilter/Invoke-ListMailQuarantineMessage.ps1 index 22a89fd7ff..d10a3436cb 100644 --- a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Spamfilter/Invoke-ListMailQuarantineMessage.ps1 +++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Spamfilter/Invoke-ListMailQuarantineMessage.ps1 @@ -18,8 +18,9 @@ function Invoke-ListMailQuarantineMessage { $EmlBase64 = $GraphRequest.Eml $EmlContent = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($EmlBase64)) $Body = @{ - 'Identity' = $Identity - 'Message' = $EmlContent + 'Identity' = $Identity + 'Message' = $EmlContent + 'EmlBase64' = $EmlBase64 } $StatusCode = [HttpStatusCode]::OK } catch { diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Spamfilter/Invoke-ListMailQuarantineMessageDetails.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Spamfilter/Invoke-ListMailQuarantineMessageDetails.ps1 new file mode 100644 index 0000000000..6867323237 --- /dev/null +++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Spamfilter/Invoke-ListMailQuarantineMessageDetails.ps1 @@ -0,0 +1,296 @@ +function Invoke-ListMailQuarantineMessageDetails { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Exchange.SpamFilter.Read + .DESCRIPTION + Retrieves Defender analyzed email details (threats, delivery, authentication, URLs, attachments) + for a quarantined message via the Graph beta security/collaboration/analyzedEmails API. + Falls back to parsing the message headers (Authentication-Results and X-Forefront-Antispam-Report) + for tenants without Defender for Office 365 Plan 2. + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + # Interact with query parameters or the body of the request. + $TenantFilter = $Request.Query.tenantFilter + # Only the quarantine Identity is trusted from the caller. NetworkMessageId, RecipientAddress and + # ReceivedTime are derived server-side from the quarantine message itself (see below) so this + # endpoint cannot be used to pull Defender analyzedEmail data for arbitrary, non-quarantined + # messages in the tenant. + $Identity = $Request.Query.Identity + + $Results = @() + $Metadata = @{ Available = $false } + + if ([string]::IsNullOrWhiteSpace($Identity)) { + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::BadRequest + Body = @{ Results = @(); Metadata = @{ Available = $false; Message = 'Identity is required' } } + }) + } + + # Resolve the trusted quarantine message first. Binding the Defender lookup to a message that is + # actually quarantined for this tenant is what keeps the Exchange.SpamFilter.Read role from being + # used to investigate messages the operator was never authorized to see. + try { + $QuarantineMessage = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-QuarantineMessage' -cmdParams @{ Identity = $Identity } + } catch { + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::NotFound + Body = @{ Results = @(); Metadata = @{ Available = $false; Message = [string](Get-NormalizedError -Message $_.Exception.Message) } } + }) + } + + if (-not $QuarantineMessage -or [string]::IsNullOrWhiteSpace($QuarantineMessage.Identity)) { + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::NotFound + Body = @{ Results = @(); Metadata = @{ Available = $false; Message = 'Quarantined message not found' } } + }) + } + + # NetworkMessageId is the first half of the quarantine Identity ({NetworkMessageId}\{RecipientGuid}). + $NetworkMessageId = [string]($QuarantineMessage.Identity -split '\\')[0] + $RecipientAddress = @($QuarantineMessage.RecipientAddress)[0] + $ReceivedTime = $QuarantineMessage.ReceivedTime + + # Primary source: Defender analyzedEmails (requires Defender for Office 365 Plan 2). + try { + $MessageGuid = [guid]::Empty + if (-not [guid]::TryParse($NetworkMessageId, [ref]$MessageGuid)) { + throw 'NetworkMessageId must be a valid GUID' + } + + # startTime/endTime are required by the analyzedEmails API. When a received time is supplied, + # search a +/-1 day window around it; otherwise fall back to the last 15 days. + $Now = (Get-Date).ToUniversalTime() + $Received = $null + if (![string]::IsNullOrWhiteSpace($ReceivedTime)) { + try { $Received = ([datetime]$ReceivedTime).ToUniversalTime() } catch { $Received = $null } + } + if ($Received) { + $StartDate = $Received.AddDays(-1) + $EndDate = $Received.AddDays(1) + } else { + $StartDate = $Now.AddDays(-15) + $EndDate = $Now + } + if ($EndDate -gt $Now) { $EndDate = $Now } + $StartTime = $StartDate.ToString('yyyy-MM-ddTHH:mm:ssZ') + $EndTime = $EndDate.ToString('yyyy-MM-ddTHH:mm:ssZ') + + $Filter = "networkMessageId eq '$($MessageGuid.Guid)'" + if (![string]::IsNullOrWhiteSpace($RecipientAddress)) { + $Filter += " and recipientEmailAddress eq '$($RecipientAddress -replace "'", "''")'" + } + $EncodedFilter = [System.Uri]::EscapeDataString($Filter) + $Uri = "https://graph.microsoft.com/beta/security/collaboration/analyzedEmails?startTime=$StartTime&endTime=$EndTime&`$filter=$EncodedFilter" + + $GraphRequest = New-GraphGetRequest -uri $Uri -tenantid $TenantFilter -AsApp $true + if (@($GraphRequest | Where-Object { $_ }).Count -gt 0) { + $Results = @($GraphRequest) + $Metadata = @{ Available = $true; Source = 'Defender' } + } + } catch { + # Tenants without Defender for Office 365 Plan 2 get an 'Invalid subscription' error here. + $DefenderError = [string](Get-NormalizedError -Message $_.Exception.Message) + $Metadata.Message = $DefenderError + # A missing SecurityAnalyzedMessage.Read.All grant fails with an authorization error rather + # than the subscription error above. Flag it so the frontend can prompt to add the missing + # permission instead of silently presenting the reduced header-only fallback as success. + if ($DefenderError -match '(?i)Authorization_RequestDenied|forbidden|insufficient privileges|do(es)? not have permission|Access(Is)?Denied') { + $Metadata.PermissionError = $true + } + } + + # Fallback: parse the message headers, then enrich from the exported EML and optional ATP report. + # Shaped like a partial analyzedEmail object so the frontend can use a single mapping. + if ($Results.Count -eq 0 -and ![string]::IsNullOrWhiteSpace($Identity)) { + try { + $HeaderResult = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-QuarantineMessageHeader' -cmdParams @{ 'Identity' = $Identity } + $RawHeaders = [string]($HeaderResult.Header ?? $HeaderResult) + if (![string]::IsNullOrWhiteSpace($RawHeaders)) { + # Unfold RFC 5322 continuation lines so each header occupies a single line + $HeaderLines = ($RawHeaders -replace "(?m)\r?\n[ \t]+", ' ') -split "\r?\n" + $GetHeader = { + param($Name) + $Pattern = "^(?i)$([regex]::Escape($Name)):\s*" + [string](($HeaderLines | Where-Object { $_ -match $Pattern } | Select-Object -First 1) -replace $Pattern, '') + } + + $Auth = @{} + $AuthResults = & $GetHeader 'Authentication-Results' + foreach ($Mechanism in @('spf', 'dkim', 'dmarc', 'compauth')) { + if ($AuthResults -match "(?i)\b$Mechanism=([a-z0-9]+)") { $Auth[$Mechanism] = $Matches[1] } + } + + # X-Forefront-Antispam-Report is a semicolon separated list of KEY:VALUE pairs + $Report = @{} + foreach ($Pair in ((& $GetHeader 'X-Forefront-Antispam-Report') -split ';')) { + $Key, $Value = $Pair -split ':', 2 + if ($Key -and $Value) { $Report[$Key.Trim()] = $Value.Trim() } + } + + # https://learn.microsoft.com/defender-office-365/message-headers-eop-mdo + $CategoryNames = @{ + AMP = 'Anti-malware'; BULK = 'Bulk'; DIMP = 'Domain impersonation'; FTBP = 'Common attachment filter' + GIMP = 'Mailbox intelligence impersonation'; HPHISH = 'High confidence phishing'; HPHSH = 'High confidence phishing' + HSPM = 'High confidence spam'; INTOS = 'Intra-organization phishing'; MALW = 'Malware'; OSPM = 'Outbound spam' + PHSH = 'Phishing'; SAP = 'Safe Attachments'; SPM = 'Spam'; SPOOF = 'Spoofing'; UIMP = 'User impersonation' + } + $DirectionNames = @{ INB = 'Inbound'; OUT = 'Outbound'; INT = 'Intra-org' } + + $FromHeader = & $GetHeader 'From' + $SenderDisplayName = if ($FromHeader -match '^\s*"?([^"<]*?)"?\s*<') { $Matches[1].Trim() } else { $null } + $Category = $CategoryNames[$Report['CAT']] ?? $Report['CAT'] + $InternetMessageId = & $GetHeader 'Message-ID' + + $Results = @([PSCustomObject]@{ + recipientEmailAddress = $RecipientAddress + internetMessageId = $InternetMessageId + returnPath = ((& $GetHeader 'Return-Path') -replace '[<>]', '').Trim() + directionality = $DirectionNames[$Report['DIR']] ?? $Report['DIR'] + language = $Report['LANG'] + spamConfidenceLevel = $Report['SCL'] + bulkComplaintLevel = $Report['BCL'] + threatTypes = @($Category | Where-Object { $_ }) + senderDetail = [PSCustomObject]@{ + displayName = $SenderDisplayName + ipv4 = $Report['CIP'] + location = $Report['CTRY'] + } + authenticationDetails = [PSCustomObject]@{ + dmarc = $Auth['dmarc'] + dkim = $Auth['dkim'] + senderPolicyFramework = $Auth['spf'] + compositeAuthentication = $Auth['compauth'] + } + }) + $Metadata.Available = $true + $Metadata.Source = 'Headers' + } + } catch { + $HeaderError = [string](Get-NormalizedError -Message $_.Exception.Message) + $Metadata.Message = @($Metadata.Message, $HeaderError) -ne $null -join ' | ' + } + + $FallbackResult = $Results | Select-Object -First 1 + if ($FallbackResult) { + $EmlBase64 = $null + try { + $Metadata.EmlExported = $false + $ExportResult = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Export-QuarantineMessage' -cmdParams @{ 'Identity' = $Identity } + $EmlBase64 = [string]$ExportResult.Eml + if (![string]::IsNullOrWhiteSpace($EmlBase64)) { + $Metadata.EmlExported = $true + } + } catch { + $Metadata.EmlExportError = [string](Get-NormalizedError -Message $_.Exception.Message) + } + + try { + $Metadata.EmlParsed = $false + $MaxEmlBytes = 25MB + if (![string]::IsNullOrWhiteSpace($EmlBase64)) { + $EmlBytes = [System.Convert]::FromBase64String($EmlBase64) + if ($EmlBytes.Length -le $MaxEmlBytes) { + $EmlContent = [System.Text.Encoding]::UTF8.GetString($EmlBytes) + $ParsedMime = Read-CippMimeMessage -Message $EmlContent + $FallbackResult | Add-Member -NotePropertyName urls -NotePropertyValue @($ParsedMime.urls) -Force + $FallbackResult | Add-Member -NotePropertyName attachments -NotePropertyValue @($ParsedMime.attachments) -Force + $Metadata.EmlParsed = $true + } else { + $Metadata.EmlSkipped = "Message export exceeds $([math]::Round($MaxEmlBytes / 1MB)) MB parser limit" + } + } + } catch { + $Metadata.EmlParseError = [string](Get-NormalizedError -Message $_.Exception.Message) + } + + try { + $Metadata.AtpReport = $false + $InternetMessageId = [string]$FallbackResult.internetMessageId + if (![string]::IsNullOrWhiteSpace($InternetMessageId)) { + $AtpReceived = $null + if (![string]::IsNullOrWhiteSpace($ReceivedTime)) { + try { $AtpReceived = ([datetime]$ReceivedTime).ToUniversalTime() } catch { $AtpReceived = $null } + } + + $Now = (Get-Date).ToUniversalTime() + if ($AtpReceived) { + $AtpStartDate = $AtpReceived.AddDays(-1) + $AtpEndDate = $AtpReceived.AddDays(1) + if ($AtpEndDate -gt $Now) { $AtpEndDate = $Now } + } else { + $AtpStartDate = $Now.AddDays(-10) + $AtpEndDate = $Now + } + + $AtpParams = @{ + MessageId = $InternetMessageId + StartDate = $AtpStartDate + EndDate = $AtpEndDate + PageSize = 5000 + } + if (![string]::IsNullOrWhiteSpace($RecipientAddress)) { + $AtpParams.RecipientAddress = $RecipientAddress + } + + $AtpReport = @(New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-MailDetailATPReport' -cmdParams $AtpParams | Where-Object { $_ }) + if (($AtpReport | Measure-Object).Count -gt 0) { + $GetAtpValue = { + param($ReportEntry, [string[]]$Names) + + foreach ($Name in $Names) { + $Property = $ReportEntry.PSObject.Properties[$Name] + if ($Property -and ![string]::IsNullOrWhiteSpace([string]$Property.Value)) { + return [string]$Property.Value + } + } + + $null + } + + $AtpDetectionMethods = @($AtpReport | ForEach-Object { & $GetAtpValue $_ @('Event Type', 'EventType') } | Where-Object { $_ } | Select-Object -Unique) + $AtpThreatTypes = @($AtpReport | ForEach-Object { & $GetAtpValue $_ @('Verdict Type', 'VerdictType') } | Where-Object { $_ } | Select-Object -Unique) + + if ($AtpDetectionMethods.Count -gt 0) { + $FallbackResult | Add-Member -NotePropertyName detectionMethods -NotePropertyValue $AtpDetectionMethods -Force + } + if ($AtpThreatTypes.Count -gt 0) { + $CombinedThreatTypes = @(@($FallbackResult.threatTypes | Where-Object { $_ }) + @($AtpThreatTypes)) | Select-Object -Unique + $FallbackResult | Add-Member -NotePropertyName threatTypes -NotePropertyValue $CombinedThreatTypes -Force + } + + foreach ($AtpEntry in $AtpReport) { + $FileName = & $GetAtpValue $AtpEntry @('File Name', 'FileName') + $VerdictType = & $GetAtpValue $AtpEntry @('Verdict Type', 'VerdictType') + if ([string]::IsNullOrWhiteSpace($FileName) -or [string]::IsNullOrWhiteSpace($VerdictType)) { continue } + + foreach ($Attachment in @($FallbackResult.attachments)) { + if ($Attachment.fileName -eq $FileName) { + $Attachment.threatType = $VerdictType + } + } + } + + $Metadata.AtpReport = $true + } + } + } catch { + $Metadata.AtpError = [string](Get-NormalizedError -Message $_.Exception.Message) + } + } + } + + $Body = @{ + Results = $Results + Metadata = $Metadata + } + + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::OK + Body = $Body + }) + +} diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Spamfilter/Invoke-ListMailQuarantineMessageHeader.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Spamfilter/Invoke-ListMailQuarantineMessageHeader.ps1 new file mode 100644 index 0000000000..2734bfbf58 --- /dev/null +++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Spamfilter/Invoke-ListMailQuarantineMessageHeader.ps1 @@ -0,0 +1,34 @@ +function Invoke-ListMailQuarantineMessageHeader { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Exchange.SpamFilter.Read + .DESCRIPTION + Retrieves the message headers of a specific quarantined email message by its Identity. + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + # Interact with query parameters or the body of the request. + $TenantFilter = $Request.Query.tenantFilter + $Identity = $Request.Query.Identity + + try { + $GraphRequest = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-QuarantineMessageHeader' -cmdParams @{ 'Identity' = $Identity } + $Body = @{ + 'Identity' = $Identity + 'Header' = [string]($GraphRequest.Header ?? $GraphRequest) + } + $StatusCode = [HttpStatusCode]::OK + } catch { + $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message + $StatusCode = [HttpStatusCode]::Forbidden + $Body = $ErrorMessage + } + + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = $Body + }) + +} diff --git a/backend/Tests/Private/Read-CippMimeMessage.Tests.ps1 b/backend/Tests/Private/Read-CippMimeMessage.Tests.ps1 new file mode 100644 index 0000000000..88ad754748 --- /dev/null +++ b/backend/Tests/Private/Read-CippMimeMessage.Tests.ps1 @@ -0,0 +1,183 @@ +# Pester tests for Read-CippMimeMessage +# Verifies common quarantine EML parsing cases used by the details fallback path + +Describe 'Read-CippMimeMessage' { + BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $FunctionPath = Join-Path $RepoRoot 'Modules/CIPPCore/Public/Read-CippMimeMessage.ps1' + + . $FunctionPath + + function Get-TestSha256 { + param([byte[]]$Bytes) + + $Sha256 = [System.Security.Cryptography.SHA256]::Create() + try { + ([BitConverter]::ToString($Sha256.ComputeHash($Bytes)) -replace '-', '').ToLowerInvariant() + } finally { + $Sha256.Dispose() + } + } + } + + It 'extracts a base64 attachment with file name, size, and SHA256' { + $AttachmentBytes = [System.Text.Encoding]::UTF8.GetBytes('Attachment body') + $AttachmentBase64 = [Convert]::ToBase64String($AttachmentBytes) + $ExpectedHash = Get-TestSha256 -Bytes $AttachmentBytes + $Eml = @" +From: Sender +To: Recipient +Subject: Attachment test +MIME-Version: 1.0 +Content-Type: multipart/mixed; boundary="mix" + +--mix +Content-Type: text/plain; charset="utf-8" + +See https://example.com/path. +--mix +Content-Type: application/pdf; name="invoice.pdf" +Content-Disposition: attachment; filename="invoice.pdf" +Content-Transfer-Encoding: base64 + +$AttachmentBase64 +--mix-- +"@ + + $Result = Read-CippMimeMessage -Message $Eml + + $Result.attachments.Count | Should -Be 1 + $Result.attachments[0].fileName | Should -Be 'invoice.pdf' + $Result.attachments[0].contentType | Should -Be 'application/pdf' + $Result.attachments[0].fileSize | Should -Be $AttachmentBytes.Length + $Result.attachments[0].sha256 | Should -Be $ExpectedHash + $Result.attachments[0].threatType | Should -BeNullOrEmpty + $Result.urls.url | Should -Contain 'https://example.com/path' + } + + It 'extracts URLs from quoted-printable HTML bodies' { + $Eml = @' +From: Sender +To: Recipient +Subject: URL test +MIME-Version: 1.0 +Content-Type: text/html; charset="utf-8" +Content-Transfer-Encoding: quoted-printable + +Open +Bare link https://tail.example/path. +'@ + + $Result = Read-CippMimeMessage -Message $Eml + + $Result.urls.Count | Should -Be 2 + $Result.urls.url | Should -Contain 'https://contoso.example/login?x=1' + $Result.urls.url | Should -Contain 'https://tail.example/path' + $Result.urls[0].threatType | Should -BeNullOrEmpty + $Result.urls[0].detectionMethod | Should -BeNullOrEmpty + } + + It 'decodes RFC 2231 UTF-8 filenames without corruption' { + $AttachmentBytes = [System.Text.Encoding]::UTF8.GetBytes('cv body') + $AttachmentBase64 = [Convert]::ToBase64String($AttachmentBytes) + $Eml = @" +From: Sender +To: Recipient +Subject: UTF-8 filename test +MIME-Version: 1.0 +Content-Type: multipart/mixed; boundary="mix" + +--mix +Content-Type: text/plain; charset="utf-8" + +body +--mix +Content-Type: application/pdf +Content-Disposition: attachment; filename*=utf-8''r%C3%A9sum%C3%A9.pdf +Content-Transfer-Encoding: base64 + +$AttachmentBase64 +--mix-- +"@ + + $Result = Read-CippMimeMessage -Message $Eml + + $Result.attachments.Count | Should -Be 1 + $Result.attachments[0].fileName | Should -Be 'résumé.pdf' + } + + It 'decodes base64-encoded message/rfc822 parts' { + $InnerEml = @" +From: Inner +To: Outer +Subject: Forwarded +Content-Type: text/html; charset="utf-8" + +click +"@ + $InnerBase64 = [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($InnerEml)) + $Eml = @" +From: Sender +To: Recipient +Subject: Attached message +MIME-Version: 1.0 +Content-Type: multipart/mixed; boundary="outer" + +--outer +Content-Type: text/plain; charset="utf-8" + +See attached. +--outer +Content-Type: message/rfc822 +Content-Transfer-Encoding: base64 + +$InnerBase64 +--outer-- +"@ + + $Result = Read-CippMimeMessage -Message $Eml + + $Result.urls.url | Should -Contain 'https://inner.example/link' + } + + It 'descends nested multiparts and decodes extended attachment file names' { + $AttachmentBytes = [System.Text.Encoding]::UTF8.GetBytes('nested attachment') + $AttachmentBase64 = [Convert]::ToBase64String($AttachmentBytes) + $Eml = @" +From: Sender +To: Recipient +Subject: Nested test +MIME-Version: 1.0 +Content-Type: multipart/mixed; boundary="outer" + +--outer +Content-Type: multipart/alternative; boundary="inner" + +--inner +Content-Type: text/plain; charset="utf-8" + +Plain link https://nested.example/plain +--inner +Content-Type: text/html; charset="utf-8" + +HTML link +--inner-- +--outer +Content-Type: application/octet-stream +Content-Disposition: attachment; filename*=utf-8''report%20one.txt +Content-Transfer-Encoding: base64 + +$AttachmentBase64 +--outer-- +"@ + + $Result = Read-CippMimeMessage -Message $Eml + + $Result.urls.url | Should -Contain 'https://nested.example/plain' + $Result.urls.url | Should -Contain 'https://nested.example/html' + $Result.attachments.Count | Should -Be 1 + $Result.attachments[0].fileName | Should -Be 'report one.txt' + $Result.attachments[0].fileSize | Should -Be $AttachmentBytes.Length + } +} + diff --git a/docs/user-documentation/email/administration/quarantine.md b/docs/user-documentation/email/administration/quarantine.md index 1f5d08b5f4..d5ab6b0907 100644 --- a/docs/user-documentation/email/administration/quarantine.md +++ b/docs/user-documentation/email/administration/quarantine.md @@ -1,14 +1,27 @@ # Quarantine -This page lists the messages Microsoft Defender for Office 365 and Exchange Online Protection have quarantined for the selected tenant. From here you can read a message safely, trace how it arrived, and release or deny it without going into the Defender portal. +This page lists the messages Microsoft Defender for Office 365 and Exchange Online Protection have quarantined for the selected tenant. From here you can inspect a message safely, trace how it arrived, and release, deny, or delete it without going into the Defender portal. + +The page has three tabs, one per quarantine type: + +| Tab | What it shows | +| -------------- | --------------------------------------------------------------------- | +| Email | Quarantined email messages (Exchange Online Protection). | +| Files | Safe Attachments files quarantined from SharePoint/OneDrive. | +| Teams Messages | Quarantined Teams messages. | + +Files and Teams quarantine require Defender for Office 365, so those tabs are empty for tenants without it. Rows in the AllTenants view are tagged with their tenant, and every per-message action is executed against the tenant the message belongs to. ## Filters +The Email tab offers release-status and quarantine-reason filters: + | Filter | Shows | | ------------ | --------------------------------------------------------------------------------------------- | | Not Released | Messages still sitting in quarantine with no request against them. | | Released | Messages that have already been released to their recipients. | | Requested | Messages a recipient has asked to have released, which are the ones waiting on your decision. | +| High Confidence Phishing / Phishing / Spam / Malware / Bulk / Transport Rule | Messages quarantined for that reason. | ## Table Details @@ -16,14 +29,24 @@ The properties returned are for the Exchange Online PowerShell command `Get-Quar Messages are listed newest first. Choosing AllTenants starts a background job to gather messages from every tenant, so the table reports that it is still loading until that job finishes. +## Row Details Flyout + +Clicking a row opens a flyout with the message's full details in expandable sections: **Quarantine Details**, **Delivery Details**, **Email Details**, and **Authentication**. When Microsoft Defender for Office 365 Plan 2 is available the delivery and authentication sections are enriched with the analyzed threat data, including per-URL and per-attachment threat verdicts. Without it, CIPP falls back to parsing the message headers and contents, so the sections are populated but individual URL/attachment verdicts are not shown. The actions at the bottom of the flyout are the same as the table actions. + ## Table Actions -
    ActionDescriptionBulk Action Available
    View MessageOpens a modal that renders the quarantined message so its contents, headers, and attachments can be inspected safely.false
    View Message TraceOpens a modal with a table of the message's trace history, showing where it was received from and what happened to it at each step.false
    ReleaseReleases the message to all of its recipients. Greyed out on a message that has already been released.true
    DenyTurns down a recipient's request to have the message released. Greyed out unless the recipient has actually requested release.true
    Release & Allow SenderReleases the message and adds the sender to the allowed senders list of the anti-spam policy that quarantined it, so future mail from them is not quarantined. Greyed out on a message that has already been released.true
    More InfoOpens the Extended Info flyout with the full details for the selected row.false
    +The Email tab offers the full action set: + +
    ActionDescriptionBulk Action Available
    Preview MessageOpens a modal that renders the quarantined message so its contents, headers, and attachments can be inspected safely.false
    View Message HeadersOpens a modal with the raw RFC 5322 message headers.false
    Download Message (.eml)Downloads the quarantined message as a .eml file for offline analysis.false
    View Message TraceOpens a modal with a table of the message's trace history, showing where it was received from and what happened to it at each step.false
    ReleaseReleases the message to all of its recipients. Greyed out on a message that has already been released.true
    Release & Allow SenderReleases the message and adds the sender to the allowed senders list of the anti-spam policy that quarantined it, so future mail from them is not quarantined. Greyed out on a message that has already been released.true
    DenyTurns down a recipient's request to have the message released. Greyed out unless the recipient has actually requested release.true
    Delete from QuarantinePermanently deletes the message from quarantine. Greyed out on a message that has already been released.true
    Submit to Microsoft for ReviewSubmits the quarantined message to Microsoft as a threat submission so they can review its classification. Prompts for a category (clean, spam, phishing, or malware).false
    Block SenderAdds the sender to the tenant's sender block list, optionally without an expiration date or with a note.true
    Open Email Entity in DefenderOpens the message's entity view in Microsoft Defender to surface the full detection details.false
    More InfoOpens the Extended Info flyout with the full details for the selected row.false
    -The flyout carries the same actions and highlights the message ID, recipient address, and quarantine type. +The Files and Teams tabs offer the shared subset of these actions: **Release**, **Delete from Quarantine**, and **More Info**. {% hint style="warning" %} **Release & Allow Sender** adds a standing allow entry to the anti-spam policy, and that entry stays until it is removed by hand. Use it for a sender that is genuinely being caught wrongly, and prefer a plain **Release** otherwise. {% endhint %} +{% hint style="info" %} +**Submit to Microsoft for Review** exports the quarantined message and submits it to Microsoft's threat submission pipeline. Submissions are analysed by Microsoft and can help correct false positives and false negatives. +{% endhint %} + {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/frontend/src/components/CippComponents/CippOffCanvas.jsx b/frontend/src/components/CippComponents/CippOffCanvas.jsx index 66705e0752..e37e593edf 100644 --- a/frontend/src/components/CippComponents/CippOffCanvas.jsx +++ b/frontend/src/components/CippComponents/CippOffCanvas.jsx @@ -29,6 +29,7 @@ export const CippOffCanvas = (props) => { navigationPosition, contentPadding = 2, keepMounted = false, + actionsPosition = "top", richFormatting = false, aboveModal = false, } = props; @@ -71,6 +72,19 @@ export const CippOffCanvas = (props) => { } }); + const infoCard = (extendedInfo.length > 0 || actions?.length > 0) && ( + + + + ); + const SIZE_WIDTHS = { sm: 400, md: 600, lg: 800, xl: 1000 }; const drawerWidth = mdDown ? "100%" : (SIZE_WIDTHS[size] ?? 400); // Prev/next navigation exists on this drawer (row detail view); on phones the 24px @@ -162,18 +176,7 @@ export const CippOffCanvas = (props) => { }} > - {extendedInfo.length > 0 && ( - - - - )} + {actionsPosition !== "bottom" && infoCard} { {typeof children === "function" ? children(extendedData) : children} + {actionsPosition === "bottom" && infoCard} diff --git a/frontend/src/components/CippComponents/CippQuarantineDetails.jsx b/frontend/src/components/CippComponents/CippQuarantineDetails.jsx new file mode 100644 index 0000000000..1e39e0e91f --- /dev/null +++ b/frontend/src/components/CippComponents/CippQuarantineDetails.jsx @@ -0,0 +1,424 @@ +import { + Accordion, + AccordionDetails, + AccordionSummary, + Chip, + Stack, + Table, + TableBody, + TableCell, + TableHead, + TableRow, + Typography, +} from '@mui/material' +import { ExpandMore } from '@mui/icons-material' +import { CippPropertyList } from './CippPropertyList' +import { CippCopyToClipBoard } from './CippCopyToClipboard' +import { getCippFormatting } from '../../utils/get-cipp-formatting' +import { ApiGetCall } from '../../api/ApiCall' +import { useSettings } from '../../hooks/use-settings' + +// Convert camelCase/underscore Graph enum values to readable text, e.g. 'softFail' -> 'Soft fail' +const formatEnum = (value) => { + if (typeof value !== 'string' || value === '') return value + const spaced = value.replace(/([a-z0-9])([A-Z])/g, '$1 $2').replace(/_/g, ' ') + return spaced.charAt(0).toUpperCase() + spaced.slice(1).toLowerCase() +} + +const releaseStatusLabels = { + NOTRELEASED: 'Not released', + RELEASED: 'Released', + REQUESTED: 'Release requested', + DENIED: 'Release denied', + PREPARING: 'Preparing', + ERROR: 'Error', +} + +const joinList = (value) => + Array.isArray(value) ? value.filter(Boolean).join(', ') : value + +const threatChipColor = (threatType) => { + // Match on substrings: the same threat arrives in different forms depending on the source, + // e.g. 'HighConfPhish' (enum) vs 'High Confidence Phish' (Exchange display value). + const threat = String(threatType ?? '').toLowerCase() + if (!threat) return 'default' + if (threat.includes('malware') || threat.includes('phish')) return 'error' + if (threat.includes('spam') || threat.includes('bulk')) return 'warning' + return 'default' +} + +const formatBytes = (bytes) => { + if (typeof bytes !== 'number' || Number.isNaN(bytes)) return bytes + if (bytes < 1024) return `${bytes} B` + let value = bytes + let unit = 'B' + for (const nextUnit of ['KB', 'MB', 'GB']) { + value = value / 1024 + unit = nextUnit + if (value < 1024) break + } + return `${value.toFixed(1)} ${unit}` +} + +const hasValue = (value) => { + if (value === undefined || value === null || value === '') return false + if (Array.isArray(value) && value.length === 0) return false + return true +} + +const buildProperties = (fields) => + fields + .filter(({ value }) => hasValue(value)) + .map(({ label, value, field }) => ({ + label, + value: field ? getCippFormatting(value, field) : value, + })) + +const Section = ({ + title, + isFetching = false, + fields, + children, + defaultExpanded = true, +}) => { + // While fetching, show label-only skeleton rows; otherwise drop empty fields entirely + const propertyItems = fields + ? isFetching + ? fields.map(({ label }) => ({ label })) + : buildProperties(fields) + : [] + if (!isFetching && propertyItems.length === 0 && !children) return null + return ( + + }> + {title} + + + {children ?? ( + + )} + + + ) +} + +export const CippQuarantineDetails = ({ row }) => { + const currentTenant = useSettings().currentTenant + // The Defender lookup must target the tenant the message belongs to (AllTenants view) + const tenantFilter = row?.Tenant ?? currentTenant + const isEmail = (row?.EntityType ?? 'Email') === 'Email' + const networkMessageId = + row?.NetworkMessageId ?? row?.Identity?.split('\\')[0] + const recipient = Array.isArray(row?.RecipientAddress) + ? row.RecipientAddress[0] + : row?.RecipientAddress + + const details = ApiGetCall({ + url: '/api/ListMailQuarantineMessageDetails', + data: { + tenantFilter: tenantFilter, + NetworkMessageId: networkMessageId, + RecipientAddress: recipient, + ReceivedTime: row?.ReceivedTime, + Identity: row?.Identity, + }, + waiting: Boolean(row && isEmail && networkMessageId && tenantFilter), + queryKey: `QuarantineMessageDetails-${tenantFilter}-${networkMessageId}-${recipient}`, + }) + + if (!row) return null + + const analyzed = + details.data?.Results?.find( + (entry) => + entry.recipientEmailAddress?.toLowerCase() === recipient?.toLowerCase() + ) ?? details.data?.Results?.[0] + const isEnriching = isEmail && details.isFetching + const enrichmentUnavailable = isEmail && details.isSuccess && !analyzed + const headerFallback = isEmail && details.data?.Metadata?.Source === 'Headers' + + const quarantineFields = [ + { label: 'Received', value: row.ReceivedTime, field: 'ReceivedTime' }, + { label: 'Expires', value: row.Expires, field: 'Expires' }, + { label: 'Subject', value: row.Subject }, + { label: 'Quarantine Reason', value: row.Type }, + { label: 'Policy Type', value: row.PolicyType }, + { label: 'Policy Name', value: row.PolicyName }, + { + label: 'Release Status', + value: releaseStatusLabels[row.ReleaseStatus] ?? row.ReleaseStatus, + }, + { label: 'Released By', value: row.ReleasedUser, field: 'ReleasedUser' }, + { + label: 'Quarantined User', + value: row.QuarantinedUser, + field: 'QuarantinedUser', + }, + { label: 'Reported', value: row.Reported, field: 'Reported' }, + { + label: 'Override Sources', + value: joinList(analyzed?.overrideSources?.map(formatEnum)), + }, + ] + + const deliveryFields = [ + { + label: 'Original Threats', + value: analyzed?.originalDelivery?.originalThreats, + }, + { label: 'Latest Threats', value: analyzed?.latestDelivery?.latestThreats }, + { + label: 'Original Location', + value: formatEnum(analyzed?.originalDelivery?.location), + }, + { + label: 'Latest Delivery Location', + value: formatEnum(analyzed?.latestDelivery?.location), + }, + { + label: 'Delivery Action', + value: formatEnum(analyzed?.originalDelivery?.action), + }, + { + label: 'Latest Delivery Action', + value: formatEnum(analyzed?.latestDelivery?.action), + }, + { + label: 'Detection Technologies', + value: joinList(analyzed?.detectionMethods), + }, + { + label: 'Threat Types', + value: joinList( + analyzed?.threatTypes + ?.filter((threat) => !['none', 'unknown'].includes(threat)) + .map(formatEnum) + ), + }, + { + label: 'Primary Override Source', + value: formatEnum(analyzed?.primaryOverrideSource), + }, + { label: 'Policy Action', value: formatEnum(analyzed?.policyAction) }, + { label: 'Phish Confidence Level', value: analyzed?.phishConfidenceLevel }, + { label: 'Spam Confidence Level', value: analyzed?.spamConfidenceLevel }, + { label: 'Bulk Complaint Level', value: analyzed?.bulkComplaintLevel }, + ] + + const emailFields = [ + { + label: 'Sender Display Name', + value: analyzed?.senderDetail?.displayName, + }, + { + label: 'Sender Address', + value: analyzed?.senderDetail?.mailFromAddress ?? row.SenderAddress, + }, + { + label: 'Sender Mail From Address', + value: analyzed?.senderDetail?.fromAddress, + }, + { label: 'Return Path', value: analyzed?.returnPath }, + { label: 'Sender IP', value: analyzed?.senderDetail?.ipv4 }, + { label: 'Sender Location', value: analyzed?.senderDetail?.location }, + { + label: 'Recipient(s)', + value: row.RecipientAddress, + field: 'RecipientAddress', + }, + { label: 'Distribution List', value: analyzed?.distributionList }, + { + label: 'Direction', + value: formatEnum(analyzed?.directionality) ?? row.Direction, + }, + { label: 'Network Message ID', value: networkMessageId }, + { + label: 'Internet Message ID', + value: analyzed?.internetMessageId ?? row.MessageId, + }, + { label: 'Size', value: row.Size, field: 'Size' }, + { label: 'Language', value: analyzed?.language }, + { label: 'Entity Type', value: row.EntityType }, + { label: 'Teams Conversation Type', value: row.TeamsConversationType }, + ] + + const authenticationFields = [ + { + label: 'DMARC', + value: formatEnum(analyzed?.authenticationDetails?.dmarc), + }, + { label: 'DKIM', value: formatEnum(analyzed?.authenticationDetails?.dkim) }, + { + label: 'SPF', + value: formatEnum(analyzed?.authenticationDetails?.senderPolicyFramework), + }, + { + label: 'Composite Authentication', + value: formatEnum( + analyzed?.authenticationDetails?.compositeAuthentication + ), + }, + ] + + return ( + + + {row.Subject} + + {hasValue(row.Type) && ( + + )} + {hasValue(row.ReleaseStatus) && ( + + )} + {analyzed?.attachments?.length > 0 && ( + + )} + {analyzed?.urls?.length > 0 && ( + + )} + + {enrichmentUnavailable && ( + + Extended threat details are unavailable for this message (requires + Microsoft Defender for Office 365). + + )} + {headerFallback && ( + + Showing details parsed from the message headers and message + contents. Microsoft per-URL and per-attachment threat verdicts + require Microsoft Defender for Office 365 Plan 2. + + )} + + +
    +
    +
    +
    + {analyzed?.urls?.length > 0 && ( +
    + + + + URL + Threat + Detection Method + + + + {analyzed.urls.map((urlEntry, index) => ( + + + {urlEntry.url} + + + + + + {urlEntry.detectionMethod} + + ))} + +
    +
    + )} + {analyzed?.attachments?.length > 0 && ( +
    + + + + File Name + Threat + Malware Family + Size + SHA256 + + + + {analyzed.attachments.map((attachment, index) => ( + + + {attachment.fileName} + + + + + {attachment.malwareFamily} + {formatBytes(attachment.fileSize)} + + {attachment.sha256 && ( + + )} + + + ))} + +
    +
    + )} + + + ) +} + +export default CippQuarantineDetails diff --git a/frontend/src/components/CippComponents/CippQuarantineTable.jsx b/frontend/src/components/CippComponents/CippQuarantineTable.jsx new file mode 100644 index 0000000000..0f59f695fd --- /dev/null +++ b/frontend/src/components/CippComponents/CippQuarantineTable.jsx @@ -0,0 +1,555 @@ +import { useEffect, useState } from 'react' +import { + CircularProgress, + Dialog, + DialogContent, + DialogTitle, + IconButton, + Skeleton, + Typography, +} from '@mui/material' +import { Block, Close, Done, DoneAll } from '@mui/icons-material' +import { + ArrowDownTrayIcon, + ArrowTopRightOnSquareIcon, + CodeBracketIcon, + DocumentTextIcon, + EyeIcon, + FlagIcon, + NoSymbolIcon, + TrashIcon, +} from '@heroicons/react/24/outline' +import { CippTablePage } from './CippTablePage.jsx' +import { CippMessageViewer } from './CippMessageViewer.jsx' +import { CippQuarantineDetails } from './CippQuarantineDetails.jsx' +import { CippDataTable } from '../CippTable/CippDataTable' +import { ApiGetCall, ApiPostCall } from '../../api/ApiCall' +import { useSettings } from '../../hooks/use-settings' + +const traceDetailColumns = [ + 'Received', + 'Status', + 'SenderAddress', + 'RecipientAddress', +] + +const releaseStatusFilters = [ + { + filterName: 'Not Released', + value: [{ id: 'ReleaseStatus', value: 'NOTRELEASED' }], + type: 'column', + filterType: 'equal', + }, + { + filterName: 'Released', + value: [{ id: 'ReleaseStatus', value: 'RELEASED' }], + type: 'column', + filterType: 'equal', + }, + { + filterName: 'Requested', + value: [{ id: 'ReleaseStatus', value: 'REQUESTED' }], + type: 'column', + filterType: 'equal', + }, +] + +const quarantineReasonFilters = [ + { filterName: 'High Confidence Phishing', value: 'HighConfPhish' }, + { filterName: 'Phishing', value: 'Phish' }, + { filterName: 'Spam', value: 'Spam' }, + { filterName: 'Malware', value: 'Malware' }, + { filterName: 'Bulk', value: 'Bulk' }, + { filterName: 'Transport Rule', value: 'TransportRule' }, +].map(({ filterName, value }) => ({ + filterName, + value: [{ id: 'Type', value }], + type: 'column', + filterType: 'equal', +})) + +const pageTitles = { + Email: 'Quarantine - Email', + SharePointOnline: 'Quarantine - Files', + Teams: 'Quarantine - Teams Messages', +} + +export const CippQuarantineTable = ({ entityType = 'Email' }) => { + const tenantFilter = useSettings().currentTenant + const isEmail = entityType === 'Email' + const queryKey = `MailQuarantine-${entityType}-${tenantFilter}` + + // In the AllTenants view each row belongs to a different tenant (row.Tenant); per-message + // actions must target that tenant rather than the page-level "AllTenants" selection. Falls back + // to the page tenant for the normal single-tenant view. + const resolveTenant = (row) => + tenantFilter === 'AllTenants' ? (row?.Tenant ?? tenantFilter) : tenantFilter + + // Preview message dialog + const [messageRow, setMessageRow] = useState(null) + const [dialogOpen, setDialogOpen] = useState(false) + + // Message headers dialog + const [headerRow, setHeaderRow] = useState(null) + const [headerDialogOpen, setHeaderDialogOpen] = useState(false) + + // Download message state + const [downloadRow, setDownloadRow] = useState(null) + + // Message trace dialog + const [traceDialogOpen, setTraceDialogOpen] = useState(false) + const [traceDetails, setTraceDetails] = useState([]) + const [traceMessageId, setTraceMessageId] = useState(null) + const [traceTenant, setTraceTenant] = useState(null) + const [messageSubject, setMessageSubject] = useState(null) + + const messageTenant = resolveTenant(messageRow) + const getMessageContents = ApiGetCall({ + url: '/api/ListMailQuarantineMessage', + data: { + tenantFilter: messageTenant, + Identity: messageRow?.Identity, + }, + waiting: Boolean(messageRow), + queryKey: `ListMailQuarantineMessage-${messageTenant}-${messageRow?.Identity}`, + }) + + const headerTenant = resolveTenant(headerRow) + const getMessageHeaders = ApiGetCall({ + url: '/api/ListMailQuarantineMessageHeader', + data: { + tenantFilter: headerTenant, + Identity: headerRow?.Identity, + }, + waiting: Boolean(headerRow), + queryKey: `ListMailQuarantineMessageHeader-${headerTenant}-${headerRow?.Identity}`, + }) + + const downloadTenant = resolveTenant(downloadRow) + const getMessageDownload = ApiGetCall({ + url: '/api/ListMailQuarantineMessage', + data: { + tenantFilter: downloadTenant, + Identity: downloadRow?.Identity, + }, + waiting: Boolean(downloadRow), + queryKey: `ListMailQuarantineMessageDownload-${downloadTenant}-${downloadRow?.Identity}`, + }) + + const getMessageTraceDetails = ApiPostCall({ + urlFromData: true, + queryKey: `MessageTraceDetail-${traceTenant}-${traceMessageId}`, + onResult: (result) => { + setTraceDetails(result) + }, + }) + + // CippPropertyListCard calls customFunction(actionItem, rowData, {}); table rows call + // customFunction(rowData). Accept both signatures by detecting which arg carries Identity. + const resolveRow = (...args) => (args[0]?.Identity ? args[0] : args[1]) + + const viewMessage = (...args) => { + const row = resolveRow(...args) + setMessageRow(row) + setDialogOpen(true) + } + + const viewHeaders = (...args) => { + const row = resolveRow(...args) + setHeaderRow(row) + setHeaderDialogOpen(true) + } + + const downloadMessage = (...args) => { + const row = resolveRow(...args) + setDownloadRow(row) + } + + const viewMessageTrace = (...args) => { + const row = resolveRow(...args) + const rowTenant = resolveTenant(row) + setTraceTenant(rowTenant) + setTraceMessageId(row.MessageId) + getMessageTraceDetails.mutate({ + url: '/api/ListMessageTrace', + data: { + tenantFilter: rowTenant, + messageId: row.MessageId, + }, + }) + setMessageSubject(row.Subject) + setTraceDialogOpen(true) + } + + const openInDefender = (...args) => { + const row = resolveRow(...args) + const networkMessageId = + row.NetworkMessageId ?? row.Identity?.split('\\')[0] + const recipient = Array.isArray(row.RecipientAddress) + ? row.RecipientAddress[0] + : row.RecipientAddress + const receivedTime = row.ReceivedTime + ? new Date(row.ReceivedTime).toISOString() + : '' + let url = + `https://security.microsoft.com/emailentityV2?id=${encodeURIComponent(networkMessageId)}` + + `&recipient=${encodeURIComponent(recipient ?? '')}` + + `&startTime=${encodeURIComponent(receivedTime)}` + + `&endTime=${encodeURIComponent(receivedTime)}` + + `&contentonly=1` + + `&subject=${encodeURIComponent(row.Subject ?? '')}` + + `&entityId=${encodeURIComponent(`${networkMessageId}_${recipient ?? ''}`)}` + if (row.CustomerId) { + url += `&tid=${row.CustomerId}` + } + window.open(url, '_blank') + } + + useEffect(() => { + if ( + downloadRow && + getMessageDownload.isSuccess && + getMessageDownload.data?.Message + ) { + const networkMessageId = + downloadRow.NetworkMessageId ?? downloadRow.Identity?.split('\\')[0] + const fileName = `${( + downloadRow.Subject || + networkMessageId || + 'quarantined-message' + ) + .replace(/[\\/:*?"<>|]/g, '_') + .slice(0, 100)}.eml` + // Use the raw base64 export when available to preserve non-UTF-8 MIME content + const emlBase64 = getMessageDownload.data.EmlBase64 + let blob + if (emlBase64) { + const bytes = Uint8Array.from(atob(emlBase64), (c) => c.charCodeAt(0)) + blob = new Blob([bytes], { type: 'message/rfc822' }) + } else { + blob = new Blob([getMessageDownload.data.Message], { + type: 'message/rfc822', + }) + } + const url = URL.createObjectURL(blob) + const link = document.createElement('a') + link.href = url + link.download = fileName + link.click() + URL.revokeObjectURL(url) + setDownloadRow(null) + } + }, [getMessageDownload.isSuccess, getMessageDownload.data, downloadRow]) + + const actions = [ + { + label: 'Release', + type: 'POST', + url: '/api/ExecQuarantineManagement', + multiPost: true, + data: { + tenantFilter: 'Tenant', + Identity: 'Identity', + Type: '!Release', + }, + confirmText: 'Are you sure you want to release this message?', + icon: , + condition: (row) => row.ReleaseStatus !== 'RELEASED', + }, + ...(isEmail + ? [ + { + label: 'Release & Allow Sender', + type: 'POST', + url: '/api/ExecQuarantineManagement', + multiPost: true, + data: { + tenantFilter: 'Tenant', + Identity: 'Identity', + Type: '!Release', + AllowSender: true, + SenderAddress: 'SenderAddress', + PolicyName: 'PolicyName', + }, + confirmText: + 'Are you sure you want to release this email and add the sender to the whitelist?', + icon: , + condition: (row) => row.ReleaseStatus !== 'RELEASED', + }, + { + label: 'Deny', + type: 'POST', + url: '/api/ExecQuarantineManagement', + multiPost: true, + data: { + tenantFilter: 'Tenant', + Identity: 'Identity', + Type: '!Deny', + RecipientAddress: 'RecipientAddress', + }, + confirmText: 'Are you sure you want to deny this message?', + icon: , + condition: (row) => row.ReleaseStatus === 'REQUESTED', + }, + ] + : []), + { + label: 'Delete from Quarantine', + type: 'POST', + url: '/api/ExecQuarantineManagement', + multiPost: true, + data: { + tenantFilter: 'Tenant', + Identity: 'Identity', + Type: '!Delete', + }, + confirmText: + 'Are you sure you want to permanently delete this message from quarantine? This action cannot be undone.', + icon: , + color: 'danger', + condition: (row) => row.ReleaseStatus !== 'RELEASED', + }, + ...(isEmail + ? [ + { + label: 'Preview Message', + noConfirm: true, + customFunction: viewMessage, + icon: , + hideBulk: true, + }, + { + label: 'View Message Headers', + noConfirm: true, + customFunction: viewHeaders, + icon: , + hideBulk: true, + }, + { + label: 'Download Message (.eml)', + noConfirm: true, + customFunction: downloadMessage, + icon: , + hideBulk: true, + }, + { + label: 'View Message Trace', + noConfirm: true, + customFunction: viewMessageTrace, + icon: , + hideBulk: true, + }, + { + label: 'Submit to Microsoft for Review', + type: 'POST', + url: '/api/ExecMailQuarantineSubmit', + data: { + tenantFilter: 'Tenant', + Identity: 'Identity', + RecipientAddress: 'RecipientAddress', + }, + fields: [ + { + type: 'autoComplete', + name: 'category', + label: 'Submission category', + multiple: false, + creatable: false, + options: [ + { + label: 'Clean - should not have been quarantined', + value: 'notJunk', + }, + { label: 'Spam', value: 'spam' }, + { label: 'Phishing', value: 'phishing' }, + { label: 'Malware', value: 'malware' }, + ], + validators: { required: 'Please select a category' }, + }, + ], + confirmText: 'Submit "[Subject]" to Microsoft for analysis?', + icon: , + hideBulk: true, + }, + { + label: 'Block Sender', + type: 'POST', + url: '/api/AddTenantAllowBlockList', + data: { + tenantID: 'Tenant', + entries: 'SenderAddress', + listType: '!Sender', + listMethod: '!Block', + }, + fields: [ + { + type: 'switch', + name: 'NoExpiration', + label: 'Never expire (default: expires after 30 days)', + }, + { + type: 'textField', + name: 'notes', + label: 'Notes (optional)', + }, + ], + confirmText: + 'Block sender [SenderAddress] by adding an entry to the Tenant Allow/Block List?', + icon: , + }, + { + label: 'Open Email Entity in Defender', + noConfirm: true, + customFunction: openInDefender, + icon: , + hideBulk: true, + }, + ] + : []), + ] + + const offCanvas = { + size: 'lg', + actions: actions, + actionsPosition: 'bottom', + children: (row) => , + } + + const filterList = isEmail + ? [...releaseStatusFilters, ...quarantineReasonFilters] + : releaseStatusFilters + + const simpleColumns = [ + 'ReceivedTime', + 'Subject', + 'SenderAddress', + 'Type', + 'ReleaseStatus', + 'PolicyType', + 'Expires', + 'RecipientAddress', + 'ReleasedUser', + 'Tenant', + ] + + return ( + <> + + setDialogOpen(false)} + maxWidth="lg" + fullWidth + > + + Quarantine Message + setDialogOpen(false)} + sx={{ position: 'absolute', right: 8, top: 8 }} + > + + + + + {getMessageContents.isSuccess ? ( + + ) : ( + + )} + + + setHeaderDialogOpen(false)} + maxWidth="lg" + fullWidth + > + + Message Headers - {headerRow?.Subject} + setHeaderDialogOpen(false)} + sx={{ position: 'absolute', right: 8, top: 8 }} + > + + + + + {getMessageHeaders.isSuccess ? ( + + {getMessageHeaders?.data?.Header} + + ) : ( + + )} + + + setTraceDialogOpen(false)} + maxWidth="lg" + fullWidth + > + + Message Trace - {messageSubject} + setTraceDialogOpen(false)} + sx={{ position: 'absolute', right: 8, top: 8 }} + > + + + + + {getMessageTraceDetails.isPending && ( + + {' '} + Loading message trace details... + + )} + {getMessageTraceDetails.isSuccess && ( + + getMessageTraceDetails.mutate({ + url: '/api/ListMessageTrace', + data: { + tenantFilter: traceTenant, + messageId: traceMessageId, + }, + }) + } + isFetching={getMessageTraceDetails.isPending} + /> + )} + + + + ) +} + +export default CippQuarantineTable diff --git a/frontend/src/pages/email/administration/quarantine/files.js b/frontend/src/pages/email/administration/quarantine/files.js new file mode 100644 index 0000000000..7d17fadc36 --- /dev/null +++ b/frontend/src/pages/email/administration/quarantine/files.js @@ -0,0 +1,14 @@ +import { Layout as DashboardLayout } from '../../../../layouts/index.js' +import { TabbedLayout } from '../../../../layouts/TabbedLayout.jsx' +import { CippQuarantineTable } from '../../../../components/CippComponents/CippQuarantineTable.jsx' +import tabOptions from './tabOptions.json' + +const Page = () => + +Page.getLayout = (page) => ( + + {page} + +) + +export default Page diff --git a/frontend/src/pages/email/administration/quarantine/index.js b/frontend/src/pages/email/administration/quarantine/index.js index e34f7d57ff..693f2434fe 100644 --- a/frontend/src/pages/email/administration/quarantine/index.js +++ b/frontend/src/pages/email/administration/quarantine/index.js @@ -1,254 +1,14 @@ import { Layout as DashboardLayout } from '../../../../layouts/index.js' -import { CippTablePage } from '../../../../components/CippComponents/CippTablePage.jsx' -import { useEffect, useState } from 'react' -import { - Dialog, - DialogTitle, - DialogContent, - IconButton, - Skeleton, - Typography, - CircularProgress, -} from '@mui/material' -import { Block, Close, Done, DoneAll } from '@mui/icons-material' -import { CippMessageViewer } from '../../../../components/CippComponents/CippMessageViewer.jsx' -import { ApiGetCall, ApiPostCall } from '../../../../api/ApiCall' -import { useSettings } from '../../../../hooks/use-settings' -import { EyeIcon, DocumentTextIcon } from '@heroicons/react/24/outline' -import { CippDataTable } from '../../../../components/CippTable/CippDataTable' +import { TabbedLayout } from '../../../../layouts/TabbedLayout.jsx' +import { CippQuarantineTable } from '../../../../components/CippComponents/CippQuarantineTable.jsx' +import tabOptions from './tabOptions.json' -const simpleColumns = [ - 'ReceivedTime', - 'ReleaseStatus', - 'Subject', - 'SenderAddress', - 'RecipientAddress', - 'Type', - 'PolicyName', - 'Tenant', -] -const detailColumns = ['Received', 'Status', 'SenderAddress', 'RecipientAddress'] -const pageTitle = 'Quarantine Management' +const Page = () => -const Page = () => { - const tenantFilter = useSettings().currentTenant - const [dialogOpen, setDialogOpen] = useState(false) - const [dialogContent, setDialogContent] = useState(null) - const [messageId, setMessageId] = useState(null) - const [traceDialogOpen, setTraceDialogOpen] = useState(false) - const [traceDetails, setTraceDetails] = useState([]) - const [traceMessageId, setTraceMessageId] = useState(null) - const [messageSubject, setMessageSubject] = useState(null) - const [messageContentsWaiting, setMessageContentsWaiting] = useState(false) - - const getMessageContents = ApiGetCall({ - url: '/api/ListMailQuarantineMessage', - data: { - tenantFilter: tenantFilter, - Identity: messageId, - }, - waiting: messageContentsWaiting, - queryKey: `ListMailQuarantineMessage-${messageId}`, - }) - - const getMessageTraceDetails = ApiPostCall({ - urlFromData: true, - queryKey: `MessageTraceDetail-${traceMessageId}`, - onResult: (result) => { - setTraceDetails(result) - }, - }) - - const viewMessage = (row) => { - const id = row.Identity - setMessageId(id) - if (!messageContentsWaiting) { - setMessageContentsWaiting(true) - } - getMessageContents.refetch() - setDialogOpen(true) - } - - const viewMessageTrace = (row) => { - setTraceMessageId(row.MessageId) - getMessageTraceDetails.mutate({ - url: '/api/ListMessageTrace', - data: { - tenantFilter: tenantFilter, - messageId: row.MessageId, - }, - }) - setMessageSubject(row.Subject) - setTraceDialogOpen(true) - } - - useEffect(() => { - if (getMessageContents.isSuccess) { - setDialogContent() - } else { - setDialogContent() - } - }, [getMessageContents.isSuccess, getMessageContents.data]) - - const actions = [ - { - label: 'View Message', - noConfirm: true, - customFunction: viewMessage, - icon: , - hideBulk: true, - }, - { - label: 'View Message Trace', - noConfirm: true, - customFunction: viewMessageTrace, - icon: , - hideBulk: true, - }, - { - label: 'Release', - type: 'POST', - url: '/api/ExecQuarantineManagement', - multiPost: true, - data: { - Identity: 'Identity', - Type: '!Release', - }, - confirmText: 'Are you sure you want to release this message?', - icon: , - condition: (row) => row.ReleaseStatus !== 'RELEASED', - }, - { - label: 'Deny', - type: 'POST', - url: '/api/ExecQuarantineManagement', - multiPost: true, - data: { - Identity: 'Identity', - Type: '!Deny', - RecipientAddress: 'RecipientAddress', - }, - confirmText: 'Are you sure you want to deny this message?', - icon: , - condition: (row) => row.ReleaseStatus === 'REQUESTED', - }, - { - label: 'Release & Allow Sender', - type: 'POST', - url: '/api/ExecQuarantineManagement', - multiPost: true, - data: { - Identity: 'Identity', - Type: '!Release', - AllowSender: true, - SenderAddress: 'SenderAddress', - PolicyName: 'PolicyName', - }, - confirmText: - 'Are you sure you want to release this email and add the sender to the whitelist?', - icon: , - condition: (row) => row.ReleaseStatus !== 'RELEASED', - }, - ] - - const offCanvas = { - extendedInfoFields: ['MessageId', 'RecipientAddress', 'Type'], - actions: actions, - } - - const filterList = [ - { - filterName: 'Not Released', - value: [{ id: 'ReleaseStatus', value: 'NOTRELEASED' }], - type: 'column', - filterType: 'equal', - }, - { - filterName: 'Released', - value: [{ id: 'ReleaseStatus', value: 'RELEASED' }], - type: 'column', - filterType: 'equal', - }, - { - filterName: 'Requested', - value: [{ id: 'ReleaseStatus', value: 'REQUESTED' }], - type: 'column', - filterType: 'equal', - }, - ] - - return ( - <> - - setDialogOpen(false)} maxWidth="lg" fullWidth> - - Quarantine Message - setDialogOpen(false)} - sx={{ position: 'absolute', right: 8, top: 8 }} - > - - - - {dialogContent} - - setTraceDialogOpen(false)} - maxWidth="lg" - fullWidth - > - - Message Trace - {messageSubject} - setTraceDialogOpen(false)} - sx={{ position: 'absolute', right: 8, top: 8 }} - > - - - - - {getMessageTraceDetails.isPending && ( - - Loading message trace - details... - - )} - {getMessageTraceDetails.isSuccess && ( - - getMessageTraceDetails.mutate({ - url: '/api/ListMessageTrace', - data: { - tenantFilter: tenantFilter, - messageId: traceMessageId, - }, - }) - } - isFetching={getMessageTraceDetails.isPending} - /> - )} - - - - ) -} - -Page.getLayout = (page) => {page} +Page.getLayout = (page) => ( + + {page} + +) export default Page diff --git a/frontend/src/pages/email/administration/quarantine/tabOptions.json b/frontend/src/pages/email/administration/quarantine/tabOptions.json new file mode 100644 index 0000000000..b431bab37b --- /dev/null +++ b/frontend/src/pages/email/administration/quarantine/tabOptions.json @@ -0,0 +1,17 @@ +[ + { + "label": "Email", + "path": "/email/administration/quarantine", + "icon": "Email" + }, + { + "label": "Files", + "path": "/email/administration/quarantine/files", + "icon": "FilePresent" + }, + { + "label": "Teams Messages", + "path": "/email/administration/quarantine/teams", + "icon": "Groups" + } +] diff --git a/frontend/src/pages/email/administration/quarantine/teams.js b/frontend/src/pages/email/administration/quarantine/teams.js new file mode 100644 index 0000000000..4bbe6f3eda --- /dev/null +++ b/frontend/src/pages/email/administration/quarantine/teams.js @@ -0,0 +1,14 @@ +import { Layout as DashboardLayout } from '../../../../layouts/index.js' +import { TabbedLayout } from '../../../../layouts/TabbedLayout.jsx' +import { CippQuarantineTable } from '../../../../components/CippComponents/CippQuarantineTable.jsx' +import tabOptions from './tabOptions.json' + +const Page = () => + +Page.getLayout = (page) => ( + + {page} + +) + +export default Page diff --git a/frontend/tests/components/CippComponents/CippOffCanvas.test.jsx b/frontend/tests/components/CippComponents/CippOffCanvas.test.jsx index b3f5e05961..dbe2f48737 100644 --- a/frontend/tests/components/CippComponents/CippOffCanvas.test.jsx +++ b/frontend/tests/components/CippComponents/CippOffCanvas.test.jsx @@ -1,5 +1,5 @@ import React, { useState } from 'react' -import { act, screen, waitFor, within } from '@testing-library/react' +import { act, cleanup, screen, waitFor, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { Button } from '@mui/material' import { renderWithTheme } from '../../test-utils' @@ -52,7 +52,12 @@ const mockDeviceData = { }, } -const InteractiveWrapper = ({ onClose, onNavigateUp, onNavigateDown, ...props }) => { +const InteractiveWrapper = ({ + onClose, + onNavigateUp, + onNavigateDown, + ...props +}) => { const [open, setOpen] = useState(false) return ( <> @@ -146,7 +151,9 @@ describe('CippOffCanvas', () => { expect(onClose).toHaveBeenCalledTimes(1) await waitFor(() => - expect(within(document.body).queryByText('Device Details')).not.toBeInTheDocument() + expect( + within(document.body).queryByText('Device Details') + ).not.toBeInTheDocument() ) }) @@ -188,4 +195,37 @@ describe('CippOffCanvas', () => { // field absent from extendedData renders the N/A fallback expect(root.getByText('N/A')).toBeInTheDocument() }) + + it('renders the info card above children by default and below with actionsPosition bottom', () => { + const renderCanvas = (actionsPosition) => { + renderWithTheme( + ( +
    child content
    + )} + /> + ) + } + const childrenBox = () => + within(document.body).getByTestId('custom-children') + const infoValue = () => within(document.body).getByText('DESKTOP-ENTRA-01') + + renderCanvas('top') + expect( + childrenBox().compareDocumentPosition(infoValue()) & + Node.DOCUMENT_POSITION_PRECEDING + ).toBeTruthy() + + cleanup() + renderCanvas('bottom') + expect( + childrenBox().compareDocumentPosition(infoValue()) & + Node.DOCUMENT_POSITION_FOLLOWING + ).toBeTruthy() + }) }) diff --git a/frontend/tests/components/CippComponents/CippQuarantineDetails.test.jsx b/frontend/tests/components/CippComponents/CippQuarantineDetails.test.jsx new file mode 100644 index 0000000000..cab1975d1d --- /dev/null +++ b/frontend/tests/components/CippComponents/CippQuarantineDetails.test.jsx @@ -0,0 +1,180 @@ +import React from 'react' +import { screen } from '@testing-library/react' +import { renderWithProviders } from '../../test-utils' +import { api, apiCallMock, getResult } from '../../mocks/api-call' +import { CippQuarantineDetails } from '../../../src/components/CippComponents/CippQuarantineDetails' + +vi.mock('../../../src/api/ApiCall', async () => + (await import('../../mocks/api-call')).apiCallMock() +) + +import TimeAgo from 'javascript-time-ago' +import en from 'javascript-time-ago/locale/en' +try { + TimeAgo.addDefaultLocale(en) +} catch (e) { + /* already added */ +} + +// producer shapes: row is Get-QuarantineMessage output enriched by Add-CIPPQuarantineMessageProperties, +// analyzed is Invoke-ListMailQuarantineMessageDetails Results[0] (analyzedEmails or header fallback) +const quarantineRow = { + Identity: + '5e5e5e5e-1111-2222-3333-444455556666\\c81d4a2e-1111-2222-3333-444455556666', + NetworkMessageId: '5e5e5e5e-1111-2222-3333-444455556666', + Tenant: 'fabrikam.com', + CustomerId: 'customer-1', + Subject: 'Suspicious invoice', + ReceivedTime: '2026-06-01T10:00:00Z', + Expires: '2026-07-01T10:00:00Z', + Type: 'HighConfPhish', + ReleaseStatus: 'NOTRELEASED', + PolicyType: 'AntiPhish', + PolicyName: 'Default AntiPhish', + SenderAddress: 'bad@evil.example', + RecipientAddress: ['user@fabrikam.com'], + Size: 2048, + Direction: 'Inbound', + EntityType: 'Email', + MessageId: '', + QuarantinedUser: 'user@fabrikam.com', + Reported: false, +} + +const analyzed = { + recipientEmailAddress: 'user@fabrikam.com', + internetMessageId: '', + returnPath: 'bounce@evil.example', + directionality: 'Inbound', + language: 'en', + spamConfidenceLevel: -1, + bulkComplaintLevel: 1, + threatTypes: ['Malware'], + detectionMethods: ['File detonation'], + primaryOverrideSource: 'None', + policyAction: 'Quarantine', + senderDetail: { + displayName: 'Evil Sender', + mailFromAddress: 'bad@evil.example', + fromAddress: 'bad@evil.example', + ipv4: '203.0.113.5', + location: 'US', + }, + originalDelivery: { + originalThreats: ['Malware'], + location: 'Quarantine', + action: 'Quarantined', + }, + latestDelivery: { + latestThreats: ['Malware'], + location: 'Quarantine', + action: 'Quarantined', + }, + authenticationDetails: { + dmarc: 'fail', + dkim: 'pass', + senderPolicyFramework: 'softfail', + compositeAuthentication: 'fail', + }, + urls: [ + { + url: 'https://evil.example/pay', + threatType: 'Malware', + detectionMethod: 'Detonated', + }, + ], + attachments: [ + { + fileName: 'invoice.pdf', + contentType: 'application/pdf', + fileSize: 1024, + sha256: + 'aa11bb22cc33dd44ee55ff6677889900aabbccddeeff00112233445566778899', + threatType: 'Malware', + malwareFamily: 'TestFamily', + }, + ], +} + +const detailsResult = (metadata = {}) => + getResult({ data: { Results: [analyzed], Metadata: metadata } }) + +const defaultMetadata = { Available: true, Source: 'Defender' } +const headersResult = detailsResult({ Available: true, Source: 'Headers' }) +const defenderResult = detailsResult(defaultMetadata) + +describe('CippQuarantineDetails', () => { + it('shows the header-parsed fallback notice and targets the row tenant for enrichment', () => { + let detailOpts = null + api.get = (opts) => { + if (opts.url === '/api/ListMailQuarantineMessageDetails') { + detailOpts = opts + return headersResult + } + return getResult() + } + renderWithProviders() + + expect( + screen.getByText(/Showing details parsed from the message headers/) + ).toBeInTheDocument() + expect(detailOpts.data.tenantFilter).toBe('fabrikam.com') + expect(detailOpts.data.Identity).toBe(quarantineRow.Identity) + // fallback fields render from the analyzed-shaped object + expect(screen.getAllByText('Fail').length).toBeGreaterThan(0) + expect(screen.getByText('Softfail')).toBeInTheDocument() + }) + + it('colors phishing and malware reason chips as error', () => { + api.get = () => defenderResult + renderWithProviders() + expect( + screen + .getAllByText('HighConfPhish') + .find((el) => el.closest('[class*="MuiChip-colorError"]')) + ).toBeTruthy() + + api.get = () => defenderResult + renderWithProviders( + + ) + expect( + screen + .getAllByText('Malware') + .find((el) => el.closest('[class*="MuiChip-colorError"]')) + ).toBeTruthy() + }) + + it('colors spam and bulk reason chips as warning', () => { + api.get = () => defenderResult + renderWithProviders( + + ) + expect( + screen + .getAllByText('Spam') + .find((el) => el.closest('[class*="MuiChip-colorWarning"]')) + ).toBeTruthy() + + api.get = () => defenderResult + renderWithProviders( + + ) + expect( + screen + .getAllByText('Bulk') + .find((el) => el.closest('[class*="MuiChip-colorWarning"]')) + ).toBeTruthy() + }) + + it('renders URL and attachment verdict tables from the analyzed enrichment', () => { + api.get = () => defenderResult + renderWithProviders() + + expect(screen.getByText('https://evil.example/pay')).toBeInTheDocument() + expect(screen.getByText('Detonated')).toBeInTheDocument() + expect(screen.getByText('invoice.pdf')).toBeInTheDocument() + expect(screen.getByText('TestFamily')).toBeInTheDocument() + expect(screen.getByText('1.0 KB')).toBeInTheDocument() + }) +}) diff --git a/frontend/tests/components/CippComponents/CippQuarantineTable.test.jsx b/frontend/tests/components/CippComponents/CippQuarantineTable.test.jsx new file mode 100644 index 0000000000..84bd7efb7c --- /dev/null +++ b/frontend/tests/components/CippComponents/CippQuarantineTable.test.jsx @@ -0,0 +1,98 @@ +import React from 'react' +import { act, screen } from '@testing-library/react' +import { renderWithProviders, settingsWith } from '../../test-utils' +import { api, apiCallMock, getResult } from '../../mocks/api-call' +import { CippQuarantineTable } from '../../../src/components/CippComponents/CippQuarantineTable' + +const tableProps = vi.hoisted(() => ({ current: null })) +vi.mock('../../../src/api/ApiCall', async () => + (await import('../../mocks/api-call')).apiCallMock() +) +vi.mock('../../../src/components/CippComponents/CippTablePage.jsx', () => ({ + CippTablePage: (props) => { + tableProps.current = props + return
    + }, +})) + +const quarantineRow = { + Identity: + '5e5e5e5e-1111-2222-3333-444455556666\\c81d4a2e-1111-2222-3333-444455556666', + NetworkMessageId: '5e5e5e5e-1111-2222-3333-444455556666', + Tenant: 'fabrikam.com', + Subject: 'Suspicious invoice', + MessageId: '', + ReceivedTime: '2026-06-01T10:00:00Z', + RecipientAddress: ['user@fabrikam.com'], + ReleaseStatus: 'NOTRELEASED', +} + +describe('CippQuarantineTable', () => { + it('gates email-only actions to the Email tab and passes the entity type to the API', () => { + api.get = () => getResult() + const { unmount } = renderWithProviders( + + ) + const { actions, apiData } = tableProps.current + const labels = actions.map((action) => action.label) + + expect(labels).toContain('Release') + expect(labels).toContain('Delete from Quarantine') + expect(labels).not.toContain('Preview Message') + expect(labels).not.toContain('Deny') + expect(labels).not.toContain('Block Sender') + expect(labels).not.toContain('Submit to Microsoft for Review') + expect(labels).not.toContain('Open Email Entity in Defender') + expect(apiData.EntityType).toBe('Teams') + unmount() + + renderWithProviders() + const emailLabels = tableProps.current.actions.map((action) => action.label) + expect(emailLabels).toContain('Preview Message') + expect(emailLabels).toContain('Deny') + expect(emailLabels).toContain('Submit to Microsoft for Review') + expect(emailLabels).toContain('Block Sender') + expect(emailLabels).toContain('Open Email Entity in Defender') + }) + + it('targets the row tenant for per-message calls in the AllTenants view', async () => { + const callOpts = [] + api.get = (opts) => { + callOpts.push(opts) + return getResult() + } + renderWithProviders(, { + settings: settingsWith({ currentTenant: 'AllTenants' }), + }) + + const preview = tableProps.current.actions.find( + (action) => action.label === 'Preview Message' + ) + await act(async () => preview.customFunction(quarantineRow)) + + const contentsCall = callOpts.find( + (opts) => + opts.url === '/api/ListMailQuarantineMessage' && + opts.data?.Identity === quarantineRow.Identity + ) + expect(contentsCall).toBeTruthy() + expect(contentsCall.data.tenantFilter).toBe('fabrikam.com') + }) + + it('renders the raw message headers in the headers dialog', async () => { + const headerText = + 'Received: from mail.evil.example\r\nX-CIPP-Test: present' + api.get = (opts) => + opts.url === '/api/ListMailQuarantineMessageHeader' + ? getResult({ data: { Header: headerText } }) + : getResult() + renderWithProviders() + + const viewHeaders = tableProps.current.actions.find( + (action) => action.label === 'View Message Headers' + ) + await act(async () => viewHeaders.customFunction(quarantineRow)) + + expect(screen.getByText(/X-CIPP-Test: present/)).toBeInTheDocument() + }) +}) From f910a105c45736bfd384c2029b50a4d55b0e9437 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Tue, 18 Aug 2026 17:58:02 +0800 Subject: [PATCH 124/226] fix(support): strip auth tokens from bundles Always remove credential material from generated support bundles before any optional redaction step. This adds a token-stripping pass that clears known *_token fields and JWT-like values across the payload, tracks how many were removed, and updates the dialog copy to state that authentication tokens are always removed. --- .../CippSupportBundleDialog.jsx | 9 ++++++- frontend/src/utils/support-bundle.js | 24 +++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/CippComponents/CippSupportBundleDialog.jsx b/frontend/src/components/CippComponents/CippSupportBundleDialog.jsx index 33f8757f8d..5872d1b057 100644 --- a/frontend/src/components/CippComponents/CippSupportBundleDialog.jsx +++ b/frontend/src/components/CippComponents/CippSupportBundleDialog.jsx @@ -23,6 +23,7 @@ import { getSupportRecording, getSupportRecordingCount, redactBundle, + stripTokens, } from '../../utils/support-bundle' // The fixed sections go through fetch() rather than axios on purpose: the armed recorder @@ -123,6 +124,11 @@ const CippSupportBundleDialog = ({ open, onClose }) => { user: { me, authMe }, network, } + // Tokens are live credentials and are stripped from every bundle, before and + // independent of the optional identifier redaction. + const stripped = stripTokens(assembled) + assembled = stripped.bundle + assembled.tokensRemoved = stripped.removed if (redact) { // The instance's own hostname identifies the installation, not a customer // tenant — support needs it, so it survives redaction. @@ -199,7 +205,8 @@ const CippSupportBundleDialog = ({ open, onClose }) => { ) : ( The file contains unredacted data from the current page, your - user identity, and instance details. Only share it with support. + user identity, and instance details. Authentication tokens are + always removed. Only share it with support. )} diff --git a/frontend/src/utils/support-bundle.js b/frontend/src/utils/support-bundle.js index b288fe7895..deb3114f2a 100644 --- a/frontend/src/utils/support-bundle.js +++ b/frontend/src/utils/support-bundle.js @@ -92,6 +92,30 @@ export const getSupportRecordingCount = () => calls.length const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +// A bearer token is a live credential, not an identifier — there is no support value in +// shipping one, so tokens are stripped from EVERY bundle regardless of the redaction +// option. Known token fields (/.auth/me's access_token, id_token, refresh_token and +// friends) are emptied by name, and anything shaped like a JWT is removed wherever it +// appears, error payloads included. base64url can never contain a quote or backslash, +// so the substitution cannot break the serialized JSON. +const TOKEN_FIELD_PATTERN = + /"([A-Za-z0-9_]*(?:access|id|refresh|session)_token[A-Za-z0-9_]*)":"[^"]*"/g +const JWT_PATTERN = /eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]*/g + +export const stripTokens = (bundle) => { + let text = JSON.stringify(bundle) + let removed = 0 + text = text.replace(TOKEN_FIELD_PATTERN, (match, key) => { + removed++ + return `"${key}":""` + }) + text = text.replace(JWT_PATTERN, () => { + removed++ + return '' + }) + return { bundle: JSON.parse(text), removed } +} + const EMAIL_PATTERN = /[A-Za-z0-9._%+'-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g const GUID_PATTERN = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi From 5f5646415e877d740b6950b7c61da3a000ea8d93 Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Tue, 18 Aug 2026 12:33:37 +0200 Subject: [PATCH 125/226] fixes #257 --- .../Templates/SensitivityLabelTemplate.json | 5 +++-- backend/Config/standards.json | 3 ++- .../Invoke-CIPPStandardSensitivityLabelTemplate.ps1 | 2 +- frontend/src/data/standards.json | 3 ++- 4 files changed, 8 insertions(+), 5 deletions(-) diff --git a/backend/Config/BaselineStandards/Templates/SensitivityLabelTemplate.json b/backend/Config/BaselineStandards/Templates/SensitivityLabelTemplate.json index 8a263f0736..93f90081f2 100644 --- a/backend/Config/BaselineStandards/Templates/SensitivityLabelTemplate.json +++ b/backend/Config/BaselineStandards/Templates/SensitivityLabelTemplate.json @@ -24,7 +24,7 @@ "instanceIdentity": "sensitivityLabelTemplate", "identity": { "partition": "SensitivityLabelTemplate", - "nameField": "name" + "nameField": "DisplayName" }, "variables": { "sensitivityLabelTemplate": { @@ -34,7 +34,8 @@ "required": true, "api": { "url": "/api/ListSensitivityLabelTemplates", - "labelField": "name", + "labelField": "DisplayName", + "altLabelField": "Name", "valueField": "GUID", "queryKey": "ListSensitivityLabelTemplates" } diff --git a/backend/Config/standards.json b/backend/Config/standards.json index b44e78ab29..c0fa5edcce 100644 --- a/backend/Config/standards.json +++ b/backend/Config/standards.json @@ -6957,7 +6957,8 @@ "label": "Select Sensitivity Label Templates", "api": { "url": "/api/ListSensitivityLabelTemplates", - "labelField": "name", + "labelField": "DisplayName", + "altLabelField": "Name", "valueField": "GUID", "queryKey": "ListSensitivityLabelTemplates" } diff --git a/backend/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardSensitivityLabelTemplate.ps1 b/backend/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardSensitivityLabelTemplate.ps1 index 01603ce0cc..8cee20cffc 100644 --- a/backend/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardSensitivityLabelTemplate.ps1 +++ b/backend/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardSensitivityLabelTemplate.ps1 @@ -23,7 +23,7 @@ function Invoke-CIPPStandardSensitivityLabelTemplate { EXECUTIVETEXT Deploys sensitivity labels for classification and protection of files, emails, and Microsoft 365 group content. Ensures consistent classification taxonomy and encryption settings across tenants. ADDEDCOMPONENT - {"type":"autoComplete","multiple":true,"creatable":false,"name":"sensitivityLabelTemplate","label":"Select Sensitivity Label Templates","api":{"url":"/api/ListSensitivityLabelTemplates","labelField":"name","valueField":"GUID","queryKey":"ListSensitivityLabelTemplates"}} + {"type":"autoComplete","multiple":true,"creatable":false,"name":"sensitivityLabelTemplate","label":"Select Sensitivity Label Templates","api":{"url":"/api/ListSensitivityLabelTemplates","labelField":"DisplayName","altLabelField":"Name","valueField":"GUID","queryKey":"ListSensitivityLabelTemplates"}} UPDATECOMMENTBLOCK Run the Tools\Update-StandardsComments.ps1 script to update this comment block .LINK diff --git a/frontend/src/data/standards.json b/frontend/src/data/standards.json index e2e74e6f0e..c73247c6cd 100644 --- a/frontend/src/data/standards.json +++ b/frontend/src/data/standards.json @@ -6959,7 +6959,8 @@ "label": "Select Sensitivity Label Templates", "api": { "url": "/api/ListSensitivityLabelTemplates", - "labelField": "name", + "labelField": "DisplayName", + "altLabelField": "Name", "valueField": "GUID", "queryKey": "ListSensitivityLabelTemplates" } From fb31d89c0d78d3a60f83c5b65b9c85d9f424b015 Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Tue, 18 Aug 2026 12:37:33 +0200 Subject: [PATCH 126/226] fixes #302 --- .../CIPP/Core/Invoke-ListCustomDataMappings.ps1 | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ListCustomDataMappings.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ListCustomDataMappings.ps1 index 057072b1bb..e9aa67f33b 100644 --- a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ListCustomDataMappings.ps1 +++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ListCustomDataMappings.ps1 @@ -21,9 +21,9 @@ function Invoke-ListCustomDataMappings { $Mappings = Get-CIPPAzDataTableEntity @CustomDataMappingsTable | ForEach-Object { $Mapping = $_.JSON | ConvertFrom-Json -AsHashtable - # Filter by tenant + # Filter by tenant: only include mappings assigned to this tenant or to AllTenants $TenantList = Expand-CIPPTenantGroups -TenantFilter $Mapping.tenantFilter - if ($TenantFilter -and ($TenantList -contains $TenantFilter -or $TenantList -eq 'AllTenants')) { + if ($TenantFilter -and $TenantList.value -notcontains $TenantFilter -and $TenantList.value -notcontains 'AllTenants') { return } From 6d2bea0899e12e17e4723c0c1b1d7b76a842be4d Mon Sep 17 00:00:00 2001 From: Roel van der Wegen Date: Tue, 18 Aug 2026 12:38:59 +0200 Subject: [PATCH 127/226] feat(cache): add script to trim Azurite cache if size exceeds threshold Introduces a new PowerShell script, Clear-CippAzuriteCacheIfNeeded.ps1, to manage Azurite's cache by emptying recreatable tables when the database size approaches the V8 string limit (~512 MiB). This script is invoked during the startup process in Start-Cipp-Dev-Windows-docker.ps1 to ensure smooth operation of the local development environment. --- .../tools/Clear-CippAzuriteCacheIfNeeded.ps1 | 185 ++++++++++++++++++ build/tools/Start-Cipp-Dev-Windows-docker.ps1 | 16 +- 2 files changed, 198 insertions(+), 3 deletions(-) create mode 100644 build/tools/Clear-CippAzuriteCacheIfNeeded.ps1 diff --git a/build/tools/Clear-CippAzuriteCacheIfNeeded.ps1 b/build/tools/Clear-CippAzuriteCacheIfNeeded.ps1 new file mode 100644 index 0000000000..12feb04c64 --- /dev/null +++ b/build/tools/Clear-CippAzuriteCacheIfNeeded.ps1 @@ -0,0 +1,185 @@ +# Trim Azurite table caches when the LokiJS DB is large enough to crash Azurite. +# +# Azurite stores every table in one JSON file and loads it as a single Node string. +# V8 refuses strings longer than 0x1fffffe8 (~512 MiB), so a full CippReportingDB +# cache will crash Table startup with: +# Cannot create a string longer than 0x1fffffe8 characters +# +# This keeps Tenants/Config/Settings and only empties recreatable cache tables. +# Called from Start-Cipp-Dev-Windows-docker.ps1 before compose up. Missing volume +# or table DB is a skip, not a failure — startup must still continue. + +[CmdletBinding()] +param( + [string]$VolumeName = 'cipp-ng_azurite-data', + [string]$TableDbFile = '__azurite_db_table__.json', + # Headroom under the ~512 MiB V8 string cap so a session of cache writes cannot immediately re-crash Azurite. + [long]$TrimThresholdBytes = 400MB +) + +# Best-effort: a missing table DB or a failed size probe must never abort compose up. +$ErrorActionPreference = 'Stop' + +function Write-AzuriteTrimSkip { + param([string]$Message) + Write-Host " $Message" -ForegroundColor DarkGray +} + +function Get-AzuriteTableDbSize { + param([string]$Volume, [string]$FileName) + + $probe = @" +if [ -f /workspace/$FileName ]; then stat -c%s /workspace/$FileName; else echo 0; fi +"@ + $output = docker run --rm --network none -v "${Volume}:/workspace" alpine sh -c $probe 2>&1 + if ($LASTEXITCODE -ne 0) { + return $null + } + $sizeLine = @($output) | Where-Object { $_ -match '^\d+$' } | Select-Object -Last 1 + if (-not $sizeLine) { + return $null + } + return [long]$sizeLine +} + +try { +$volumes = @(docker volume ls --format '{{.Name}}') +if ($LASTEXITCODE -ne 0) { + Write-AzuriteTrimSkip 'Could not list Docker volumes; skip cache trim.' + return +} +if ($volumes -notcontains $VolumeName) { + Write-AzuriteTrimSkip "Azurite volume '$VolumeName' does not exist yet; skip cache trim." + return +} + +Write-Host 'Checking Azurite table DB size...' -ForegroundColor DarkGray +$size = Get-AzuriteTableDbSize -Volume $VolumeName -FileName $TableDbFile +if ($null -eq $size) { + Write-AzuriteTrimSkip "Could not find or inspect $TableDbFile; skip cache trim." + return +} +if ($size -le 0) { + Write-AzuriteTrimSkip 'No table DB yet; skip cache trim.' + return +} + +$sizeMb = [math]::Round($size / 1MB, 1) +if ($size -lt $TrimThresholdBytes) { + Write-Host (" {0} is {1} MB (trim at {2} MB)." -f $TableDbFile, $sizeMb, [math]::Round($TrimThresholdBytes / 1MB)) -ForegroundColor DarkGray + return +} + +Write-Host (" {0} is {1} MB — emptying cache tables so Azurite can start." -f $TableDbFile, $sizeMb) -ForegroundColor Yellow + +$existing = docker ps -aq --filter "name=^cipp-azurite$" +if ($existing) { + Write-Host ' Stopping cipp-azurite so the table DB can be rewritten...' -ForegroundColor DarkGray + docker stop cipp-azurite | Out-Null +} + +$python = @' +#!/usr/bin/env python3 +import json +import os +import sys + +PATH = "/workspace/" + os.environ["AZURITE_TABLE_DB"] +EXPLICIT = {"cippreportingdb", "calendarfoldercache", "reruncache"} + +def table_from_collection(coll_name): + if not coll_name or coll_name.startswith("$"): + return None + if "$" in coll_name: + return coll_name.split("$", 1)[1] + return coll_name + +def should_empty(table): + t = (table or "").lower() + return t in EXPLICIT or t.startswith("cache") + +def main(): + if not os.path.isfile(PATH): + print("missing=1") + return + before = os.path.getsize(PATH) + with open(PATH, "r", encoding="utf-8") as f: + db = json.load(f) + + emptied = [] + for coll in db.get("collections") or []: + table = table_from_collection(coll.get("name", "")) + if not table or not should_empty(table): + continue + n = len(coll.get("data") or []) + coll["data"] = [] + if "idIndex" in coll: + coll["idIndex"] = [] + coll["dirtyIds"] = [] + coll["maxId"] = 0 + emptied.append((coll.get("name", table), n)) + + tmp = PATH + ".tmp" + with open(tmp, "w", encoding="utf-8") as f: + json.dump(db, f, separators=(",", ":"), ensure_ascii=False) + os.replace(tmp, PATH) + after = os.path.getsize(PATH) + + print(f"before_bytes={before}") + print(f"after_bytes={after}") + for name, n in sorted(emptied, key=lambda x: -x[1]): + print(f"emptied\t{n}\t{name}") + +if __name__ == "__main__": + try: + main() + except Exception as exc: + print(f"error: {exc}", file=sys.stderr) + sys.exit(1) +'@ + +$scriptPath = Join-Path $env:TEMP 'cipp-clear-azurite-cache.py' +Set-Content -Path $scriptPath -Value $python -Encoding utf8NoBOM + +$trimOutput = docker run --rm --memory=4g --network none ` + -e "AZURITE_TABLE_DB=$TableDbFile" ` + -v "${VolumeName}:/workspace" ` + -v "${scriptPath}:/script.py:ro" ` + python:3-slim python /script.py +$trimExit = $LASTEXITCODE +Remove-Item -LiteralPath $scriptPath -ErrorAction SilentlyContinue + +if ($trimExit -ne 0) { + Write-Warning "Azurite cache trim failed; continuing startup.`n$trimOutput" + return +} + +$missing = $false +foreach ($line in @($trimOutput)) { + if ($line -match '^missing=1$') { + $missing = $true + } elseif ($line -match '^emptied\t(\d+)\t(.+)$') { + Write-Host (" {0}: {1} rows" -f $Matches[2], $Matches[1]) -ForegroundColor DarkGray + } elseif ($line -match '^after_bytes=(\d+)$') { + $afterMb = [math]::Round([long]$Matches[1] / 1MB, 1) + Write-Host (" Table DB is now {0} MB. Tenant/config tables were left in place." -f $afterMb) -ForegroundColor Green + } +} +if ($missing) { + Write-AzuriteTrimSkip "Could not find $TableDbFile; skip cache trim." + return +} + +$afterSize = Get-AzuriteTableDbSize -Volume $VolumeName -FileName $TableDbFile +# 0x1fffffe8 UTF-16 units; ASCII JSON bytes ~= character count. +$nodeStringLimit = 536870888 +if ($null -eq $afterSize) { + Write-AzuriteTrimSkip "Could not re-inspect $TableDbFile after trim; continuing startup." + return +} +if ($afterSize -ge $nodeStringLimit) { + Write-Warning ("Azurite table DB is still {0} MB after emptying caches (above the Node string limit). Continuing startup; if Table crashes, factory-reset with: docker volume rm {1}" -f ([math]::Round($afterSize / 1MB, 1)), $VolumeName) +} +} catch { + Write-Warning "Azurite cache trim skipped; continuing startup. $($_.Exception.Message)" +} diff --git a/build/tools/Start-Cipp-Dev-Windows-docker.ps1 b/build/tools/Start-Cipp-Dev-Windows-docker.ps1 index b503aa8204..6b155398af 100644 --- a/build/tools/Start-Cipp-Dev-Windows-docker.ps1 +++ b/build/tools/Start-Cipp-Dev-Windows-docker.ps1 @@ -1,7 +1,8 @@ # Start CIPP local dev environment for windows. # # Runs docker compose up which starts: -# 1. Azurite (local Azure Storage emulator) +# 1. Azurite (local Azure Storage emulator). Cache tables are emptied first if the +# on-disk LokiJS file is large enough to crash Azurite's Table service. # 2. Craft API container (mounts ./backend for PS modules) # 3. Next.js frontend started in ps directly since bind mounts are really slow in Docker for Windows # @@ -29,6 +30,17 @@ if ($LASTEXITCODE -ne 0) { } Write-Host ' Docker is running.' -ForegroundColor Green +$RepoRoot = (Get-Item $PSScriptRoot).Parent.Parent.FullName + +# Azurite loads all tables as one Node string (~512 MiB cap). Trim caches first if the +# volume is already large enough to crash Table startup, and free 10000-10002 if we stop it. +docker volume create cipp-ng_azurite-data | Out-Null +try { + & (Join-Path $PSScriptRoot 'Clear-CippAzuriteCacheIfNeeded.ps1') +} catch { + Write-Warning "Azurite cache trim skipped; continuing startup. $($_.Exception.Message)" +} + # Free host frontend port by stopping leftover Next.js/node processes from prior runs Get-Process node -ErrorAction SilentlyContinue | Stop-Process -ErrorAction SilentlyContinue @@ -51,7 +63,6 @@ if ($blocked.Count -gt 0) { } Write-Host (" Ports free: {0}" -f ($requiredPorts -join ', ')) -ForegroundColor Green -$RepoRoot = (Get-Item $PSScriptRoot).Parent.Parent.FullName $frontendPath = Join-Path -Path $RepoRoot -ChildPath 'frontend' $dockerpath = Join-Path -Path $RepoRoot -ChildPath 'build' $frontendCommand = 'try { yarn install --network-timeout 500000; yarn run dev } catch { Write-Error $_.Exception.Message } finally { Read-Host "Press Enter to exit" }' @@ -60,7 +71,6 @@ $dockerCommand = 'try { ./tools/build-dev-modules.ps1; docker compose -f docker- $dockerEncoded = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($dockerCommand)) $watcherCommand = 'try { ./tools/Watch-Cipp-Dev-Modules.ps1 -SkipInitialBuild } catch { Write-Error $_.Exception.Message } finally { Read-Host "Press Enter to exit" }' $watcherEncoded = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($watcherCommand)) -docker volume create cipp-ng_azurite-data wt --title CIPP-Docker -d $dockerpath pwsh -EncodedCommand $dockerEncoded`; new-tab --title 'CIPP Modules' -d $dockerpath pwsh -EncodedCommand $watcherEncoded`; new-tab --title 'CIPP Frontend' -d $frontendPath pwsh -EncodedCommand $frontendEncoded Write-Host "`n API + Frontend: http://localhost:5196" -ForegroundColor Green From bacb822ee2d96bd749cb5cf41b9263cbe4ad7e9d Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Tue, 18 Aug 2026 12:50:25 +0200 Subject: [PATCH 128/226] fixes #47 --- backend/Config/standards.json | 3 +- .../Standards/Invoke-CIPPStandardNudgeMFA.ps1 | 2 +- frontend/src/data/standards.json | 3 +- .../CippComponents/CippAddUserDrawer.test.jsx | 171 ++++++++++++++++++ 4 files changed, 176 insertions(+), 3 deletions(-) create mode 100644 frontend/tests/components/CippComponents/CippAddUserDrawer.test.jsx diff --git a/backend/Config/standards.json b/backend/Config/standards.json index c0fa5edcce..15af9e3788 100644 --- a/backend/Config/standards.json +++ b/backend/Config/standards.json @@ -1387,7 +1387,8 @@ "name": "standards.NudgeMFA.state", "options": [ { "label": "Enabled", "value": "enabled" }, - { "label": "Disabled", "value": "disabled" } + { "label": "Disabled", "value": "disabled" }, + { "label": "Microsoft managed", "value": "default" } ] }, { diff --git a/backend/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardNudgeMFA.ps1 b/backend/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardNudgeMFA.ps1 index afac49b65a..4b15c56be6 100644 --- a/backend/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardNudgeMFA.ps1 +++ b/backend/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardNudgeMFA.ps1 @@ -17,7 +17,7 @@ function Invoke-CIPPStandardNudgeMFA { EXECUTIVETEXT Prompts employees to set up multi-factor authentication during login, gradually improving the organization's security posture by encouraging adoption of stronger authentication methods. This helps achieve better security compliance without forcing immediate mandatory changes. ADDEDCOMPONENT - {"type":"autoComplete","multiple":false,"creatable":false,"label":"Registration campaign state","name":"standards.NudgeMFA.state","options":[{"label":"Enabled","value":"enabled"},{"label":"Disabled","value":"disabled"}]} + {"type":"autoComplete","multiple":false,"creatable":false,"label":"Registration campaign state","name":"standards.NudgeMFA.state","options":[{"label":"Enabled","value":"enabled"},{"label":"Disabled","value":"disabled"},{"label":"Microsoft managed","value":"default"}]} {"type":"autoComplete","multiple":false,"creatable":false,"required":false,"label":"Authentication method to nudge users to register (default is Microsoft Authenticator)","name":"standards.NudgeMFA.targetedAuthenticationMethod","options":[{"label":"Microsoft Authenticator","value":"microsoftAuthenticator"},{"label":"Passkey (FIDO2)","value":"fido2"}],"condition":{"field":"standards.NudgeMFA.state","compareType":"valueEq","compareValue":"enabled"}} {"type":"number","name":"standards.NudgeMFA.snoozeDurationInDays","label":"Number of days to allow users to skip registering Authenticator (0-14, default is 1)","defaultValue":1,"validators":{"min":{"value":0,"message":"Minimum value is 0"},"max":{"value":14,"message":"Maximum value is 14"}}} {"type":"switch","name":"standards.NudgeMFA.enforceRegistrationAfterAllowedSnoozes","label":"Limited number of snoozes (require registration after 3 snoozes)","defaultValue":true,"condition":{"field":"standards.NudgeMFA.state","compareType":"valueEq","compareValue":"enabled"}} diff --git a/frontend/src/data/standards.json b/frontend/src/data/standards.json index c73247c6cd..3904d85c81 100644 --- a/frontend/src/data/standards.json +++ b/frontend/src/data/standards.json @@ -1387,7 +1387,8 @@ "name": "standards.NudgeMFA.state", "options": [ { "label": "Enabled", "value": "enabled" }, - { "label": "Disabled", "value": "disabled" } + { "label": "Disabled", "value": "disabled" }, + { "label": "Microsoft managed", "value": "default" } ] }, { diff --git a/frontend/tests/components/CippComponents/CippAddUserDrawer.test.jsx b/frontend/tests/components/CippComponents/CippAddUserDrawer.test.jsx new file mode 100644 index 0000000000..9a899ccb52 --- /dev/null +++ b/frontend/tests/components/CippComponents/CippAddUserDrawer.test.jsx @@ -0,0 +1,171 @@ +import React, { useReducer } from 'react' +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { screen, waitFor, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { renderWithProviders, settingsWith } from '../../test-utils' +import { CippAddUserDrawer } from '../../../src/components/CippComponents/CippAddUserDrawer' +import { ApiGetCall, ApiPostCall, ApiGetCallWithPagination } from '../../../src/api/ApiCall' + +vi.mock('../../../src/api/ApiCall', () => ({ + ApiGetCall: vi.fn(), + ApiPostCall: vi.fn(), + ApiGetCallWithPagination: vi.fn(), +})) + +// The user pickers and the license selector pull in the data-table stack and the 2.2 MB license +// dataset; none of them take part in the create-another-user flow, so they are stubbed. The +// domain selector stays real - its auto-preselect is central to the bug under test. +vi.mock('../../../src/components/CippComponents/CippFormUserSelector', () => ({ + CippFormUserSelector: () =>
    , + default: () =>
    , +})) +vi.mock('../../../src/components/CippComponents/CippFormLicenseSelector', () => ({ + CippFormLicenseSelector: () =>
    , + default: () =>
    , +})) +vi.mock('../../../src/components/CippComponents/CippApiResults', () => ({ + CippApiResults: () => null, +})) + +const idleGet = { isSuccess: false, isFetching: false, isError: false, data: undefined, refetch: vi.fn() } +const okGet = (data) => ({ isSuccess: true, isFetching: false, isError: false, data, refetch: vi.fn() }) + +// Mutable state backing the ApiPostCall mock: flipping it and re-rendering imitates the +// react-query mutation lifecycle (idle -> pending -> success) the drawer sees in production. +let postState +let mutateSpy + +function mockApis() { + ApiGetCall.mockImplementation(({ url }) => { + if (url.startsWith('/api/ListNewUserDefaults')) return okGet([]) + if (url.startsWith('/api/ListExtensionsConfig')) return okGet({}) + if (url.startsWith('/api/ListGroups')) return okGet([]) + if (url.startsWith('/api/ListCustomDataMappings')) return okGet({ Results: [] }) + if (url.startsWith('/api/ListUserGroups')) return okGet([]) + return idleGet + }) + ApiGetCallWithPagination.mockImplementation(({ url }) => { + if (url === '/api/ListGraphRequest') { + return { + isSuccess: true, + isFetching: false, + isError: false, + data: { + pages: [ + { + Results: [ + { id: 'testdomain.com', isDefault: true, isInitial: false, isVerified: true }, + { id: 'other.com', isDefault: false, isInitial: false, isVerified: true }, + ], + }, + ], + }, + fetchNextPage: vi.fn(), + refetch: vi.fn(), + } + } + return { ...idleGet, fetchNextPage: vi.fn() } + }) + ApiPostCall.mockImplementation(() => ({ ...postState, mutate: mutateSpy })) +} + +// Buttons that force a re-render after mutating postState stand in for react-query pushing new +// mutation state into the drawer. +function Harness() { + const [, force] = useReducer((x) => x + 1, 0) + return ( + <> + + + + + ) +} + +const getDomainInput = () => + screen.getByLabelText(/Primary Domain name/i, { selector: 'input' }) + +const fillRequiredFields = async (user, { displayName, username }) => { + const displayNameInput = screen.getByLabelText(/Display Name/i, { selector: 'input' }) + await user.clear(displayNameInput) + await user.type(displayNameInput, displayName) + const usernameInput = screen.getByLabelText(/^Username/i, { selector: 'input' }) + await user.clear(usernameInput) + await user.type(usernameInput, username) +} + +describe('CippAddUserDrawer - create another user without a page refresh (issue #309)', () => { + beforeEach(() => { + vi.clearAllMocks() + postState = { isPending: false, isSuccess: false, isError: false } + mutateSpy = vi.fn() + mockApis() + }) + + it('re-enables the Create button for a second user after the first succeeds', async () => { + const user = userEvent.setup() + renderWithProviders(, { + settings: settingsWith({ usageLocation: { value: 'US', label: 'United States' } }), + }) + + await user.click(screen.getByRole('button', { name: 'Add User' })) + + // First user: the domain selector auto-picks the tenant default domain + await waitFor(() => { + expect(getDomainInput()).toHaveValue('testdomain.com') + }) + await fillRequiredFields(user, { displayName: 'First User', username: 'first.user' }) + + const createButton = screen.getByRole('button', { name: 'Create User' }) + await waitFor(() => { + expect(createButton).toBeEnabled() + }) + await user.click(createButton) + expect(mutateSpy).toHaveBeenCalledTimes(1) + expect(mutateSpy.mock.calls[0][0].data).toMatchObject({ + displayName: 'First User', + username: 'first.user', + primDomain: { value: 'testdomain.com' }, + }) + + // Simulate the mutation lifecycle so isSuccess transitions like it does in production + await user.click(screen.getByRole('button', { name: 'flip-pending' })) + await user.click(screen.getByRole('button', { name: 'flip-success' })) + + // The drawer resets the form for the next user + const anotherButton = await screen.findByRole('button', { name: 'Create Another User' }) + + // Second user: complete all required fields again, exactly as the issue describes + await fillRequiredFields(user, { displayName: 'Second User', username: 'second.user' }) + + // Diagnostic: what does the domain field show after the reset? + // eslint-disable-next-line no-console + console.log('domain input after reset:', JSON.stringify(getDomainInput().value)) + + await waitFor(() => { + expect(anotherButton).toBeEnabled() + }) + await user.click(anotherButton) + expect(mutateSpy).toHaveBeenCalledTimes(2) + expect(mutateSpy.mock.calls[1][0].data).toMatchObject({ + displayName: 'Second User', + username: 'second.user', + primDomain: { value: 'testdomain.com' }, + }) + }) +}) From b83822e9e870e131f490907a3fa4ecfcc00caeb5 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Tue, 18 Aug 2026 18:52:54 +0800 Subject: [PATCH 129/226] fix(pwpush): handle string account ids in links MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace `New-Push` with a direct `Invoke-PasswordPusherAPI` call in `New-PwPushLink` so authenticated pushes no longer fail when pwpush returns string account IDs (for example `acct_...`). The request payload is now built in the API’s expected `password` schema (`expire_after_days`, `expire_after_views`, `deletable_by_viewer`, `passphrase`, optional `account_id`), keeps range guards with warnings for invalid saved settings, and derives the returned link from `json_url`/`html_url` while preserving retrieval-step behavior. --- .../Public/PwPush/New-PwPushLink.ps1 | 47 +++++++++++++------ 1 file changed, 32 insertions(+), 15 deletions(-) diff --git a/backend/Modules/CippExtensions/Public/PwPush/New-PwPushLink.ps1 b/backend/Modules/CippExtensions/Public/PwPush/New-PwPushLink.ps1 index e93ed460ca..ba0c02975e 100644 --- a/backend/Modules/CippExtensions/Public/PwPush/New-PwPushLink.ps1 +++ b/backend/Modules/CippExtensions/Public/PwPush/New-PwPushLink.ps1 @@ -37,16 +37,17 @@ function New-PwPushLink { # Proceed with creating the PwPush link try { Set-PwPushConfig -Configuration $Configuration -FullConfiguration $ParsedConfig - $PushParams = @{ - Payload = $Payload + $PasswordValues = @{ + kind = 'text' + payload = [string]$Payload } - # New-Push validates ExpireAfterDays as 1-90 and ExpireAfterViews as 1-100 at bind - # time; an out-of-range saved value would throw here and downgrade every caller to - # plain text passwords, so drop the setting and warn instead. + # The API accepts 1-90 days and 1-100 views; an out-of-range saved value would fail + # the whole push and downgrade every caller to plain text passwords, so drop the + # setting and warn instead. $ExpireAfterDays = $Configuration.ExpireAfterDays -as [int] if ($ExpireAfterDays) { if ($ExpireAfterDays -ge 1 -and $ExpireAfterDays -le 90) { - $PushParams.ExpireAfterDays = $ExpireAfterDays + $PasswordValues.expire_after_days = $ExpireAfterDays } else { Write-LogMessage -API PwPush -Message "Ignoring ExpireAfterDays '$($Configuration.ExpireAfterDays)': PWPush accepts 1 to 90 days" -Sev 'Warning' } @@ -54,25 +55,41 @@ function New-PwPushLink { $ExpireAfterViews = $Configuration.ExpireAfterViews -as [int] if ($ExpireAfterViews) { if ($ExpireAfterViews -ge 1 -and $ExpireAfterViews -le 100) { - $PushParams.ExpireAfterViews = $ExpireAfterViews + $PasswordValues.expire_after_views = $ExpireAfterViews } else { Write-LogMessage -API PwPush -Message "Ignoring ExpireAfterViews '$($Configuration.ExpireAfterViews)': PWPush accepts 1 to 100 views" -Sev 'Warning' } } - if ($Configuration.DeletableByViewer) { $PushParams.DeletableByViewer = $Configuration.DeletableByViewer } - # New-Push rejects an account id at bind time when no Authorization header is set, so - # a stale or placeholder selection saved with bearer auth off must not be passed on. + if ($Configuration.DeletableByViewer) { $PasswordValues.deletable_by_viewer = $true } + if (![string]::IsNullOrEmpty($Configuration.DefaultPassphrase)) { $PasswordValues.passphrase = $Configuration.DefaultPassphrase } + $PushBody = @{ password = $PasswordValues } + # An account id is only valid on an authenticated session, so a stale or placeholder + # selection saved while bearer auth is off must not be passed on. if ($Configuration.UseBearerAuth -eq $true -and -not [string]::IsNullOrEmpty($Configuration.AccountId.value)) { - $PushParams.AccountId = $Configuration.AccountId.value + $PushBody.account_id = $Configuration.AccountId.value } - if (![string]::IsNullOrEmpty($Configuration.DefaultPassphrase)) { $PushParams.Passphrase = $Configuration.DefaultPassphrase } if ($PSCmdlet.ShouldProcess('Create a new PwPush link')) { - $Link = New-Push @PushParams + # POST through the module's internal API helper so its auth headers, user agent + # and base URL are reused, but skip New-Push: its PasswordPush class types + # account_id as [int] while pwpush.com now issues string ids ('acct_...'), so + # the response conversion throws away the link on every authenticated push. + $Response = & (Get-Module PassPushPosh) { + param($Body) + Invoke-PasswordPusherAPI -Endpoint 'p.json' -Method Post -Body $Body -ErrorAction Stop + } $PushBody + $Link = if (![string]::IsNullOrEmpty($Response.json_url)) { + $Response.json_url -replace '\.json$', '' + } elseif (![string]::IsNullOrEmpty($Response.html_url)) { + $Response.html_url -replace '/r$', '' + } else { + # Deliberately not including the response: it echoes the pushed payload + throw 'PWPush API response did not contain a link' + } if ($Configuration.RetrievalStep) { - return $Link.LinkRetrievalStep -replace '/r/r', '/r' + return "$Link/r" -replace '/r/r$', '/r' } - return $Link.Link + return $Link } } catch { $LogData = [PSCustomObject]@{ From 8dc8739805ab42191a9c521a16cfc0a4ef0dccf3 Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Tue, 18 Aug 2026 13:05:50 +0200 Subject: [PATCH 130/226] add disabling of alerts and scheduled tasks #304 --- .../CIPPCore/Public/Add-CIPPScheduledTask.ps1 | 8 +++ .../Start-AuditLogSearchCreation.ps1 | 2 +- .../Start-AuditLogSearchCreationV2.ps1 | 2 +- .../Start-UserTasksOrchestrator.ps1 | 3 +- .../Webhooks/Test-CIPPAuditLogRules.ps1 | 3 ++ .../Administration/Alerts/Invoke-AddAlert.ps1 | 7 +++ .../Alerts/Invoke-ExecToggleAlert.ps1 | 54 +++++++++++++++++++ .../Alerts/Invoke-ListAlertsQueue.ps1 | 2 + .../Webhooks/Test-CIPPAuditLogRules.Tests.ps1 | 46 ++++++++++++++++ .../alert-configuration/index.js | 42 ++++++++++++++- 10 files changed, 165 insertions(+), 4 deletions(-) create mode 100644 backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Alerts/Invoke-ExecToggleAlert.ps1 diff --git a/backend/Modules/CIPPCore/Public/Add-CIPPScheduledTask.ps1 b/backend/Modules/CIPPCore/Public/Add-CIPPScheduledTask.ps1 index 89a9ffc0cd..1eaaa5f840 100644 --- a/backend/Modules/CIPPCore/Public/Add-CIPPScheduledTask.ps1 +++ b/backend/Modules/CIPPCore/Public/Add-CIPPScheduledTask.ps1 @@ -250,6 +250,14 @@ function Add-CIPPScheduledTask { $entity['Tag'] = [string]$task.Tag } + if ($Task.RowKey) { + # Editing replaces the entity, so carry the disabled state over to keep a disabled task disabled + $ExistingEntity = Get-CIPPAzDataTableEntity @Table -Filter "PartitionKey eq 'ScheduledTask' and RowKey eq '$RowKey'" -Property RowKey, Disabled + if ($ExistingEntity.Disabled -eq $true) { + $entity['Disabled'] = $true + } + } + # Always store DesiredStartTime if provided if ($DesiredStartTime) { $entity['DesiredStartTime'] = [string]$DesiredStartTime diff --git a/backend/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-AuditLogSearchCreation.ps1 b/backend/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-AuditLogSearchCreation.ps1 index f368e083ff..656fd5282b 100644 --- a/backend/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-AuditLogSearchCreation.ps1 +++ b/backend/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-AuditLogSearchCreation.ps1 @@ -10,7 +10,7 @@ function Start-AuditLogSearchCreation { param() try { $ConfigTable = Get-CippTable -TableName 'WebhookRules' - $ConfigEntries = Get-CIPPAzDataTableEntity @ConfigTable -Filter "PartitionKey eq 'Webhookv2'" | ForEach-Object { + $ConfigEntries = Get-CIPPAzDataTableEntity @ConfigTable -Filter "PartitionKey eq 'Webhookv2'" | Where-Object { $_.Disabled -ne $true } | ForEach-Object { $ConfigEntry = $_ if (!$ConfigEntry.excludedTenants) { $ConfigEntry | Add-Member -MemberType NoteProperty -Name 'excludedTenants' -Value @() -Force diff --git a/backend/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-AuditLogSearchCreationV2.ps1 b/backend/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-AuditLogSearchCreationV2.ps1 index 70cfedc883..034cde6f58 100644 --- a/backend/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-AuditLogSearchCreationV2.ps1 +++ b/backend/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-AuditLogSearchCreationV2.ps1 @@ -24,7 +24,7 @@ function Start-AuditLogSearchCreationV2 { try { # --- Tenant selection (same source as V1) --- $ConfigTable = Get-CippTable -TableName 'WebhookRules' - $ConfigEntries = Get-CIPPAzDataTableEntity @ConfigTable -Filter "PartitionKey eq 'Webhookv2'" | ForEach-Object { + $ConfigEntries = Get-CIPPAzDataTableEntity @ConfigTable -Filter "PartitionKey eq 'Webhookv2'" | Where-Object { $_.Disabled -ne $true } | ForEach-Object { $ConfigEntry = $_ if (!$ConfigEntry.excludedTenants) { $ConfigEntry | Add-Member -MemberType NoteProperty -Name 'excludedTenants' -Value @() -Force diff --git a/backend/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-UserTasksOrchestrator.ps1 b/backend/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-UserTasksOrchestrator.ps1 index 14c29bc7e4..9ddcfcc9b1 100644 --- a/backend/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-UserTasksOrchestrator.ps1 +++ b/backend/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-UserTasksOrchestrator.ps1 @@ -32,7 +32,8 @@ function Start-UserTasksOrchestrator { # Pending = orchestrator claimed but executor not yet started, Running = actively executing # Pick up: Planned, Failed-Planned, stuck Pending (>1hr - orphaned claim), or stuck Running/Processing (>4hr for large AllTenants tasks) $Filter = "PartitionKey eq 'ScheduledTask' and (TaskState eq 'Planned' or TaskState eq 'Failed - Planned' or (TaskState eq 'Pending' and Timestamp lt datetime'$1HourAgo') or (TaskState eq 'Running' and Timestamp lt datetime'$4HoursAgo') or (TaskState eq 'Processing' and Timestamp lt datetime'$4HoursAgo'))" - $tasks = Get-CIPPAzDataTableEntity @Table -Filter $Filter + # Disabled is filtered client side: an OData comparison excludes rows that lack the property, which is every task created before the flag existed + $tasks = Get-CIPPAzDataTableEntity @Table -Filter $Filter | Where-Object { $_.Disabled -ne $true } } $Batch = [System.Collections.Generic.List[object]]::new() diff --git a/backend/Modules/CIPPCore/Public/Webhooks/Test-CIPPAuditLogRules.ps1 b/backend/Modules/CIPPCore/Public/Webhooks/Test-CIPPAuditLogRules.ps1 index 66ff07f113..935ff56ac8 100644 --- a/backend/Modules/CIPPCore/Public/Webhooks/Test-CIPPAuditLogRules.ps1 +++ b/backend/Modules/CIPPCore/Public/Webhooks/Test-CIPPAuditLogRules.ps1 @@ -224,6 +224,9 @@ function Test-CIPPAuditLogRules { $ConfigEntries = Get-CIPPAzDataTableEntity @ConfigTable $Configuration = @(foreach ($ConfigEntry in $ConfigEntries) { + if ($ConfigEntry.Disabled -eq $true) { + continue + } if ([string]::IsNullOrEmpty($ConfigEntry.Tenants)) { continue } diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Alerts/Invoke-AddAlert.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Alerts/Invoke-AddAlert.ps1 index 31b38f41bc..8afe3d021f 100644 --- a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Alerts/Invoke-AddAlert.ps1 +++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Alerts/Invoke-AddAlert.ps1 @@ -49,6 +49,13 @@ function Invoke-AddAlert { CustomSubject = [string]$Request.Body.CustomSubject } $WebhookTable = Get-CippTable -TableName 'WebhookRules' + if ($Request.Body.RowKey) { + # Editing replaces the entity, so carry the disabled state over to keep a disabled alert disabled + $ExistingAlert = Get-CIPPAzDataTableEntity @WebhookTable -Filter "RowKey eq '$RowKey'" -Property RowKey, Disabled + if ($ExistingAlert.Disabled -eq $true) { + $CompleteObject.Disabled = $true + } + } Add-CIPPAzDataTableEntity @WebhookTable -Entity $CompleteObject -Force $Results = "Added Audit Log Alert for $($Tenants.count) tenants. It may take up to four hours before Microsoft starts delivering these alerts." Write-LogMessage -API 'AddAlert' -message $Results -sev Info -LogData $CompleteObject -headers $Request.Headers diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Alerts/Invoke-ExecToggleAlert.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Alerts/Invoke-ExecToggleAlert.ps1 new file mode 100644 index 0000000000..c6afe8b455 --- /dev/null +++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Alerts/Invoke-ExecToggleAlert.ps1 @@ -0,0 +1,54 @@ +Function Invoke-ExecToggleAlert { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + CIPP.Alert.ReadWrite + .DESCRIPTION + Enables or disables an alert rule without deleting it. Works for both audit log alerts and scheduled alert tasks. + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers + + # Interact with the query or body of the request + $EventType = $Request.Query.EventType ?? $Request.Body.EventType + $ID = $Request.Query.ID ?? $Request.Body.ID + $Disabled = [System.Convert]::ToBoolean($Request.Query.Disabled ?? $Request.Body.Disabled) + + if ($EventType -eq 'Audit log Alert') { + $Table = 'WebhookRules' + } else { + $Table = 'ScheduledTasks' + } + + $Table = Get-CIPPTable -TableName $Table + try { + $Filter = "RowKey eq '{0}'" -f $ID + $Alert = Get-CIPPAzDataTableEntity @Table -Filter $Filter -Property PartitionKey, RowKey + if (!$Alert) { + throw "No alert found with ID $ID" + } + $null = Update-AzDataTableEntity -Force @Table -Entity @{ + PartitionKey = $Alert.PartitionKey + RowKey = $Alert.RowKey + Disabled = [bool]$Disabled + } + $State = $Disabled ? 'disabled' : 'enabled' + $Result = "Successfully $State alert $ID" + Write-LogMessage -headers $Headers -API $APIName -message $Result -Sev 'Info' + $StatusCode = [HttpStatusCode]::OK + } catch { + $ErrorMessage = Get-CippException -Exception $_ + $Result = "Failed to toggle alert $ID. $($ErrorMessage.NormalizedError)" + Write-LogMessage -headers $Headers -API $APIName -message $Result -Sev 'Error' -LogData $ErrorMessage + $StatusCode = [HttpStatusCode]::InternalServerError + } + + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = @{ 'Results' = $Result } + }) +} diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Alerts/Invoke-ListAlertsQueue.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Alerts/Invoke-ListAlertsQueue.ps1 index 35ec64a84b..81f7e6013b 100644 --- a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Alerts/Invoke-ListAlertsQueue.ps1 +++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Alerts/Invoke-ListAlertsQueue.ps1 @@ -36,6 +36,7 @@ function Invoke-ListAlertsQueue { RepeatsEvery = 'When received' AlertComment = $Task.AlertComment CustomSubject = $Task.CustomSubject + Enabled = $Task.Disabled -ne $true RawAlert = @{ Conditions = @($Conditions) Actions = @($($Task.Actions | ConvertFrom-Json -Depth 10 -ErrorAction SilentlyContinue)) @@ -163,6 +164,7 @@ function Invoke-ListAlertsQueue { AlertComment = $Task.AlertComment RawAlert = $Task ScriptName = $ScriptName + Enabled = $Task.Disabled -ne $true } if ($AllowedTenants -notcontains 'AllTenants') { diff --git a/backend/Tests/Webhooks/Test-CIPPAuditLogRules.Tests.ps1 b/backend/Tests/Webhooks/Test-CIPPAuditLogRules.Tests.ps1 index e8670f782f..05382be2e0 100644 --- a/backend/Tests/Webhooks/Test-CIPPAuditLogRules.Tests.ps1 +++ b/backend/Tests/Webhooks/Test-CIPPAuditLogRules.Tests.ps1 @@ -516,4 +516,50 @@ Describe 'Test-CIPPAuditLogRules record shaping' { Should -Invoke Invoke-CippWebhookProcessing -Times 0 } } + + Context 'a disabled rule' { + BeforeEach { + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { + param($TableName, $Context, $Filter, $Property, $First) + switch ($TableName) { + 'WebhookRules' { + [pscustomobject]@{ + PartitionKey = 'WebhookRules' + RowKey = 'rule-1' + Tenants = (@('AllTenants') | ConvertTo-Json -Compress) + excludedTenants = $null + Conditions = (@( + @{ + Property = @{ label = 'Operation' } + Operator = @{ label = 'eq' } + Input = @{ value = 'Set-Mailbox' } + } + ) | ConvertTo-Json -Compress -Depth 5) + Actions = (@('generatemail') | ConvertTo-Json -Compress) + Type = 'Audit' + AlertComment = 'test comment' + CustomSubject = '' + Disabled = $true + } + } + 'cacheauditloglookups' { + @( + New-LookupRow 'users' + New-LookupRow 'groups' + New-LookupRow 'devices' + New-LookupRow 'servicePrincipals' + ) + } + 'Config' { [pscustomobject]@{ Value = 'cipp.contoso.com' } } + default { @() } + } + } + } + + It 'is skipped even when its conditions would match' { + $result = Test-CIPPAuditLogRules -TenantFilter 'contoso.com' -Rows @(New-AuditRow) + $result.MatchedLogs | Should -Be 0 + Should -Invoke Invoke-CippWebhookProcessing -Times 0 + } + } } diff --git a/frontend/src/pages/tenant/administration/alert-configuration/index.js b/frontend/src/pages/tenant/administration/alert-configuration/index.js index 6d6fdc434f..de7b74c343 100644 --- a/frontend/src/pages/tenant/administration/alert-configuration/index.js +++ b/frontend/src/pages/tenant/administration/alert-configuration/index.js @@ -4,7 +4,15 @@ import { Layout as DashboardLayout } from '../../../../layouts/index.js' // had import { TabbedLayout } from '../../../../layouts/TabbedLayout' import tabOptions from './tabOptions.json' import Link from 'next/link' -import { CopyAll, Delete, Edit, NotificationAdd, Visibility } from '@mui/icons-material' +import { + CopyAll, + Delete, + Edit, + NotificationAdd, + ToggleOff, + ToggleOn, + Visibility, +} from '@mui/icons-material' const Page = () => { const pageTitle = 'Alerts' @@ -29,6 +37,37 @@ const Page = () => { color: 'success', target: '_self', }, + { + label: 'Enable Alert', + type: 'POST', + url: '/api/ExecToggleAlert', + data: { + ID: 'RowKey', + EventType: 'EventType', + Disabled: '!false', + }, + icon: , + relatedQueryKeys: 'ListAlertsQueue', + condition: (row) => row.Enabled !== true, + confirmText: 'Are you sure you want to enable this alert?', + multiPost: false, + }, + { + label: 'Disable Alert', + type: 'POST', + url: '/api/ExecToggleAlert', + data: { + ID: 'RowKey', + EventType: 'EventType', + Disabled: '!true', + }, + icon: , + relatedQueryKeys: 'ListAlertsQueue', + condition: (row) => row.Enabled === true, + confirmText: + 'Are you sure you want to disable this alert? It will not run until you enable it again.', + multiPost: false, + }, { label: 'Delete Alert', type: 'POST', @@ -62,6 +101,7 @@ const Page = () => { simpleColumns={[ 'Tenants', 'EventType', + 'Enabled', 'Conditions', 'RepeatsEvery', 'Actions', From 078d6718cf3a9bec56280685652395823e5c783c Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Tue, 18 Aug 2026 13:10:53 +0200 Subject: [PATCH 131/226] add form key for resets --- .../components/CippComponents/CippAddUserDrawer.jsx | 7 +++++++ .../CippComponents/CippAddUserDrawer.test.jsx | 10 ++++++---- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/frontend/src/components/CippComponents/CippAddUserDrawer.jsx b/frontend/src/components/CippComponents/CippAddUserDrawer.jsx index 297fa91dd5..49594f4ddc 100644 --- a/frontend/src/components/CippComponents/CippAddUserDrawer.jsx +++ b/frontend/src/components/CippComponents/CippAddUserDrawer.jsx @@ -15,6 +15,11 @@ export const CippAddUserDrawer = ({ PermissionButton = Button, }) => { const [drawerVisible, setDrawerVisible] = useState(false); + // Bumped after each successful create. The form fields only auto-populate on mount + // (domain selector's auto-select of the default domain, template auto-apply), so an + // in-place reset leaves the required primDomain empty with no visible error and the + // Create button stays disabled. Remounting restores the same state as a fresh open. + const [formResetKey, setFormResetKey] = useState(0); const userSettingsDefaults = useSettings(); const formControl = useForm({ @@ -76,6 +81,7 @@ export const CippAddUserDrawer = ({ } formControl.reset(resetValues); + setFormResetKey((key) => key + 1); } }, [createUser.isSuccess]); @@ -166,6 +172,7 @@ export const CippAddUserDrawer = ({ > { + expect(getDomainInput()).toHaveValue('testdomain.com') + }) + // Second user: complete all required fields again, exactly as the issue describes await fillRequiredFields(user, { displayName: 'Second User', username: 'second.user' }) - // Diagnostic: what does the domain field show after the reset? - // eslint-disable-next-line no-console - console.log('domain input after reset:', JSON.stringify(getDomainInput().value)) - await waitFor(() => { expect(anotherButton).toBeEnabled() }) From bdf3b56f5bc78b90f6b839a1b82b15cb34547413 Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Tue, 18 Aug 2026 13:22:51 +0200 Subject: [PATCH 132/226] allow removal via selector --- .../QuarantineRequestAlert.json | 19 +++++-- backend/Config/standards.json | 25 +++++++-- ...IPPBaselineQuarantineRequestAlertState.ps1 | 16 +++++- ...oke-CIPPBaselineQuarantineRequestAlert.ps1 | 11 ++-- ...oke-CIPPStandardQuarantineRequestAlert.ps1 | 51 +++++++++++++++---- frontend/src/data/standards.json | 25 +++++++-- 6 files changed, 121 insertions(+), 26 deletions(-) diff --git a/backend/Config/BaselineStandards/Defender Standards/QuarantineRequestAlert.json b/backend/Config/BaselineStandards/Defender Standards/QuarantineRequestAlert.json index 554b1726a1..fd5d6def28 100644 --- a/backend/Config/BaselineStandards/Defender Standards/QuarantineRequestAlert.json +++ b/backend/Config/BaselineStandards/Defender Standards/QuarantineRequestAlert.json @@ -4,12 +4,12 @@ "cat": "Defender Standards", "tag": [], "impact": "Low Impact", - "helpText": "Sets a e-mail address to alert when a User requests to release a quarantined message. With \"Allow extra addresses\" on, additional recipients are accepted and preserved; with it off, the configured address is enforced as the only recipient.", + "helpText": "Sets a e-mail address to alert when a User requests to release a quarantined message. With \"Allow extra addresses\" on, additional recipients are accepted and preserved; with it off, the configured address is enforced as the only recipient. Set the alert state to Removed to delete the alert rule CIPP created from the tenant.", "executiveText": "Notifies IT administrators when employees request to release emails that were quarantined for security reasons, enabling oversight of potentially dangerous messages. This helps ensure that legitimate emails are released while maintaining security controls over suspicious content.", - "docsDescription": "Sets a e-mail address to alert when a User requests to release a quarantined message. This is useful for monitoring and ensuring that the correct messages are released.", + "docsDescription": "Sets a e-mail address to alert when a User requests to release a quarantined message. This is useful for monitoring and ensuring that the correct messages are released. Setting the alert state to Removed deletes the alert rule CIPP created from the tenant, for when the alert is no longer wanted.", "impactColour": "info", "addedDate": "2024-07-15", - "powershellEquivalent": "New-ProtectionAlert and Set-ProtectionAlert", + "powershellEquivalent": "New-ProtectionAlert, Set-ProtectionAlert and Remove-ProtectionAlert", "recommendedBy": [], "requiredCapabilities": [ "EXCHANGE_S_STANDARD", @@ -21,9 +21,21 @@ "secureScoreImpact": 0, "compare": "subset", "variables": { + "State": { + "type": "select", + "multiple": false, + "label": "Alert state", + "helperText": "Enabled creates or updates the alert. Removed deletes the alert rule CIPP created; the other settings are ignored.", + "options": [ + { "label": "Enabled", "value": "enabled" }, + { "label": "Removed (delete the alert)", "value": "removed" } + ], + "default": "enabled" + }, "NotifyUser": { "type": "textField", "label": "E-mail to receive the alert", + "helperText": "Ignored when the alert state is Removed.", "required": true }, "AllowExtraAddresses": { @@ -45,6 +57,7 @@ }, "remediate": { "executor": "QuarantineRequestAlert", + "state": "%State%", "notifyUser": "%NotifyUser%", "allowExtraAddresses": "%AllowExtraAddresses%" }, diff --git a/backend/Config/standards.json b/backend/Config/standards.json index 15af9e3788..b63ed277cc 100644 --- a/backend/Config/standards.json +++ b/backend/Config/standards.json @@ -3430,21 +3430,38 @@ "name": "standards.QuarantineRequestAlert", "cat": "Defender Standards", "tag": [], - "helpText": "Sets a e-mail address to alert when a User requests to release a quarantined message.", - "docsDescription": "Sets a e-mail address to alert when a User requests to release a quarantined message. This is useful for monitoring and ensuring that the correct messages are released.", + "helpText": "Sets a e-mail address to alert when a User requests to release a quarantined message. Set the alert state to Removed to delete the alert rule CIPP created from the tenant.", + "docsDescription": "Sets a e-mail address to alert when a User requests to release a quarantined message. This is useful for monitoring and ensuring that the correct messages are released. Setting the alert state to Removed deletes the alert rule CIPP created from the tenant, for when the alert is no longer wanted.", "executiveText": "Notifies IT administrators when employees request to release emails that were quarantined for security reasons, enabling oversight of potentially dangerous messages. This helps ensure that legitimate emails are released while maintaining security controls over suspicious content.", "addedComponent": [ + { + "type": "autoComplete", + "multiple": false, + "creatable": false, + "required": false, + "label": "Alert state (blank or Enabled creates the alert, Removed deletes it)", + "name": "standards.QuarantineRequestAlert.state", + "options": [ + { "label": "Enabled", "value": "enabled" }, + { "label": "Removed", "value": "removed" } + ] + }, { "type": "textField", "name": "standards.QuarantineRequestAlert.NotifyUser", - "label": "E-mail to receive the alert" + "label": "E-mail to receive the alert", + "condition": { + "field": "standards.QuarantineRequestAlert.state", + "compareType": "isNot", + "compareValue": { "label": "Removed", "value": "removed" } + } } ], "label": "Quarantine Release Request Alert", "impact": "Low Impact", "impactColour": "info", "addedDate": "2024-07-15", - "powershellEquivalent": "New-ProtectionAlert and Set-ProtectionAlert", + "powershellEquivalent": "New-ProtectionAlert, Set-ProtectionAlert and Remove-ProtectionAlert", "recommendedBy": [], "requiredCapabilities": [ "EXCHANGE_S_STANDARD", diff --git a/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineQuarantineRequestAlertState.ps1 b/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineQuarantineRequestAlertState.ps1 index f41e2d37ad..b3ea82d116 100644 --- a/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineQuarantineRequestAlertState.ps1 +++ b/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineQuarantineRequestAlertState.ps1 @@ -1,7 +1,7 @@ function Get-CIPPBaselineQuarantineRequestAlertState { <# .SYNOPSIS - Prepare hook for QuarantineRequestAlert, in either of its two modes. + Prepare hook for QuarantineRequestAlert, in any of its three modes. .DESCRIPTION The 'Allow extra addresses' switch decides what correct means, so it decides the shape of the comparison too: @@ -12,9 +12,13 @@ function Get-CIPPBaselineQuarantineRequestAlertState { off - the notify list must be exactly the configured address. Graded as the list itself, so the drift row names the recipients that should not be there. + The 'Removed' state inverts the whole standard: the desired state is that the alert + does not exist, so presence is the drift and absence is compliant. The notify settings + play no part in that grading. + Absence of the alert is DRIFT, not No Data: the classic standard treated a missing alert as incorrect and remediation creates it. Only an ExoProtectionAlert cache that - has never been collected is genuinely unknown. + has never been collected is genuinely unknown - in every mode, including Removed. .FUNCTIONALITY Internal #> @@ -34,6 +38,14 @@ function Get-CIPPBaselineQuarantineRequestAlertState { } $Alert = @($Alerts | Where-Object { $_.Name -eq $PolicyName }) | Select-Object -First 1 + + if ("$($Item.Variables.State)" -eq 'removed') { + return @{ + Expected = [PSCustomObject]@{ AlertPresent = $false } + Current = [PSCustomObject]@{ AlertPresent = [bool]$Alert } + } + } + $Recipients = @(@($Alert.NotifyUser) | Where-Object { $_ }) if ($AllowExtra) { diff --git a/backend/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineQuarantineRequestAlert.ps1 b/backend/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineQuarantineRequestAlert.ps1 index 8deed5164e..dcf5893796 100644 --- a/backend/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineQuarantineRequestAlert.ps1 +++ b/backend/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineQuarantineRequestAlert.ps1 @@ -2,7 +2,7 @@ function Invoke-CIPPBaselineQuarantineRequestAlert { <# .SYNOPSIS QuarantineRequestAlert executor: creates or updates the quarantine release-request - alert without discarding recipients it did not add. + alert without discarding recipients it did not add, or removes the alert entirely. .DESCRIPTION Needs its own executor because the recipient list it writes depends on the list already there, and a rendered ExoRequest spec is fixed before it ever sees the tenant. @@ -12,12 +12,17 @@ function Invoke-CIPPBaselineQuarantineRequestAlert { their own address by hand keeps it. Without it, the configured address is the whole list and anything else is removed - the classic standard's behaviour. + With state 'removed' the desired state is that the alert does not exist: the alert is + deleted when present and left alone when it already is not, and the notify settings + are ignored. + The existing list is read LIVE rather than from cache. A cached list can be hours old, and merging into a stale one would silently drop a recipient added since the last - collection, which is precisely the loss the merge exists to prevent. + collection, which is precisely the loss the merge exists to prevent. Removal shares + the read: deleting is only skipped when the alert is verifiably absent. Create-vs-update is decided the same way: the alert is looked up by name, and only - created when it genuinely is not there. Both cmdlets are Security & Compliance only. + created when it genuinely is not there. All three cmdlets are Security & Compliance only. .FUNCTIONALITY Internal #> diff --git a/backend/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardQuarantineRequestAlert.ps1 b/backend/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardQuarantineRequestAlert.ps1 index d1d0161111..144141a671 100644 --- a/backend/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardQuarantineRequestAlert.ps1 +++ b/backend/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardQuarantineRequestAlert.ps1 @@ -7,8 +7,8 @@ function Invoke-CIPPStandardQuarantineRequestAlert { .SYNOPSIS (Label) Quarantine Release Request Alert .DESCRIPTION - (Helptext) Sets a e-mail address to alert when a User requests to release a quarantined message. - (DocsDescription) Sets a e-mail address to alert when a User requests to release a quarantined message. This is useful for monitoring and ensuring that the correct messages are released. + (Helptext) Sets a e-mail address to alert when a User requests to release a quarantined message. Set the alert state to Removed to delete the alert rule CIPP created from the tenant. + (DocsDescription) Sets a e-mail address to alert when a User requests to release a quarantined message. This is useful for monitoring and ensuring that the correct messages are released. Setting the alert state to Removed deletes the alert rule CIPP created from the tenant, for when the alert is no longer wanted. .NOTES CAT Defender Standards @@ -16,13 +16,14 @@ function Invoke-CIPPStandardQuarantineRequestAlert { EXECUTIVETEXT Notifies IT administrators when employees request to release emails that were quarantined for security reasons, enabling oversight of potentially dangerous messages. This helps ensure that legitimate emails are released while maintaining security controls over suspicious content. ADDEDCOMPONENT - {"type":"textField","name":"standards.QuarantineRequestAlert.NotifyUser","label":"E-mail to receive the alert"} + {"type":"autoComplete","multiple":false,"creatable":false,"required":false,"label":"Alert state (blank or Enabled creates the alert, Removed deletes it)","name":"standards.QuarantineRequestAlert.state","options":[{"label":"Enabled","value":"enabled"},{"label":"Removed","value":"removed"}]} + {"type":"textField","name":"standards.QuarantineRequestAlert.NotifyUser","label":"E-mail to receive the alert","condition":{"field":"standards.QuarantineRequestAlert.state","compareType":"isNot","compareValue":{"label":"Removed","value":"removed"}}} IMPACT Low Impact ADDEDDATE 2024-07-15 POWERSHELLEQUIVALENT - New-ProtectionAlert and Set-ProtectionAlert + New-ProtectionAlert, Set-ProtectionAlert and Remove-ProtectionAlert RECOMMENDEDBY REQUIREDCAPABILITIES "EXCHANGE_S_STANDARD" @@ -44,6 +45,16 @@ function Invoke-CIPPStandardQuarantineRequestAlert { } #we're done. $PolicyName = 'CIPP User requested to release a quarantined message' + # Templates saved before the state selector existed carry no state value: treat those as + # enabled, the only behaviour that existed at the time. + $State = $Settings.state.value ?? $Settings.state + if ([string]::IsNullOrWhiteSpace($State)) { $State = 'enabled' } + + if ($State -ne 'removed' -and [string]::IsNullOrWhiteSpace($Settings.NotifyUser) -and ($Settings.remediate -eq $true -or $Settings.alert -eq $true)) { + Write-LogMessage -API 'Standards' -Tenant $Tenant -Message 'QuarantineRequestAlert: NotifyUser is required when the alert state is Enabled' -sev Error + return + } + try { $CurrentState = New-ExoRequest -TenantId $Tenant -cmdlet 'Get-ProtectionAlert' -Compliance | Where-Object { $_.Name -eq $PolicyName } } catch { @@ -52,11 +63,27 @@ function Invoke-CIPPStandardQuarantineRequestAlert { return } - $StateIsCorrect = ($CurrentState.NotifyUser -contains $Settings.NotifyUser) + $StateIsCorrect = if ($State -eq 'removed') { + !$CurrentState + } else { + ($CurrentState.NotifyUser -contains $Settings.NotifyUser) + } if ($Settings.remediate -eq $true) { if ($StateIsCorrect -eq $true) { - Write-LogMessage -API 'Standards' -Tenant $Tenant -Message 'Quarantine Request Alert is already configured correctly.' -sev Info + if ($State -eq 'removed') { + Write-LogMessage -API 'Standards' -Tenant $Tenant -Message 'Quarantine Request Alert is already removed.' -sev Info + } else { + Write-LogMessage -API 'Standards' -Tenant $Tenant -Message 'Quarantine Request Alert is already configured correctly.' -sev Info + } + } elseif ($State -eq 'removed') { + try { + New-ExoRequest -TenantId $Tenant -cmdlet 'Remove-ProtectionAlert' -Compliance -cmdParams @{ Identity = $PolicyName } -UseSystemMailbox $true + Write-LogMessage -API 'Standards' -Tenant $Tenant -Message 'Successfully removed Quarantine Request Alert' -sev Info + } catch { + $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message + Write-LogMessage -API 'Standards' -Tenant $Tenant -Message "Failed to remove Quarantine Request Alert. Error: $ErrorMessage" -sev Error + } } else { $cmdParams = @{ 'NotifyUser' = $Settings.NotifyUser @@ -92,9 +119,13 @@ function Invoke-CIPPStandardQuarantineRequestAlert { if ($Settings.alert -eq $true) { if ($StateIsCorrect -eq $true) { - Write-LogMessage -API 'Standards' -Tenant $Tenant -Message 'Quarantine Request Alert is enabled' -sev Info + if ($State -eq 'removed') { + Write-LogMessage -API 'Standards' -Tenant $Tenant -Message 'Quarantine Request Alert is not present' -sev Info + } else { + Write-LogMessage -API 'Standards' -Tenant $Tenant -Message 'Quarantine Request Alert is enabled' -sev Info + } } else { - $Message = 'Quarantine Request Alert is not enabled.' + $Message = if ($State -eq 'removed') { 'Quarantine Request Alert is still present but should be removed.' } else { 'Quarantine Request Alert is not enabled.' } Write-StandardsAlert -message $Message -object $CurrentState -tenant $Tenant -standardName 'QuarantineRequestAlert' -standardId $Settings.standardId Write-LogMessage -API 'Standards' -Tenant $Tenant -Message $Message -sev Info } @@ -104,10 +135,10 @@ function Invoke-CIPPStandardQuarantineRequestAlert { Add-CIPPBPAField -FieldName 'QuarantineRequestAlert' -FieldValue $StateIsCorrect -StoreAs bool -Tenant $Tenant $CurrentValue = @{ - NotifyUser = @($CurrentState.NotifyUser) + NotifyUser = @($CurrentState.NotifyUser | Where-Object { $_ }) } $ExpectedValue = @{ - NotifyUser = @($Settings.NotifyUser) + NotifyUser = if ($State -eq 'removed') { @() } else { @($Settings.NotifyUser) } } Set-CIPPStandardsCompareField -FieldName 'standards.QuarantineRequestAlert' -CurrentValue $CurrentValue -ExpectedValue $ExpectedValue -Tenant $Tenant } diff --git a/frontend/src/data/standards.json b/frontend/src/data/standards.json index 3904d85c81..da8fcb29f2 100644 --- a/frontend/src/data/standards.json +++ b/frontend/src/data/standards.json @@ -3430,21 +3430,38 @@ "name": "standards.QuarantineRequestAlert", "cat": "Defender Standards", "tag": [], - "helpText": "Sets a e-mail address to alert when a User requests to release a quarantined message.", - "docsDescription": "Sets a e-mail address to alert when a User requests to release a quarantined message. This is useful for monitoring and ensuring that the correct messages are released.", + "helpText": "Sets a e-mail address to alert when a User requests to release a quarantined message. Set the alert state to Removed to delete the alert rule CIPP created from the tenant.", + "docsDescription": "Sets a e-mail address to alert when a User requests to release a quarantined message. This is useful for monitoring and ensuring that the correct messages are released. Setting the alert state to Removed deletes the alert rule CIPP created from the tenant, for when the alert is no longer wanted.", "executiveText": "Notifies IT administrators when employees request to release emails that were quarantined for security reasons, enabling oversight of potentially dangerous messages. This helps ensure that legitimate emails are released while maintaining security controls over suspicious content.", "addedComponent": [ + { + "type": "autoComplete", + "multiple": false, + "creatable": false, + "required": false, + "label": "Alert state (blank or Enabled creates the alert, Removed deletes it)", + "name": "standards.QuarantineRequestAlert.state", + "options": [ + { "label": "Enabled", "value": "enabled" }, + { "label": "Removed", "value": "removed" } + ] + }, { "type": "textField", "name": "standards.QuarantineRequestAlert.NotifyUser", - "label": "E-mail to receive the alert" + "label": "E-mail to receive the alert", + "condition": { + "field": "standards.QuarantineRequestAlert.state", + "compareType": "isNot", + "compareValue": { "label": "Removed", "value": "removed" } + } } ], "label": "Quarantine Release Request Alert", "impact": "Low Impact", "impactColour": "info", "addedDate": "2024-07-15", - "powershellEquivalent": "New-ProtectionAlert and Set-ProtectionAlert", + "powershellEquivalent": "New-ProtectionAlert, Set-ProtectionAlert and Remove-ProtectionAlert", "recommendedBy": [], "requiredCapabilities": [ "EXCHANGE_S_STANDARD", From 33a4d75ead9152f0090ce64c2ce20543be17e7d7 Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Tue, 18 Aug 2026 13:23:30 +0200 Subject: [PATCH 133/226] remove alert option for baseline --- ...oke-CIPPBaselineQuarantineRequestAlert.ps1 | 12 ++++++++-- .../Baselines/BaselineExecutors.Tests.ps1 | 24 +++++++++++++++++++ .../Baselines/BaselinePrepareHooks.Tests.ps1 | 19 +++++++++++++++ 3 files changed, 53 insertions(+), 2 deletions(-) diff --git a/backend/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineQuarantineRequestAlert.ps1 b/backend/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineQuarantineRequestAlert.ps1 index dcf5893796..fe793461d9 100644 --- a/backend/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineQuarantineRequestAlert.ps1 +++ b/backend/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineQuarantineRequestAlert.ps1 @@ -35,8 +35,9 @@ function Invoke-CIPPBaselineQuarantineRequestAlert { ) $PolicyName = 'CIPP User requested to release a quarantined message' + $RemoveAlert = "$($Remediate.state)" -eq 'removed' $Configured = "$($Remediate.notifyUser)" - if ([string]::IsNullOrWhiteSpace($Configured)) { throw 'QuarantineRequestAlert: no notify address configured to write.' } + if (-not $RemoveAlert -and [string]::IsNullOrWhiteSpace($Configured)) { throw 'QuarantineRequestAlert: no notify address configured to write.' } $AllowExtra = [bool]($Remediate.allowExtraAddresses -eq $true) $Existing = $null @@ -44,7 +45,14 @@ function Invoke-CIPPBaselineQuarantineRequestAlert { $Existing = @(New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-ProtectionAlert' -Compliance | Where-Object { $_.Name -eq $PolicyName }) | Select-Object -First 1 } catch { - throw "QuarantineRequestAlert: could not read the existing alert to merge into: $($_.Exception.Message)" + throw "QuarantineRequestAlert: could not read the existing alert: $($_.Exception.Message)" + } + + if ($RemoveAlert) { + if ($Existing) { + $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Remove-ProtectionAlert' -Compliance -cmdParams @{ Identity = $PolicyName } -useSystemMailbox $true + } + return } $Recipients = [System.Collections.Generic.List[string]]::new() diff --git a/backend/Tests/Baselines/BaselineExecutors.Tests.ps1 b/backend/Tests/Baselines/BaselineExecutors.Tests.ps1 index 5bac86e136..40f3b7ac7d 100644 --- a/backend/Tests/Baselines/BaselineExecutors.Tests.ps1 +++ b/backend/Tests/Baselines/BaselineExecutors.Tests.ps1 @@ -487,4 +487,28 @@ Describe 'Invoke-CIPPBaselineQuarantineRequestAlert' { { Invoke-CIPPBaselineQuarantineRequestAlert -Remediate $Spec -TenantFilter $script:Tenant -Current $null } | Should -Throw '*could not read the existing alert*' Should -Invoke New-ExoRequest -Times 0 -ParameterFilter { $cmdlet -eq 'Set-ProtectionAlert' } } + + It 'removes the alert when the state is removed, without demanding a notify address' { + # The spec deliberately has no notifyUser: removal must not trip the enabled-mode guard. + $Spec = @{ state = 'removed' } | ConvertTo-Spec + Invoke-CIPPBaselineQuarantineRequestAlert -Remediate $Spec -TenantFilter $script:Tenant -Current $null + Should -Invoke New-ExoRequest -Times 1 -ParameterFilter { + $cmdlet -eq 'Remove-ProtectionAlert' -and $cmdParams['Identity'] -eq $script:AlertName + } + Should -Invoke New-ExoRequest -Times 0 -ParameterFilter { $cmdlet -in @('Set-ProtectionAlert', 'New-ProtectionAlert') } + } + + It 'leaves the tenant alone when the state is removed and the alert is already gone' { + Mock New-ExoRequest { @() } -ParameterFilter { $cmdlet -eq 'Get-ProtectionAlert' } + $Spec = @{ state = 'removed'; notifyUser = 'soc@contoso.com' } | ConvertTo-Spec + Invoke-CIPPBaselineQuarantineRequestAlert -Remediate $Spec -TenantFilter $script:Tenant -Current $null + Should -Invoke New-ExoRequest -Times 0 -ParameterFilter { $cmdlet -eq 'Remove-ProtectionAlert' } + } + + It 'refuses to remove blind if the existing alert cannot be read' { + Mock New-ExoRequest { throw 'compliance endpoint unavailable' } -ParameterFilter { $cmdlet -eq 'Get-ProtectionAlert' } + $Spec = @{ state = 'removed' } | ConvertTo-Spec + { Invoke-CIPPBaselineQuarantineRequestAlert -Remediate $Spec -TenantFilter $script:Tenant -Current $null } | Should -Throw '*could not read the existing alert*' + Should -Invoke New-ExoRequest -Times 0 -ParameterFilter { $cmdlet -eq 'Remove-ProtectionAlert' } + } } diff --git a/backend/Tests/Baselines/BaselinePrepareHooks.Tests.ps1 b/backend/Tests/Baselines/BaselinePrepareHooks.Tests.ps1 index ffe145e0cf..10be0306a5 100644 --- a/backend/Tests/Baselines/BaselinePrepareHooks.Tests.ps1 +++ b/backend/Tests/Baselines/BaselinePrepareHooks.Tests.ps1 @@ -306,6 +306,25 @@ Describe 'Get-CIPPBaselineQuarantineRequestAlertState' { Mock Get-CIPPDbItem { $null } (Get-CIPPBaselineQuarantineRequestAlertState -Item $script:Item -TenantFilter $script:Tenant).Current | Should -BeNullOrEmpty } + + It 'grades the removed state on presence alone: present is drift, absent is compliant' { + $Removed = [PSCustomObject]@{ Variables = [PSCustomObject]@{ NotifyUser = 'soc@contoso.com'; State = 'removed' } } + Mock New-CIPPDbRequest { @(@{ Name = $script:AlertName; NotifyUser = @('soc@contoso.com') } | ConvertTo-Cached) } + $Prepared = Get-CIPPBaselineQuarantineRequestAlertState -Item $Removed -TenantFilter $script:Tenant + $Prepared.Expected.AlertPresent | Should -BeFalse + $Prepared.Current.AlertPresent | Should -BeTrue + + Mock New-CIPPDbRequest { @(@{ Name = 'some other alert'; NotifyUser = @('x@y.com') } | ConvertTo-Cached) } + (Get-CIPPBaselineQuarantineRequestAlertState -Item $Removed -TenantFilter $script:Tenant).Current.AlertPresent | Should -BeFalse + } + + It 'still reports unknown in the removed state when the alert cache has never been collected' { + # An uncollected cache proves nothing about absence, so it must not grade as compliant. + $Removed = [PSCustomObject]@{ Variables = [PSCustomObject]@{ State = 'removed' } } + Mock New-CIPPDbRequest { @() } + Mock Get-CIPPDbItem { $null } + (Get-CIPPBaselineQuarantineRequestAlertState -Item $Removed -TenantFilter $script:Tenant).Current | Should -BeNullOrEmpty + } } Describe 'Get-CIPPBaselineSafeAttachmentPolicyState' { From ab6d80355a8dbbcd00cbce7bec99493691bdfab1 Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Tue, 18 Aug 2026 13:29:18 +0200 Subject: [PATCH 134/226] add offboarding of Quartatine alerts to offboarding --- backend/Config/openapi.json | 92 +++++++++++++++++++ .../Tenant/Invoke-ExecOffboardTenant.ps1 | 17 ++++ .../tenant/gdap-management/offboarding.js | 6 ++ 3 files changed, 115 insertions(+) diff --git a/backend/Config/openapi.json b/backend/Config/openapi.json index a0b69d81ce..4a56cced7b 100644 --- a/backend/Config/openapi.json +++ b/backend/Config/openapi.json @@ -26720,6 +26720,9 @@ "type": "boolean", "description": "All customer tenant specific actions ALWAYS have to be completed before this action!" }, + "RemoveQuarantineAlert": { + "type": "boolean" + }, "TenantFilter": { "$ref": "#/components/schemas/LabelValue" }, @@ -34584,6 +34587,92 @@ "x-cipp-role": "CIPP.SuperAdmin.ReadWrite" } }, + "/api/ExecToggleAlert": { + "post": { + "summary": "ExecToggleAlert", + "operationId": "ExecToggleAlert", + "tags": [ + "Tenant > Administration > Alerts" + ], + "description": "Enables or disables an alert rule without deleting it. Works for both audit log alerts and scheduled alert tasks.", + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "Disabled": { + "type": "string" + }, + "EventType": { + "type": "string", + "description": "Interact with the query or body of the request" + }, + "ID": { + "type": "string" + } + } + } + } + } + }, + "parameters": [ + { + "name": "Disabled", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "EventType", + "in": "query", + "description": "Interact with the query or body of the request", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "ID", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StandardResults" + } + } + } + }, + "401": { + "description": "Unauthorized - invalid or missing bearer token" + }, + "403": { + "description": "Forbidden - caller lacks the required RBAC role" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "x-cipp-role": "CIPP.Alert.ReadWrite" + } + }, "/api/ExecTokenExchange": { "post": { "summary": "ExecTokenExchange", @@ -36094,6 +36183,9 @@ "Conditions": { "x-cipp-field-source": "frontend" }, + "Enabled": { + "x-cipp-field-source": "frontend" + }, "EventType": { "x-cipp-field-source": "frontend" }, diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Tenant/Invoke-ExecOffboardTenant.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Tenant/Invoke-ExecOffboardTenant.ps1 index 219140ee5c..0c8432fef8 100644 --- a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Tenant/Invoke-ExecOffboardTenant.ps1 +++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Tenant/Invoke-ExecOffboardTenant.ps1 @@ -126,6 +126,23 @@ function Invoke-ExecOffboardTenant { } } + if ($Request.Body.RemoveQuarantineAlert -eq $true) { + # Remove the protection alert created by the Quarantine Release Request Alert standard + try { + $PolicyName = 'CIPP User requested to release a quarantined message' + $QuarantineAlert = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-ProtectionAlert' -Compliance | Where-Object { $_.Name -eq $PolicyName } + if ($QuarantineAlert) { + $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Remove-ProtectionAlert' -Compliance -cmdParams @{ Identity = $PolicyName } -UseSystemMailbox $true + $Results.Add('Successfully removed the CIPP Quarantine Release Request Alert') + Write-LogMessage -headers $Headers -API $APIName -message 'CIPP Quarantine Release Request Alert was removed' -Sev 'Info' -tenant $TenantFilter + } else { + $Results.Add('No CIPP Quarantine Release Request Alert found to remove') + } + } catch { + $Errors.Add("Failed to remove the CIPP Quarantine Release Request Alert: $($_.Exception.message)") + } + } + $VendorApps = $Request.Body.vendorApplications if ($VendorApps) { $VendorApps | ForEach-Object { diff --git a/frontend/src/pages/tenant/gdap-management/offboarding.js b/frontend/src/pages/tenant/gdap-management/offboarding.js index aa6cddc87d..5cfc5be94f 100644 --- a/frontend/src/pages/tenant/gdap-management/offboarding.js +++ b/frontend/src/pages/tenant/gdap-management/offboarding.js @@ -226,6 +226,12 @@ const Page = () => { label="Remove all Domain Analyser results for this tenant." type="switch" /> + From 98ca595219d75a6dedceff53b6481d155cd062ff Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Tue, 18 Aug 2026 19:18:31 +0800 Subject: [PATCH 135/226] fix(auth): fail closed when the allowed-tenant scope is explicitly empty A custom role can resolve to zero allowed tenants (every allowed tenant also blocked, or an allowed tenant group that expands to no members). That empty scope was collapsing into the unrestricted sentinel at three points - PowerShell unwraps an empty array returned from a function or routed through a statement-expression into $null, and $null/@() are both falsy - so a caller entitled to nothing was treated as entitled to everything on AllTenants reads, cached and live alike. - New-CippCoreRequest: wrap the scope-list calls in @() so an empty list reaches the storage slot as an empty list, not $null. - Select-CippAllowedTenantData: $null scope stays unrestricted; any non-null scope with zero usable ids now denies every row. - Get-Tenants: null-test the storage value instead of truthiness, so an empty scope narrows to no tenants instead of skipping the filter. - Get-CippRequestContext: copy the scope slots with plain assignments so an empty array survives into AllowedTenants. - Invoke-ListUsers: null-test AllowedTenants so zero-tenant restricted callers get the deprecation message, not the legacy all-tenant blob. - Get-CIPPTestResultsTenants: bind-presence decides restriction, so an explicit empty AllowedTenantIds reads zero partitions. --- .../Authentication/Get-CippRequestContext.ps1 | 10 ++++- .../Select-CippAllowedTenantData.ps1 | 44 ++++++++++++------- .../HTTP Functions/New-CippCoreRequest.ps1 | 8 +++- .../Public/Get-CIPPTestResultsTenants.ps1 | 6 ++- .../Public/GraphHelper/Get-Tenants.ps1 | 7 ++- .../Administration/Users/Invoke-ListUsers.ps1 | 8 ++-- .../Select-CippAllowedTenantData.Tests.ps1 | 30 ++++++++++++- 7 files changed, 86 insertions(+), 27 deletions(-) diff --git a/backend/Modules/CIPPCore/Public/Authentication/Get-CippRequestContext.ps1 b/backend/Modules/CIPPCore/Public/Authentication/Get-CippRequestContext.ps1 index a9df439f94..c4fa730a63 100644 --- a/backend/Modules/CIPPCore/Public/Authentication/Get-CippRequestContext.ps1 +++ b/backend/Modules/CIPPCore/Public/Authentication/Get-CippRequestContext.ps1 @@ -23,8 +23,14 @@ function Get-CippRequestContext { param() $InvocationId = if ($script:CippInvocationIdStorage) { $script:CippInvocationIdStorage.Value } else { $null } - $AllowedTenants = if ($script:CippAllowedTenantsStorage) { $script:CippAllowedTenantsStorage.Value } else { $null } - $AllowedGroups = if ($script:CippAllowedGroupsStorage) { $script:CippAllowedGroupsStorage.Value } else { $null } + + # The scope slots distinguish $null (unrestricted) from an empty array (restricted, entitled + # to nothing), so they must be copied with plain assignments: routing .Value through an + # if-statement-expression unwraps an empty array to $null and erases that distinction. + $AllowedTenants = $null + if ($script:CippAllowedTenantsStorage) { $AllowedTenants = $script:CippAllowedTenantsStorage.Value } + $AllowedGroups = $null + if ($script:CippAllowedGroupsStorage) { $AllowedGroups = $script:CippAllowedGroupsStorage.Value } # Count only. The keys are user principal names and the diagnostic endpoint that surfaces # this is gated on CIPP.Core.Read, which is not a high enough bar to hand out a list of who diff --git a/backend/Modules/CIPPCore/Public/Authentication/Select-CippAllowedTenantData.ps1 b/backend/Modules/CIPPCore/Public/Authentication/Select-CippAllowedTenantData.ps1 index c31a698162..f159ce5cd3 100644 --- a/backend/Modules/CIPPCore/Public/Authentication/Select-CippAllowedTenantData.ps1 +++ b/backend/Modules/CIPPCore/Public/Authentication/Select-CippAllowedTenantData.ps1 @@ -16,7 +16,10 @@ function Select-CippAllowedTenantData { is CIPPCore module-scoped; a copy defined in CIPPHTTP would read that module's own empty variable and silently filter nothing (see Get-CippRequestContext). - The stored scope is a list of customerIds (or $null = unrestricted). Cache rows identify + The stored scope is a list of customerIds. $null means unrestricted; any non-null scope - + including an empty list, which a role produces when every allowed tenant is also blocked + or an allowed tenant group expands to no members - means restricted and must fail closed + rather than fall through to the unrestricted path. Cache rows identify their tenant by domain name (defaultDomainName, stored on a 'Tenant' property) and/or by customerId, so allowed customerIds are expanded to every identifier form an allowed tenant might present, mirroring the match logic in Invoke-ListLogs. @@ -50,24 +53,34 @@ function Select-CippAllowedTenantData { ) begin { - # $null / empty stored scope means the caller is unrestricted - pass everything through - # with zero overhead (no Get-Tenants call). - $AllowedCustomerIds = if ($script:CippAllowedTenantsStorage) { $script:CippAllowedTenantsStorage.Value } else { $null } - $Unrestricted = -not ($AllowedCustomerIds | Where-Object { $_ }) + # A $null stored scope means the caller is unrestricted - pass everything through with + # zero overhead (no Get-Tenants call). An explicit scope that resolves to zero usable ids + # is a restricted caller entitled to nothing, and has to deny rather than degrade into the + # unrestricted path. The two cannot be told apart with plain truthiness ($null and @() are + # both falsy), and the null test must run against the property itself: routing .Value + # through an intermediate statement-expression unwraps an empty array to $null, which is + # exactly the collapse that used to leak every tenant's rows. + $Unrestricted = -not $script:CippAllowedTenantsStorage -or $null -eq $script:CippAllowedTenantsStorage.Value + $DenyAll = $false if (-not $Unrestricted) { - # Build a case-insensitive set of every identifier a row might carry for an allowed - # tenant. Get-Tenants is already narrowed to the caller's scope by the storage filter. - $AllowedSet = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) - foreach ($Id in $AllowedCustomerIds) { - if ($Id) { [void]$AllowedSet.Add([string]$Id) } - } - foreach ($Tenant in (Get-Tenants -IncludeErrors)) { - foreach ($Value in @($Tenant.customerId, $Tenant.defaultDomainName, $Tenant.initialDomainName)) { - if ($Value) { [void]$AllowedSet.Add([string]$Value) } + $AllowedCustomerIds = @($script:CippAllowedTenantsStorage.Value | Where-Object { $_ }) + if ($AllowedCustomerIds.Count -eq 0) { + $DenyAll = $true + } else { + # Build a case-insensitive set of every identifier a row might carry for an allowed + # tenant. Get-Tenants is already narrowed to the caller's scope by the storage filter. + $AllowedSet = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($Id in $AllowedCustomerIds) { + [void]$AllowedSet.Add([string]$Id) + } + foreach ($Tenant in (Get-Tenants -IncludeErrors)) { + foreach ($Value in @($Tenant.customerId, $Tenant.defaultDomainName, $Tenant.initialDomainName)) { + if ($Value) { [void]$AllowedSet.Add([string]$Value) } + } } + if ($AllowPartner) { [void]$AllowedSet.Add('CIPP') } } - if ($AllowPartner) { [void]$AllowedSet.Add('CIPP') } } } @@ -78,6 +91,7 @@ function Select-CippAllowedTenantData { $Item continue } + if ($DenyAll) { continue } foreach ($Prop in $TenantProperty) { $Value = $Item.$Prop if ($Value -and $AllowedSet.Contains([string]$Value)) { diff --git a/backend/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/New-CippCoreRequest.ps1 b/backend/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/New-CippCoreRequest.ps1 index 84e4198b18..136bf42dcc 100644 --- a/backend/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/New-CippCoreRequest.ps1 +++ b/backend/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/New-CippCoreRequest.ps1 @@ -141,12 +141,16 @@ function New-CippCoreRequest { }) } $swTenants = [System.Diagnostics.Stopwatch]::StartNew() - $AllowedTenants = Test-CippAccess -Request $Request -TenantList + # The @() wrap is load-bearing: a scope-only call can return an empty list (a + # restricted caller entitled to nothing), and a bare assignment unwraps that to + # $null - the sentinel consumers read as 'unrestricted'. The wrap keeps the empty + # list an empty list so the storage below stores a restricted scope, not a free pass. + $AllowedTenants = @(Test-CippAccess -Request $Request -TenantList) $swTenants.Stop() $HttpTimings['AllowedTenants'] = $swTenants.Elapsed.TotalMilliseconds $swGroups = [System.Diagnostics.Stopwatch]::StartNew() - $AllowedGroups = Test-CippAccess -Request $Request -GroupList + $AllowedGroups = @(Test-CippAccess -Request $Request -GroupList) $swGroups.Stop() $HttpTimings['AllowedGroups'] = $swGroups.Elapsed.TotalMilliseconds diff --git a/backend/Modules/CIPPCore/Public/Get-CIPPTestResultsTenants.ps1 b/backend/Modules/CIPPCore/Public/Get-CIPPTestResultsTenants.ps1 index 9c1c267925..9ad2092151 100644 --- a/backend/Modules/CIPPCore/Public/Get-CIPPTestResultsTenants.ps1 +++ b/backend/Modules/CIPPCore/Public/Get-CIPPTestResultsTenants.ps1 @@ -127,8 +127,12 @@ function Get-CIPPTestResultsTenants { Write-Warning "Get-CIPPTestResultsTenants: failed to load tenant list: $($_.Exception.Message)" } + # Presence of the parameter is what marks the caller as restricted, not the list having + # entries: a restricted caller whose scope resolved to zero tenants passes @(), which is + # falsy, and a truthiness check would hand that caller the unrestricted path. An empty + # HashSet stays truthy at the filter below, so zero allowed ids reads zero partitions. $AllowedSet = $null - if ($AllowedTenantIds) { + if ($PSBoundParameters.ContainsKey('AllowedTenantIds')) { $AllowedSet = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) foreach ($Allowed in $AllowedTenantIds) { if ($Allowed) { [void]$AllowedSet.Add([string]$Allowed) } } } diff --git a/backend/Modules/CIPPCore/Public/GraphHelper/Get-Tenants.ps1 b/backend/Modules/CIPPCore/Public/GraphHelper/Get-Tenants.ps1 index 3468f3d7df..4c15c67838 100644 --- a/backend/Modules/CIPPCore/Public/GraphHelper/Get-Tenants.ps1 +++ b/backend/Modules/CIPPCore/Public/GraphHelper/Get-Tenants.ps1 @@ -277,8 +277,11 @@ function Get-Tenants { } } - # Limit tenant list to allowed tenants if set in script scope from New-CippCoreRequest - if ($script:CippAllowedTenantsStorage -and $script:CippAllowedTenantsStorage.Value) { + # Limit tenant list to allowed tenants if set in script scope from New-CippCoreRequest. + # $null means unrestricted; any non-null scope filters, so a restricted caller whose scope + # resolved to zero tenants gets an empty list back rather than every tenant (an empty array + # is falsy, so a plain truthiness check would silently skip the narrowing). + if ($script:CippAllowedTenantsStorage -and $null -ne $script:CippAllowedTenantsStorage.Value) { $IncludedTenantsCache = $IncludedTenantsCache | Where-Object { $script:CippAllowedTenantsStorage.Value -contains $_.customerId } } diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListUsers.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListUsers.ps1 index 69396848aa..a43c639147 100644 --- a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListUsers.ps1 +++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListUsers.ps1 @@ -83,10 +83,12 @@ Function Invoke-ListUsers { $_ | Add-Member -MemberType NoteProperty -Name 'primDomain' -Value @{value = ($_.userPrincipalName -split '@' | Select-Object -Last 1); label = ($_.userPrincipalName -split '@' | Select-Object -Last 1); } -Force $_ } - } elseif ((Get-CippRequestContext).AllowedTenants) { + } elseif ($null -ne (Get-CippRequestContext).AllowedTenants) { # Deprecated cacheusers blob has no reliable per-tenant column, so it cannot be safely - # narrowed for a tenant-restricted caller. Return the deprecation message instead of - # leaking every tenant's users. Unrestricted callers keep the legacy behavior below. + # narrowed for a tenant-restricted caller - including one whose scope resolved to zero + # tenants, whose empty array is falsy and would otherwise fall through to the legacy + # path. Return the deprecation message instead of leaking every tenant's users. + # Unrestricted callers ($null scope) keep the legacy behavior below. [PSCustomObject]@{ Message = 'This function has been deprecated for all users, please use ListGraphRequest instead' } diff --git a/backend/Tests/Private/Select-CippAllowedTenantData.Tests.ps1 b/backend/Tests/Private/Select-CippAllowedTenantData.Tests.ps1 index b8f9da7dd9..ae7a8d1c1b 100644 --- a/backend/Tests/Private/Select-CippAllowedTenantData.Tests.ps1 +++ b/backend/Tests/Private/Select-CippAllowedTenantData.Tests.ps1 @@ -90,10 +90,36 @@ Describe 'Select-CippAllowedTenantData' { Should -Invoke -CommandName Get-Tenants -Times 0 -Exactly } - It 'treats an empty scope array as unrestricted' { + It 'does not treat an explicit empty scope as unrestricted' { + Mock -CommandName Get-Tenants -MockWith { throw 'Get-Tenants must not be called when the scope is empty' } $script:CippAllowedTenantsStorage.Value = @() $Result = $script:MixedRows | Select-CippAllowedTenantData -TenantProperty 'Tenant' - @($Result).Count | Should -Be 3 + @($Result).Count | Should -Be 0 + } + } + + Context 'Restricted caller with zero effective tenants (explicit empty scope)' { + BeforeEach { + # An empty scope is a restricted caller entitled to nothing - it must deny without + # falling back to Get-Tenants, whose unfiltered list would defeat the point. + Mock -CommandName Get-Tenants -MockWith { throw 'Get-Tenants must not be called when the scope is empty' } + $script:CippAllowedTenantsStorage.Value = @() + } + + It 'returns no rows from a non-empty cache input' { + $Result = $script:MixedRows | Select-CippAllowedTenantData -TenantProperty 'Tenant' + @($Result).Count | Should -Be 0 + } + + It 'treats a scope of only blank ids as deny-all, not unrestricted' { + $script:CippAllowedTenantsStorage.Value = @($null, '') + $Result = $script:MixedRows | Select-CippAllowedTenantData -TenantProperty 'Tenant' + @($Result).Count | Should -Be 0 + } + + It 'drops partner/system CIPP rows even with -AllowPartner' { + $Rows = @([pscustomobject]@{ Tenant = 'CIPP'; Data = 'system' }) + (@($Rows | Select-CippAllowedTenantData -TenantProperty 'Tenant' -AllowPartner)).Count | Should -Be 0 } } } From f6754aa1dd42f1097582ff9a5c904540f06df8f4 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Tue, 18 Aug 2026 19:18:46 +0800 Subject: [PATCH 136/226] fix(auth): narrow remaining cached AllTenants readers to allowed tenants Four cached readers missed the v10.7.3 tenant-scope enforcement (0aa6200d), each reachable by a tenant-restricted custom role: - Invoke-ListHVEAccounts: UseReportDB=true with tenantFilter=AllTenants hits Get-CIPPDbItem's cross-partition sentinel (-ne is case-insensitive) and returned every tenant's HVE accounts. - Invoke-DomainAnalyser_List: AnyTenant skips the framework per-tenant check and the endpoint read the Domains table raw, so AllTenants returned every tenant's domain/DNS posture and any single tenant was readable by naming it. - Invoke-ListAlertResults: AnyTenant with no self-scoping let any tenant's fired-alert items be read by naming the tenant. - Invoke-ListSnoozedAlerts: returned every snooze record, tenant names and alert content previews included. Each now pipes its rows through Select-CippAllowedTenantData on the column that carries the row's tenant, the same pattern the 0aa6200d endpoints use: unrestricted callers pass through untouched, restricted callers keep only their tenants' rows. Get-CIPPDomainAnalyser additionally skipped per-scope isolation on its in-worker results cache: entries are keyed by tenant filter alone and workers serve many users, so results computed under one caller's scope could be replayed to a caller with a different scope for up to five minutes. Tenant-restricted requests now bypass that cache in both directions; unrestricted callers and background runs keep it. --- .../CIPPCore/Public/Get-CIPPDomainAnalyser.ps1 | 18 ++++++++++++++---- .../CIPP/Core/Invoke-ListAlertResults.ps1 | 6 +++++- .../CIPP/Core/Invoke-ListSnoozedAlerts.ps1 | 5 +++++ .../Administration/Invoke-ListHVEAccounts.ps1 | 5 ++++- .../Standards/invoke-DomainAnalyser_List.ps1 | 6 +++++- 5 files changed, 33 insertions(+), 7 deletions(-) diff --git a/backend/Modules/CIPPCore/Public/Get-CIPPDomainAnalyser.ps1 b/backend/Modules/CIPPCore/Public/Get-CIPPDomainAnalyser.ps1 index f2494efa09..87c6883e87 100644 --- a/backend/Modules/CIPPCore/Public/Get-CIPPDomainAnalyser.ps1 +++ b/backend/Modules/CIPPCore/Public/Get-CIPPDomainAnalyser.ps1 @@ -18,11 +18,19 @@ function Get-CIPPDomainAnalyser { if (-not $script:CIPPDomainAnalyserCache) { $script:CIPPDomainAnalyserCache = @{} } + # The in-worker results cache is keyed by tenant filter alone, and a worker serves many + # callers in turn: results computed under one caller's tenant scope must never be replayed + # to a caller with a different scope. Tenant-restricted requests therefore skip the cache + # entirely, in both directions; the unrestricted majority (admins, background alert and + # test runs) keeps the caching benefit. + $ScopeRestricted = $null -ne (Get-CippRequestContext).AllowedTenants $CacheKey = if ([string]::IsNullOrEmpty($TenantFilter)) { 'AllTenants' } else { $TenantFilter } $CacheNow = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds() - $CachedEntry = $script:CIPPDomainAnalyserCache[$CacheKey] - if ($CachedEntry -and ($CacheNow - $CachedEntry.Timestamp) -lt 300) { - return $CachedEntry.Results + if (-not $ScopeRestricted) { + $CachedEntry = $script:CIPPDomainAnalyserCache[$CacheKey] + if ($CachedEntry -and ($CacheNow - $CachedEntry.Timestamp) -lt 300) { + return $CachedEntry.Results + } } $DomainTable = Get-CIPPTable -Table 'Domains' @@ -54,6 +62,8 @@ function Get-CIPPDomainAnalyser { } catch { $Results = @() } - $script:CIPPDomainAnalyserCache[$CacheKey] = @{ Results = $Results; Timestamp = $CacheNow } + if (-not $ScopeRestricted) { + $script:CIPPDomainAnalyserCache[$CacheKey] = @{ Results = $Results; Timestamp = $CacheNow } + } return $Results } diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ListAlertResults.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ListAlertResults.ps1 index 15246398a3..45447d1e1a 100644 --- a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ListAlertResults.ps1 +++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ListAlertResults.ps1 @@ -29,7 +29,11 @@ function Invoke-ListAlertResults { $Table = Get-CIPPTable -tablename 'AlertLastRun' # AlertLastRun: PartitionKey = run date (yyyyMMdd), RowKey = "{tenant}-{cmdlet}" $SafeTenant = ConvertTo-CIPPODataFilterValue -Value $TenantFilter -Type String - $Rows = Get-CIPPAzDataTableEntity @Table -Filter "Tenant eq '$SafeTenant'" + # AnyTenant skips the framework's per-tenant check, so a tenant-restricted caller could + # otherwise read any tenant's fired-alert items by naming it. Narrowing on the row's own + # Tenant column keeps allowed tenants' rows and drops everything else, estate-wide rows + # included, for restricted callers; unrestricted callers pass through untouched. + $Rows = Get-CIPPAzDataTableEntity @Table -Filter "Tenant eq '$SafeTenant'" | Select-CippAllowedTenantData -TenantProperty 'Tenant' # Keep only the most recent run (highest date partition) per alert. RowKey is # "{tenant}-{cmdlet}", uniquely identifying the alert for this tenant. Write-AlertTrace diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ListSnoozedAlerts.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ListSnoozedAlerts.ps1 index 7829191267..32e8876606 100644 --- a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ListSnoozedAlerts.ps1 +++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ListSnoozedAlerts.ps1 @@ -25,6 +25,11 @@ function Invoke-ListSnoozedAlerts { $SnoozeRecords = Get-CIPPAzDataTableEntity @SnoozeTable } + # AnyTenant skips the framework's per-tenant check, and snooze rows carry alert content + # previews. Narrow to the caller's allowed tenants (dropping estate-wide rows for + # restricted callers); unrestricted callers pass through untouched. + $SnoozeRecords = $SnoozeRecords | Select-CippAllowedTenantData -TenantProperty 'Tenant' + $CurrentUnixTime = [int64](([datetime]::UtcNow) - (Get-Date '1/1/1970')).TotalSeconds diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ListHVEAccounts.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ListHVEAccounts.ps1 index e2b3fa48ee..c4ce318772 100644 --- a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ListHVEAccounts.ps1 +++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ListHVEAccounts.ps1 @@ -81,7 +81,10 @@ function Invoke-ListHVEAccounts { if ($UseReportDB) { try { - $HVEItems = Get-CIPPDbItem -TenantFilter $TenantFilter -Type 'HVEAccounts' | Where-Object { $_.RowKey -ne 'HVEAccounts-Count' } + # 'AllTenants' hits Get-CIPPDbItem's cross-partition sentinel ('allTenants', and -ne is + # case-insensitive), so the read returns every tenant's rows. CippReportingDB partitions + # by defaultDomainName; narrow to the caller's allowed tenants before responding. + $HVEItems = Get-CIPPDbItem -TenantFilter $TenantFilter -Type 'HVEAccounts' | Where-Object { $_.RowKey -ne 'HVEAccounts-Count' } | Select-CippAllowedTenantData -TenantProperty 'PartitionKey' if (-not $HVEItems) { $GraphRequest = @() } else { diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/invoke-DomainAnalyser_List.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/invoke-DomainAnalyser_List.ps1 index 1a80c14926..cf1643dd5e 100644 --- a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/invoke-DomainAnalyser_List.ps1 +++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/invoke-DomainAnalyser_List.ps1 @@ -21,8 +21,12 @@ Function Invoke-DomainAnalyser_List { } try { + # AnyTenant skips the framework's per-tenant check, so scoping is enforced here: narrow the + # rows to the caller's allowed tenants before extracting results. Rows carry the tenant as + # TenantGUID (customerId) and TenantId (defaultDomainName), matching Get-CIPPDomainAnalyser. + $DomainRows = Get-CIPPAzDataTableEntity @DomainTable | Select-CippAllowedTenantData -TenantProperty 'TenantGUID', 'TenantId' # Extract json from table results - $Results = foreach ($DomainAnalyserResult in (Get-CIPPAzDataTableEntity @DomainTable).DomainAnalyser) { + $Results = foreach ($DomainAnalyserResult in $DomainRows.DomainAnalyser) { try { if (![string]::IsNullOrEmpty($DomainAnalyserResult)) { $Object = $DomainAnalyserResult | ConvertFrom-Json -ErrorAction SilentlyContinue From 25dea1b7a7402ca2a2f985b2054c760ae494d497 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Tue, 18 Aug 2026 19:44:04 +0800 Subject: [PATCH 137/226] feat(identity): add guest lifecycle dashboard Adds /api/ListGuestUsers classifying guests as Active, Stale, Pending Acceptance, Never Signed In or Disabled from Graph beta sign-in activity, falling back to invitation-state-only statuses on tenants without Entra ID P1. New guest-users page with clickable summary count cards that filter the table, status filter presets, and a re-invite row action reusing /api/AddGuest for pending or stale guests. --- backend/Config/openapi.json | 107 +++++++++ .../Users/Invoke-ListGuestUsers.ps1 | 101 ++++++++ .../Endpoint/Invoke-ListGuestUsers.Tests.ps1 | 186 +++++++++++++++ frontend/src/layouts/config.js | 5 + .../administration/guest-users/index.js | 224 ++++++++++++++++++ 5 files changed, 623 insertions(+) create mode 100644 backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListGuestUsers.ps1 create mode 100644 backend/Tests/Endpoint/Invoke-ListGuestUsers.Tests.ps1 create mode 100644 frontend/src/pages/identity/administration/guest-users/index.js diff --git a/backend/Config/openapi.json b/backend/Config/openapi.json index 4a56cced7b..35171c71ca 100644 --- a/backend/Config/openapi.json +++ b/backend/Config/openapi.json @@ -46035,6 +46035,113 @@ "x-cipp-any-tenant": true } }, + "/api/ListGuestUsers": { + "get": { + "summary": "List guest users with lifecycle status", + "operationId": "ListGuestUsers", + "tags": [ + "Identity > Administration > Users" + ], + "description": "Lists all guest accounts in a tenant with a computed lifecycle status (Active, Pending Acceptance, Stale, Never Signed In or Disabled) based on the invitation state and sign-in activity from the Graph beta API.", + "parameters": [ + { + "name": "staleDays", + "in": "query", + "description": "Days without any sign-in before an enabled guest is considered stale. Defaults to 90.", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "$ref": "#/components/parameters/tenantFilter" + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "description": "Derived from the Microsoft Graph entity it queries, and the fields the endpoint selects onto each record, and the columns the CIPP UI renders. The fields taken from Graph are the ones this endpoint selects, so they are what the response actually carries.", + "properties": { + "accountEnabled": { + "type": "boolean", + "x-cipp-field-source": "graph,backend,frontend" + }, + "createdDateTime": { + "type": "string", + "x-cipp-field-source": "graph,backend,frontend" + }, + "daysSinceSignIn": { + "x-cipp-field-source": "backend,frontend" + }, + "displayName": { + "type": "string", + "x-cipp-field-source": "graph,backend,frontend" + }, + "externalUserState": { + "type": "string", + "x-cipp-field-source": "graph,backend" + }, + "externalUserStateChangeDateTime": { + "type": "string", + "x-cipp-field-source": "graph,backend" + }, + "id": { + "type": "string", + "x-cipp-field-source": "graph,backend" + }, + "lastInteractiveSignInDateTime": { + "x-cipp-field-source": "backend" + }, + "lastNonInteractiveSignInDateTime": { + "x-cipp-field-source": "backend" + }, + "lastSignInDateTime": { + "x-cipp-field-source": "backend,frontend" + }, + "lastSuccessfulSignInDateTime": { + "x-cipp-field-source": "backend" + }, + "mail": { + "type": "string", + "x-cipp-field-source": "graph,backend,frontend" + }, + "sourceDomain": { + "x-cipp-field-source": "backend,frontend" + }, + "status": { + "x-cipp-field-source": "backend,frontend" + }, + "userPrincipalName": { + "type": "string", + "x-cipp-field-source": "graph,backend" + } + } + } + } + } + } + }, + "401": { + "description": "Unauthorized - invalid or missing bearer token" + }, + "403": { + "description": "Forbidden - caller lacks the required RBAC role" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "x-cipp-role": "Identity.User.Read" + } + }, "/api/ListHaloClients": { "get": { "summary": "ListHaloClients", diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListGuestUsers.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListGuestUsers.ps1 new file mode 100644 index 0000000000..d29a86ee0c --- /dev/null +++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListGuestUsers.ps1 @@ -0,0 +1,101 @@ +function Invoke-ListGuestUsers { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Identity.User.Read + .SYNOPSIS + List guest users with lifecycle status + .DESCRIPTION + Lists all guest accounts in a tenant with a computed lifecycle status (Active, Pending Acceptance, Stale, Never Signed In or Disabled) based on the invitation state and sign-in activity from the Graph beta API. + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers + + # The tenant to list guest users for + $TenantFilter = $Request.Query.tenantFilter + # Days without any sign-in before an enabled guest is considered stale. Defaults to 90. + $StaleDays = $Request.Query.staleDays ? [int]$Request.Query.staleDays : 90 + + try { + # signInActivity can only be requested on tenants with an Entra ID P1 license - Graph + # rejects the whole query on unlicensed tenants, so fall back to listing without + # sign-in data there and compute status from the invitation state alone. + $SignInLogsCapable = Test-CIPPStandardLicense -StandardName 'GuestLifecycle' -TenantFilter $TenantFilter -Preset Entra -SkipLog + + $SelectFields = @( + 'id', 'displayName', 'mail', 'userPrincipalName', 'createdDateTime', + 'accountEnabled', 'externalUserState', 'externalUserStateChangeDateTime' + ) + if ($SignInLogsCapable) { $SelectFields += 'signInActivity' } + # Graph caps the page size lower when signInActivity is selected + $Top = $SignInLogsCapable ? 500 : 999 + $Uri = "https://graph.microsoft.com/beta/users?`$filter=userType eq 'Guest'&`$select=$($SelectFields -join ',')&`$count=true&`$top=$Top" + $GuestUsers = New-GraphGetRequest -uri $Uri -tenantid $TenantFilter -ComplexFilter + + $Now = Get-Date + $GraphRequest = foreach ($Guest in $GuestUsers) { + # Last sign-in is the most recent of the three signInActivity fields. + # lastSuccessfulSignInDateTime can run ahead of the other two, so leaving it + # out would report recently-active guests as stale. + $LastSignIn = $null + $Candidates = @( + $Guest.signInActivity.lastSignInDateTime + $Guest.signInActivity.lastNonInteractiveSignInDateTime + $Guest.signInActivity.lastSuccessfulSignInDateTime + ) + foreach ($Candidate in $Candidates) { + if ($Candidate -and (-not $LastSignIn -or [datetime]$Candidate -gt [datetime]$LastSignIn)) { + $LastSignIn = $Candidate + } + } + $DaysSinceSignIn = $LastSignIn ? [math]::Round(($Now - [datetime]$LastSignIn).TotalDays) : $null + + $Status = if ($Guest.accountEnabled -eq $false) { + 'Disabled' + } elseif ($Guest.externalUserState -eq 'PendingAcceptance') { + 'Pending Acceptance' + } elseif (-not $SignInLogsCapable) { + 'Unknown' + } elseif (-not $LastSignIn) { + 'Never Signed In' + } elseif ($DaysSinceSignIn -ge $StaleDays) { + 'Stale' + } else { + 'Active' + } + + [PSCustomObject]@{ + id = $Guest.id + displayName = $Guest.displayName + mail = $Guest.mail + userPrincipalName = $Guest.userPrincipalName + sourceDomain = $Guest.mail ? ($Guest.mail -split '@')[1] : $null + status = $Status + accountEnabled = $Guest.accountEnabled + externalUserState = $Guest.externalUserState + externalUserStateChangeDateTime = $Guest.externalUserStateChangeDateTime + createdDateTime = $Guest.createdDateTime + lastSignInDateTime = $LastSignIn + lastInteractiveSignInDateTime = $Guest.signInActivity.lastSignInDateTime + lastNonInteractiveSignInDateTime = $Guest.signInActivity.lastNonInteractiveSignInDateTime + lastSuccessfulSignInDateTime = $Guest.signInActivity.lastSuccessfulSignInDateTime + daysSinceSignIn = $DaysSinceSignIn + } + } + $StatusCode = [System.Net.HttpStatusCode]::OK + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message "Failed to list guest users: $($ErrorMessage.NormalizedError)" -Sev 'Error' -LogData $ErrorMessage + $StatusCode = [System.Net.HttpStatusCode]::InternalServerError + $GraphRequest = @{ Error = $ErrorMessage.NormalizedError } + } + + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = @($GraphRequest) + }) +} diff --git a/backend/Tests/Endpoint/Invoke-ListGuestUsers.Tests.ps1 b/backend/Tests/Endpoint/Invoke-ListGuestUsers.Tests.ps1 new file mode 100644 index 0000000000..35daa544d6 --- /dev/null +++ b/backend/Tests/Endpoint/Invoke-ListGuestUsers.Tests.ps1 @@ -0,0 +1,186 @@ +# Pester tests for Invoke-ListGuestUsers +# Validates lifecycle status classification, the sign-in date selection, the staleDays +# override, and the fallback for tenants without an Entra ID P1 license. + +BeforeAll { + # Resolve by name under Modules/ so the test survives the function moving between modules. + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $FunctionPath = Get-ChildItem -Path (Join-Path $RepoRoot 'Modules') -Recurse -Filter 'Invoke-ListGuestUsers.ps1' -File -ErrorAction SilentlyContinue | + Select-Object -First 1 -ExpandProperty FullName + if (-not $FunctionPath) { throw 'Could not locate Invoke-ListGuestUsers.ps1 under Modules/' } + + # Azure Functions binding types do not exist outside the Functions host - fake them. + class HttpResponseContext { + [int]$StatusCode + [object]$Body + } + + # Stub every CIPP helper the function calls so Pester's Mock has a command to replace. + function Get-CippException { param($Exception) @{ NormalizedError = $Exception } } + function Test-CIPPStandardLicense { param($StandardName, $TenantFilter, $RequiredCapabilities, $Preset, [switch]$SkipLog) } + function New-GraphGetRequest { param($uri, $tenantid, [switch]$ComplexFilter) } + function Write-LogMessage { param($headers, $API, $tenant, $message, $Sev, $LogData) } + + . $FunctionPath + + function New-GuestRequest { + param([hashtable]$Query = @{}) + [pscustomobject]@{ + Params = @{ CIPPEndpoint = 'ListGuestUsers' } + Headers = @{ Authorization = 'token' } + Query = [pscustomobject](@{ tenantFilter = 'contoso.onmicrosoft.com' } + $Query) + } + } +} + +Describe 'Invoke-ListGuestUsers' { + BeforeEach { + Mock -CommandName Write-LogMessage -MockWith { } + Mock -CommandName Get-CippException -MockWith { param($Exception) @{ NormalizedError = "$Exception" } } + Mock -CommandName Test-CIPPStandardLicense -MockWith { $true } + } + + It 'classifies each lifecycle status on the happy path' { + Mock -CommandName New-GraphGetRequest -MockWith { + @( + # Interactive sign-in is old, but the successful sign-in is recent - the most + # recent of the three must win or this guest would be misreported as Stale. + [pscustomobject]@{ + id = 'g-active'; displayName = 'Active Guest'; mail = 'active@partner.com' + userPrincipalName = 'active_partner.com#EXT#@contoso.onmicrosoft.com' + createdDateTime = (Get-Date).AddDays(-400).ToString('o'); accountEnabled = $true + externalUserState = 'Accepted'; externalUserStateChangeDateTime = (Get-Date).AddDays(-399).ToString('o') + signInActivity = [pscustomobject]@{ + lastSignInDateTime = (Get-Date).AddDays(-120).ToString('o') + lastNonInteractiveSignInDateTime = $null + lastSuccessfulSignInDateTime = (Get-Date).AddDays(-4).ToString('o') + } + } + [pscustomobject]@{ + id = 'g-stale'; displayName = 'Stale Guest'; mail = 'stale@partner.com' + userPrincipalName = 'stale_partner.com#EXT#@contoso.onmicrosoft.com' + createdDateTime = (Get-Date).AddDays(-400).ToString('o'); accountEnabled = $true + externalUserState = 'Accepted'; externalUserStateChangeDateTime = $null + signInActivity = [pscustomobject]@{ + lastSignInDateTime = (Get-Date).AddDays(-120).ToString('o') + lastNonInteractiveSignInDateTime = $null + lastSuccessfulSignInDateTime = $null + } + } + [pscustomobject]@{ + id = 'g-pending'; displayName = 'Pending Guest'; mail = 'pending@partner.com' + userPrincipalName = 'pending_partner.com#EXT#@contoso.onmicrosoft.com' + createdDateTime = (Get-Date).AddDays(-10).ToString('o'); accountEnabled = $true + externalUserState = 'PendingAcceptance'; externalUserStateChangeDateTime = $null + signInActivity = $null + } + [pscustomobject]@{ + id = 'g-never'; displayName = 'Never Guest'; mail = 'never@partner.com' + userPrincipalName = 'never_partner.com#EXT#@contoso.onmicrosoft.com' + createdDateTime = (Get-Date).AddDays(-200).ToString('o'); accountEnabled = $true + externalUserState = 'Accepted'; externalUserStateChangeDateTime = $null + signInActivity = $null + } + # Disabled must win over PendingAcceptance. + [pscustomobject]@{ + id = 'g-disabled'; displayName = 'Disabled Guest'; mail = $null + userPrincipalName = 'disabled_partner.com#EXT#@contoso.onmicrosoft.com' + createdDateTime = (Get-Date).AddDays(-200).ToString('o'); accountEnabled = $false + externalUserState = 'PendingAcceptance'; externalUserStateChangeDateTime = $null + signInActivity = $null + } + ) + } + + $response = Invoke-ListGuestUsers -Request (New-GuestRequest) -TriggerMetadata $null + + $response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::OK) + $response.Body | Should -HaveCount 5 + $ByStatus = @{} + foreach ($Row in $response.Body) { $ByStatus[$Row.id] = $Row } + $ByStatus['g-active'].status | Should -Be 'Active' + $ByStatus['g-stale'].status | Should -Be 'Stale' + $ByStatus['g-pending'].status | Should -Be 'Pending Acceptance' + $ByStatus['g-never'].status | Should -Be 'Never Signed In' + $ByStatus['g-disabled'].status | Should -Be 'Disabled' + + # The reported last sign-in is the most recent of the three signInActivity fields. + $ByStatus['g-active'].daysSinceSignIn | Should -Be 4 + ([datetime]$ByStatus['g-active'].lastSignInDateTime).Date | Should -Be (Get-Date).AddDays(-4).Date + $ByStatus['g-stale'].daysSinceSignIn | Should -Be 120 + $ByStatus['g-never'].lastSignInDateTime | Should -BeNullOrEmpty + + $ByStatus['g-active'].sourceDomain | Should -Be 'partner.com' + $ByStatus['g-disabled'].sourceDomain | Should -BeNullOrEmpty + + Should -Invoke New-GraphGetRequest -Times 1 -ParameterFilter { + $uri -like "*userType eq 'Guest'*" -and $uri -like '*signInActivity*' -and $uri -like '*$top=500*' -and $tenantid -eq 'contoso.onmicrosoft.com' -and $ComplexFilter + } + } + + It 'honours the staleDays override' { + Mock -CommandName New-GraphGetRequest -MockWith { + @( + [pscustomobject]@{ + id = 'g-1'; displayName = 'Guest'; mail = 'g@partner.com' + userPrincipalName = 'g_partner.com#EXT#@contoso.onmicrosoft.com' + createdDateTime = (Get-Date).AddDays(-100).ToString('o'); accountEnabled = $true + externalUserState = 'Accepted'; externalUserStateChangeDateTime = $null + signInActivity = [pscustomobject]@{ + lastSignInDateTime = (Get-Date).AddDays(-4).ToString('o') + lastNonInteractiveSignInDateTime = $null + lastSuccessfulSignInDateTime = $null + } + } + ) + } + + $response = Invoke-ListGuestUsers -Request (New-GuestRequest -Query @{ staleDays = '1' }) -TriggerMetadata $null + + $response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::OK) + $response.Body[0].status | Should -Be 'Stale' + } + + It 'lists without signInActivity and reports Unknown on tenants without Entra P1' { + Mock -CommandName Test-CIPPStandardLicense -MockWith { $false } + Mock -CommandName New-GraphGetRequest -MockWith { + @( + [pscustomobject]@{ + id = 'g-accepted'; displayName = 'Accepted Guest'; mail = 'a@partner.com' + userPrincipalName = 'a_partner.com#EXT#@contoso.onmicrosoft.com' + createdDateTime = (Get-Date).AddDays(-100).ToString('o'); accountEnabled = $true + externalUserState = 'Accepted'; externalUserStateChangeDateTime = $null + } + [pscustomobject]@{ + id = 'g-pending'; displayName = 'Pending Guest'; mail = 'p@partner.com' + userPrincipalName = 'p_partner.com#EXT#@contoso.onmicrosoft.com' + createdDateTime = (Get-Date).AddDays(-10).ToString('o'); accountEnabled = $true + externalUserState = 'PendingAcceptance'; externalUserStateChangeDateTime = $null + } + ) + } + + $response = Invoke-ListGuestUsers -Request (New-GuestRequest) -TriggerMetadata $null + + $response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::OK) + $ByStatus = @{} + foreach ($Row in $response.Body) { $ByStatus[$Row.id] = $Row } + # Without sign-in data there is no way to tell Active from Stale - never guess. + $ByStatus['g-accepted'].status | Should -Be 'Unknown' + $ByStatus['g-pending'].status | Should -Be 'Pending Acceptance' + + Should -Invoke New-GraphGetRequest -Times 1 -ParameterFilter { + $uri -notlike '*signInActivity*' -and $uri -like '*$top=999*' + } + } + + It 'returns InternalServerError and logs when Graph fails' { + Mock -CommandName New-GraphGetRequest -MockWith { throw 'Graph exploded' } + + $response = Invoke-ListGuestUsers -Request (New-GuestRequest) -TriggerMetadata $null + + $response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::InternalServerError) + $response.Body[0].Error | Should -Match 'Graph exploded' + Should -Invoke Write-LogMessage -Times 1 -ParameterFilter { $Sev -eq 'Error' } + } +} diff --git a/frontend/src/layouts/config.js b/frontend/src/layouts/config.js index 6d35ba95d2..008147ac8b 100644 --- a/frontend/src/layouts/config.js +++ b/frontend/src/layouts/config.js @@ -44,6 +44,11 @@ export const nativeMenuItems = [ path: '/identity/administration/users', permissions: ['Identity.User.*'], }, + { + title: 'Guest Users', + path: '/identity/administration/guest-users', + permissions: ['Identity.User.*'], + }, { title: 'Risky Users', path: '/identity/administration/risky-users', diff --git a/frontend/src/pages/identity/administration/guest-users/index.js b/frontend/src/pages/identity/administration/guest-users/index.js new file mode 100644 index 0000000000..325c65a7c4 --- /dev/null +++ b/frontend/src/pages/identity/administration/guest-users/index.js @@ -0,0 +1,224 @@ +import { useMemo, useState } from 'react' +import { Layout as DashboardLayout } from '../../../../layouts/index.js' +import { CippTablePage } from '../../../../components/CippComponents/CippTablePage.jsx' +import { ApiGetCallWithPagination } from '../../../../api/ApiCall' +import { useSettings } from '../../../../hooks/use-settings' +import { + Card, + CardActionArea, + CardContent, + Skeleton, + Stack, + Typography, +} from '@mui/material' +import { Box, Grid } from '@mui/system' +import { EyeIcon } from '@heroicons/react/24/outline' +import { + Block, + CheckCircle, + GroupOutlined, + HourglassEmpty, + PersonOff, + Send, + WarningAmber, +} from '@mui/icons-material' + +const GUEST_STATUSES = [ + { status: 'Active', color: 'success', icon: CheckCircle }, + { status: 'Stale', color: 'error', icon: WarningAmber }, + { status: 'Pending Acceptance', color: 'warning', icon: HourglassEmpty }, + { status: 'Never Signed In', color: 'info', icon: PersonOff }, + { status: 'Disabled', color: 'secondary', icon: Block }, +] + +const SummaryCard = ({ + title, + count, + icon: Icon, + color, + selected, + isFetching, + onClick, +}) => ( + + + + + + + + {isFetching ? : count} + + + {title} + + + + + + +) + +const Page = () => { + const pageTitle = 'Guest Users' + const currentTenant = useSettings().currentTenant + const [statusFilter, setStatusFilter] = useState(null) + const queryKey = `ListGuestUsers-${currentTenant}` + + // Same queryKey as the table below, so react-query shares one request between + // the summary cards and the table. + const guestData = ApiGetCallWithPagination({ + url: '/api/ListGuestUsers', + data: { tenantFilter: currentTenant }, + queryKey: queryKey, + waiting: true, + }) + + const guests = useMemo( + () => + guestData.data?.pages?.flatMap((page) => + Array.isArray(page) ? page : [] + ) ?? [], + [guestData.data] + ) + + const statusCounts = useMemo(() => { + const counts = {} + for (const guest of guests) { + counts[guest.status] = (counts[guest.status] ?? 0) + 1 + } + return counts + }, [guests]) + + // The trailing column-format entry drives the table's status filter from the + // summary cards; an empty value clears it again. The named presets surface the + // same one-click filters in the table's filter menu. + const filterList = useMemo( + () => [ + ...GUEST_STATUSES.map(({ status }) => ({ + filterName: `${status} guests`, + value: [{ id: 'status', value: status }], + type: 'column', + })), + { id: 'status', value: statusFilter ?? '' }, + ], + [statusFilter] + ) + + const toggleStatusFilter = (status) => + setStatusFilter((current) => (current === status ? null : status)) + + const tableFilter = ( + + + setStatusFilter(null)} + /> + + {GUEST_STATUSES.map(({ status, color, icon }) => ( + + toggleStatusFilter(status)} + /> + + ))} + + ) + + const actions = [ + { + label: 'View User', + link: '/identity/administration/users/user?userId=[id]', + multiPost: false, + icon: , + color: 'success', + }, + { + label: 'Re-invite Guest', + type: 'POST', + icon: , + url: '/api/AddGuest', + data: { displayName: 'displayName', mail: 'mail', sendInvite: '!true' }, + confirmText: 'Are you sure you want to re-send the invitation to [mail]?', + multiPost: false, + condition: (row) => + !!row.mail && + (row.status === 'Pending Acceptance' || row.status === 'Stale'), + }, + ] + + const offCanvas = { + extendedInfoFields: [ + 'displayName', + 'userPrincipalName', + 'mail', + 'id', + 'status', + 'externalUserState', + 'externalUserStateChangeDateTime', + 'createdDateTime', + 'lastSignInDateTime', + 'lastInteractiveSignInDateTime', + 'lastNonInteractiveSignInDateTime', + 'lastSuccessfulSignInDateTime', + 'daysSinceSignIn', + 'accountEnabled', + 'sourceDomain', + ], + actions: actions, + } + + const simpleColumns = [ + 'displayName', + 'mail', + 'sourceDomain', + 'status', + 'accountEnabled', + 'createdDateTime', + 'lastSignInDateTime', + 'daysSinceSignIn', + ] + + return ( + + ) +} + +Page.getLayout = (page) => ( + {page} +) + +export default Page From ef20e301c02e14b84a2b719cb4cc428ffc2fc271 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Tue, 18 Aug 2026 19:51:01 +0800 Subject: [PATCH 138/226] fix(sharepoint): marker-based completion and per-drive fan-out for sharing-links scan The scan-row pending counter and FailedSites JSON list are replaced with insert-only marker rows: the failed-site list overflowed the 64KB table property cap at ~315 SharePoint composite site ids, after which every counter update failed, the counter never reached zero and finalisation never ran. Markers cannot lose completions to write conflicts and have no aggregate size cap. Site tasks now dispatch one resumable task per drive. Drive tasks timebox themselves (1100s under Craft, 9 minutes otherwise, override via CIPP_SHARINGLINKS_TIMEBOX_SECONDS) and requeue from their page checkpoint, since a task that hits the platform kill limit is failed without retry. Full scans of non-personal sites enumerate the backing list with the hidden PrincipalCount field and permission-read only items with extra role assignments - on group-connected team sites the delta shared facet is true for every item, which made the classic path one permission read per item and the dominant throttling source. The delta position is captured afterwards with token=latest so subsequent scans stay incremental; OneDrive and ForceFullSync keep the classic delta walk. The Preservation Hold Library is skipped by URL segment, and a full scan that had permission reads throttled away keeps existing rows and defers its sweep instead of pruning items it failed to read. --- ...Push-DBCacheSharePointSiteSharingLinks.ps1 | 823 +++++++++++------- .../Push-StoreSharePointSharingLinks.ps1 | 18 +- .../Set-CIPPDBCacheSharePointSharingLinks.ps1 | 31 +- .../SharePointSharingLinks.Resume.Tests.ps1 | 360 +++++--- 4 files changed, 748 insertions(+), 484 deletions(-) diff --git a/backend/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/SharePoint Sharing/Push-DBCacheSharePointSiteSharingLinks.ps1 b/backend/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/SharePoint Sharing/Push-DBCacheSharePointSiteSharingLinks.ps1 index d77f6d5119..2d97d858cc 100644 --- a/backend/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/SharePoint Sharing/Push-DBCacheSharePointSiteSharingLinks.ps1 +++ b/backend/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/SharePoint Sharing/Push-DBCacheSharePointSiteSharingLinks.ps1 @@ -1,42 +1,64 @@ function Push-DBCacheSharePointSiteSharingLinks { <# .SYNOPSIS - Scans a single SharePoint/OneDrive site for sharing links, resumably. + Scans SharePoint/OneDrive sharing links: site tasks fan out per drive, drive tasks scan + one drive resumably. .DESCRIPTION - Processes one site (fanned out by Set-CIPPDBCacheSharePointSharingLinks). Enumerates the - site's drives and scans each for shared items, writing sharing-link rows straight to the - reporting DB page by page. The activity runs to completion - there is deliberately no - internal time budget or self-requeue; bounding runtime is the platform's job, not the - scan's. - - What makes that safe on sites of any size: - - Checkpointing — after every page whose rows have been persisted, the drive's next delta - page URL is saved (along with which drives already finished). A run killed by a timeout, - recycle or crash loses at most one page: re-dispatching the same task resumes exactly - where the dead run stopped. That re-dispatch is the retry mechanism's contract - any - task-level retry (runtime or scheduler) can fire the same payload again at any time. - - Idempotent completion — a retried task can race a still-alive original, so counting a - site against the scan's pending counter is guarded by a first-writer-wins marker row. - However often a site's task is dispatched, it decrements the counter exactly once; - without that, a duplicate would drive the counter to zero early and finalisation would - prune rows of sites still mid-scan. - - Delta persistence — when a drive completes, its Graph deltaLink is stored. The next scan - replays only items changed since (tombstoning each changed item's old rows and re-reading - its permissions) instead of enumerating the whole drive. A drive falls back to a full scan - when its token is rejected (resyncRequired), when its last full scan is older than - CIPP_SHARINGLINKS_FULLSCAN_DAYS (default 14, bounding drift from any change delta misses), - or when the sync was started with ForceFullSync. - - Scan progress lives in the CippSharingLinksState table (see the fan-out parent for the - row layout). The single-caller state operations - checkpoint CRUD, drive-state writes and - the completion counter - are nested functions here rather than module functions, so only - genuinely shared helpers exist as files. The activity that completes the tenant's last - pending site runs Push-StoreSharePointSharingLinks to prune rows of vanished drives and - refresh the count. + One activity, two roles, discriminated by the payload: + + Site task ($Item.DriveId absent) - lists the site's document libraries, records how many + drive tasks the site owns (drives-{site} row), and dispatches one drive task per drive + through a child orchestration. Sites whose drives cannot be listed (locked/blocked sites, + throttling) complete immediately as failed. The Preservation Hold Library is skipped by + URL segment: it is a hidden system library that cannot carry sharing links and is often + by far the largest drive on the site. + + Drive task ($Item.DriveId present) - scans one drive for shared items and writes + sharing-link rows to the reporting DB page by page. Three scan modes: + + Principal - full scan of a non-personal site's drive. Enumerates the backing list + with the hidden PrincipalCount field (999 rows per request); only items + whose principal count differs from the drive's inherited baseline have + extra role assignments (sharing links, direct grants), and only those get + a batched driveItem + permissions read. On group-connected team sites the + delta 'shared' facet is true for EVERY item (group access), so the classic + path costs one permission read per item; this path replaces that with + items/999 list pages + a permission read per actually-shared item. + The drive's deltaLink is captured afterwards via delta?token=latest so the + next scan runs incrementally. + Full - classic delta walk reading permissions for every shared-facet item. Used + for personal sites (OneDrive only flags genuinely shared items) and for + ForceFullSync, where it serves as the ground-truth deep scan. + Incremental - delta from the stored token; only changed items are processed. Changed + items' existing rows are tombstoned and re-added from a fresh permission + read. + + Timebox - a drive task that exceeds CIPP_SHARINGLINKS_TIMEBOX_SECONDS (default 900) + checkpoints and re-dispatches itself instead of running into the platform kill limit + (Worker:BgTimeoutSeconds, default 1200): the runtime marks a timed-out task Failed + without retry, so the task must yield before that. The checkpoint written after every + persisted page means a re-dispatched task loses at most one page. + + Completion is tracked with insert-only marker rows (first writer wins), never counters: + a scan-row counter was abandoned because concurrent decrements lost ETag races, and the + companion failed-site list overflowed Azure Table's 64KB property cap at ~315 SharePoint + composite site ids, silently losing decrements and leaving scans uncompletable. + + CippSharingLinksState rows (PartitionKey = tenant): + scan scan identity: ScanId, TotalSites, FullSweep, StartedUtc + drives-{site} site's dispatched drive-task count for this scan + ddone-{site}~{drive} drive task completion marker (idempotent insert) + done-{site} site completion marker; Failed=true means the SITE failed + (drives could not be listed) - drive-level failures instead + keep their delta-state row current, which by itself protects + their cached rows from finalisation pruning + final finalisation claim marker (one finaliser per scan) + chk-{site}~{drive} drive task resume position, ScanId-gated + delta-{drive} per-drive delta token + last-scan bookkeeping + + The activity that completes the tenant's last pending site claims the 'final' marker and + runs Push-StoreSharePointSharingLinks inline. .FUNCTIONALITY Entrypoint @@ -55,8 +77,12 @@ function Push-DBCacheSharePointSiteSharingLinks { $FullScanDays = 14 if ($env:CIPP_SHARINGLINKS_FULLSCAN_DAYS -match '^\d+$') { $FullScanDays = [Math]::Max(1, [int]$env:CIPP_SHARINGLINKS_FULLSCAN_DAYS) } + # Re-dispatch budget: stay under the platform kill limit with room to finish the current + # page - Craft kills background tasks at Worker:BgTimeoutSeconds (1200s), the Functions + # consumption plan at 10 minutes. + $TimeboxSeconds = if ($env:CIPPNG -eq 'true') { 1100 } else { 540 } + if ($env:CIPP_SHARINGLINKS_TIMEBOX_SECONDS -match '^\d+$') { $TimeboxSeconds = [Math]::Max(1, [int]$env:CIPP_SHARINGLINKS_TIMEBOX_SECONDS) } - # Verified domains passed from the parent; used to tell internal from external recipients. $InternalDomains = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) foreach ($Domain in @($Item.InternalDomains)) { if ($Domain) { [void]$InternalDomains.Add([string]$Domain) } } @@ -79,12 +105,79 @@ function Push-DBCacheSharePointSiteSharingLinks { $Identity.user.email ?? $Identity.user.userPrincipalName ?? $Identity.siteUser.email ?? $Identity.user.displayName ?? $Identity.siteUser.displayName ?? $Identity.group.email ?? $Identity.group.displayName ?? $Identity.siteGroup.displayName } - # Fetch permissions for a buffer of shared items and append their sharing-link rows to $RowsOut. + # Converts one item's permission array into report rows. Shared by every scan mode; the only + # difference between modes is where the permissions came from. + function ConvertTo-CIPPSharingRow { + param($Permissions, $DriveItem, $Drive, $Site, $InternalDomains, $RowsOut) + foreach ($Permission in @($Permissions)) { + # Only permissions set on the item itself; inherited ones are reported on their parent. + if ($Permission.inheritedFrom) { continue } + + if ($Permission.link) { + $Recipients = @($Permission.grantedToIdentitiesV2 ?? $Permission.grantedToIdentities) + $LinkScope = $Permission.link.scope ?? 'users' + $Classification = switch ($LinkScope) { + 'anonymous' { 'Anonymous' } + 'organization' { 'Internal' } + 'existingAccess' { 'Internal' } + default { + $HasExternal = $false + foreach ($Recipient in $Recipients) { + if (Test-CIPPExternalIdentity -Identity $Recipient -InternalDomains $InternalDomains) { $HasExternal = $true; break } + } + if ($HasExternal) { 'External' } else { 'Internal' } + } + } + $LinkType = $Permission.link.type ?? 'link' + $LinkUrl = $Permission.link.webUrl + } else { + # Direct grant (no sharing link): only report grants to external users. + $Recipients = @($Permission.grantedToV2 ?? $Permission.grantedTo) + if ($Permission.roles -contains 'owner') { continue } + $HasExternal = $false + foreach ($Recipient in $Recipients) { + if (Test-CIPPExternalIdentity -Identity $Recipient -InternalDomains $InternalDomains) { $HasExternal = $true; break } + } + if (-not $HasExternal) { continue } + $Classification = 'External' + $LinkScope = 'direct' + $LinkType = 'directGrant' + $LinkUrl = $null + } + + $SharedWith = @($Recipients | ForEach-Object { Get-CIPPIdentityLabel -Identity $_ } | Where-Object { $_ } | Sort-Object -Unique) + + $RowsOut.Add([PSCustomObject]@{ + id = "$($Drive.id)_$($DriveItem.id)_$($Permission.id)" + siteId = $Site.SiteId + siteName = $Site.SiteName + siteUrl = $Site.SiteUrl + workload = if ($Site.IsPersonalSite) { 'OneDrive' } else { 'SharePoint' } + driveId = $Drive.id + driveName = $Drive.name + itemId = $DriveItem.id + fileName = $DriveItem.name + itemUrl = $DriveItem.webUrl + itemType = if ($DriveItem.folder) { 'Folder' } else { 'File' } + size = $DriveItem.size + lastModifiedDateTime = $DriveItem.lastModifiedDateTime + permissionId = $Permission.id + linkType = $LinkType + linkScope = $LinkScope + classification = $Classification + roles = @($Permission.roles) + sharedWith = $SharedWith + linkUrl = $LinkUrl + hasPassword = $Permission.hasPassword ?? $false + expirationDateTime = $Permission.expirationDateTime + }) + } + } + + # Fetch permissions for a buffer of shared delta items and append their rows to $RowsOut. function Add-CIPPSharingRows { param($Buffer, $Drive, $Site, $InternalDomains, $TenantFilter, $RowsOut) - if (@($Buffer).Count -eq 0) { return } - $ItemByRequestId = @{} $RequestId = 0 $PermissionRequests = foreach ($SharedItem in $Buffer) { @@ -96,97 +189,172 @@ function Push-DBCacheSharePointSiteSharingLinks { } $RequestId++ } - $PermissionResponses = New-GraphBulkRequest -tenantid $TenantFilter -Requests @($PermissionRequests) -asapp $true foreach ($Response in $PermissionResponses) { if ($Response.status -and $Response.status -ne 200) { continue } $DriveItem = $ItemByRequestId["$($Response.id)"] - - foreach ($Permission in @($Response.body.value)) { - # Only permissions set on the item itself; inherited ones are reported on their parent. - if ($Permission.inheritedFrom) { continue } - - if ($Permission.link) { - $Recipients = @($Permission.grantedToIdentitiesV2 ?? $Permission.grantedToIdentities) - $LinkScope = $Permission.link.scope ?? 'users' - $Classification = switch ($LinkScope) { - 'anonymous' { 'Anonymous' } - 'organization' { 'Internal' } - 'existingAccess' { 'Internal' } - default { - $HasExternal = $false - foreach ($Recipient in $Recipients) { - if (Test-CIPPExternalIdentity -Identity $Recipient -InternalDomains $InternalDomains) { $HasExternal = $true; break } - } - if ($HasExternal) { 'External' } else { 'Internal' } - } - } - $LinkType = $Permission.link.type ?? 'link' - $LinkUrl = $Permission.link.webUrl - } else { - # Direct grant (no sharing link): only report grants to external users. - $Recipients = @($Permission.grantedToV2 ?? $Permission.grantedTo) - if ($Permission.roles -contains 'owner') { continue } - $HasExternal = $false - foreach ($Recipient in $Recipients) { - if (Test-CIPPExternalIdentity -Identity $Recipient -InternalDomains $InternalDomains) { $HasExternal = $true; break } - } - if (-not $HasExternal) { continue } - $Classification = 'External' - $LinkScope = 'direct' - $LinkType = 'directGrant' - $LinkUrl = $null - } - - $SharedWith = @($Recipients | ForEach-Object { Get-CIPPIdentityLabel -Identity $_ } | Where-Object { $_ } | Sort-Object -Unique) - - $RowsOut.Add([PSCustomObject]@{ - id = "$($Drive.id)_$($DriveItem.id)_$($Permission.id)" - siteId = $Site.SiteId - siteName = $Site.SiteName - siteUrl = $Site.SiteUrl - workload = if ($Site.IsPersonalSite) { 'OneDrive' } else { 'SharePoint' } - driveId = $Drive.id - driveName = $Drive.name - itemId = $DriveItem.id - fileName = $DriveItem.name - itemUrl = $DriveItem.webUrl - itemType = if ($DriveItem.folder) { 'Folder' } else { 'File' } - size = $DriveItem.size - lastModifiedDateTime = $DriveItem.lastModifiedDateTime - permissionId = $Permission.id - linkType = $LinkType - linkScope = $LinkScope - classification = $Classification - roles = @($Permission.roles) - sharedWith = $SharedWith - linkUrl = $LinkUrl - hasPassword = $Permission.hasPassword ?? $false - expirationDateTime = $Permission.expirationDateTime - }) - } + ConvertTo-CIPPSharingRow -Permissions @($Response.body.value) -DriveItem $DriveItem -Drive $Drive -Site $Site -InternalDomains $InternalDomains -RowsOut $RowsOut } } # --- scan-state plumbing -------------------------------------------------------------------- - # These read the surrounding activity's variables ($StateTable, $SafeTenant, $ScanId, ...) - # directly; they exist to keep the call sites in the scan loop readable, not to be reused. + # These read the surrounding activity's variables directly; they exist to keep the call sites + # readable, not to be reused. $StateTable = Get-CippTable -tablename 'CippSharingLinksState' $SafeTenant = ConvertTo-CIPPODataFilterValue -Value $TenantFilter -Type String $SiteKeySegment = ConvertTo-CIPPSharingLinksKeySegment -Value $SiteId - $CheckpointRowKey = "chk-$SiteKeySegment" function Get-ScanRow { Get-CIPPAzDataTableEntity @StateTable -Filter "PartitionKey eq '$SafeTenant' and RowKey eq 'scan'" } - function Get-SiteCheckpoint { + # Insert-only marker write. Returns $true when THIS caller created the marker for the current + # scan - the idempotency primitive completion tracking is built on. A leftover marker from a + # superseded scan that slipped past the parent's cleanup is taken over and counts as created. + function Add-ScanMarker { + param([string]$RowKey, [hashtable]$Extra = @{}) + $Marker = @{ PartitionKey = $TenantFilter; RowKey = $RowKey; ScanId = $ScanId } + $Extra + try { + Add-CIPPAzDataTableEntity @StateTable -Entity $Marker -ErrorAction Stop + return $true + } catch { + $Existing = Get-CIPPAzDataTableEntity @StateTable -Filter "PartitionKey eq '$SafeTenant' and RowKey eq '$(ConvertTo-CIPPODataFilterValue -Value $RowKey -Type String)'" + if ($Existing -and [string]$Existing.ScanId -eq $ScanId) { return $false } + Add-CIPPAzDataTableEntity @StateTable -Entity $Marker -Force + return $true + } + } + + function Get-ScanMarkers { + param([string]$Prefix) + @(Get-CIPPAzDataTableEntity @StateTable -Filter ("PartitionKey eq '{0}' and RowKey ge '{1}' and RowKey lt '{1}~~'" -f $SafeTenant, $Prefix) -Property @('PartitionKey', 'RowKey', 'ScanId', 'Failed')) | + Where-Object { [string]$_.ScanId -eq $ScanId } + } + + # Marks this site finished and runs finalisation if it was the last pending one. Idempotent: + # the site marker is an insert (first writer wins), so duplicate dispatches count a site once; + # the 'final' marker guarantees exactly one finaliser per scan. No counters anywhere - the + # set of markers IS the completion state, so nothing can be lost to write conflicts. + function Complete-Site { + param([switch]$Failed) + # A task from a scan that has since been superseded must not write markers - the current + # scan owns them. + $CurrentScan = Get-ScanRow + if (-not $CurrentScan -or [string]$CurrentScan.ScanId -ne $ScanId) { return } + + $Created = Add-ScanMarker -RowKey "done-$SiteKeySegment" -Extra @{ + Failed = [bool]$Failed + CompletedUtc = [string]([DateTimeOffset]::UtcNow.ToString('o')) + } + if (-not $Created) { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Sharing links: duplicate completion of '$SiteUrl' suppressed (scan $ScanId)" -sev Debug + return + } + + $DoneCount = @(Get-ScanMarkers -Prefix 'done-').Count + if ($DoneCount -lt [int]$CurrentScan.TotalSites) { return } + + # Last site out claims finalisation; a concurrent completer that lost the claim skips. + if (Add-ScanMarker -RowKey 'final') { + Push-StoreSharePointSharingLinks -TenantFilter $TenantFilter -ScanId $ScanId + } + } + + # A task from a superseded scan has nothing valid to do; a fresh scan owns the state rows. + $Scan = Get-ScanRow + if (-not $Scan -or [string]$Scan.ScanId -ne $ScanId) { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Sharing links: skipping '$SiteUrl' - scan $ScanId superseded" -sev Debug + return @() + } + + $SiteContext = [PSCustomObject]@{ + SiteId = $SiteId + SiteName = $SiteName + SiteUrl = $SiteUrl + IsPersonalSite = $IsPersonalSite + } + + # ================================ SITE TASK: fan out per drive ============================== + if (-not $Item.DriveId) { + try { + $Drives = @() + try { + $Drives = @(New-GraphGetRequest -uri "https://graph.microsoft.com/beta/sites/$SiteId/drives?`$select=id,name,driveType,webUrl" -tenantid $TenantFilter -asapp $true) + } catch { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Sharing links: could not list drives for '$SiteUrl': $($_.Exception.Message)" -sev Warning + Complete-Site -Failed + return @() + } + + # The Preservation Hold Library holds retained copies users cannot share from; it is + # frequently the biggest drive on the site and pure cost. Matched on the URL segment + # because the display name is localised. + $Drives = @($Drives | Where-Object { $_.id -and [string]$_.webUrl -notmatch '/PreservationHoldLibrary/?$' }) + + if ($Drives.Count -eq 0) { + Complete-Site + return @() + } + + # Record the drive-task total BEFORE dispatching: a drive task finishing first must + # be able to see how many siblings it has. + Add-CIPPAzDataTableEntity @StateTable -Entity @{ + PartitionKey = $TenantFilter + RowKey = "drives-$SiteKeySegment" + ScanId = $ScanId + DriveCount = [int]$Drives.Count + } -Force + + $Batch = foreach ($Drive in $Drives) { + [PSCustomObject]@{ + FunctionName = 'DBCacheSharePointSiteSharingLinks' + TenantFilter = $TenantFilter + SiteId = $SiteId + SiteName = $SiteName + SiteUrl = $SiteUrl + IsPersonalSite = $IsPersonalSite + InternalDomains = @($InternalDomains) + ScanId = $ScanId + DriveId = [string]$Drive.id + DriveName = [string]$Drive.name + ForceFull = $ForceFull + QueueId = $Item.QueueId + QueueName = "Sharing Links - $($Drive.name) - $SiteUrl" + } + } + if ($Item.QueueId) { + try { + Update-CippQueueEntry -RowKey $Item.QueueId -TotalTasks $Drives.Count -IncrementTotalTasks + } catch { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Sharing links: could not update queue $($Item.QueueId) with drive tasks: $($_.Exception.Message)" -sev Debug + } + } + $null = Start-CIPPOrchestrator -InputObject ([PSCustomObject]@{ + Batch = @($Batch) + OrchestratorName = "SharingLinksDrives_$($TenantFilter)_$([guid]::NewGuid().ToString('N').Substring(0, 8))" + SkipLog = $true + }) + return @() + } catch { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Sharing links: failed dispatching drives for '$SiteUrl': $($_.Exception.Message)" -sev Error -LogData (Get-CippException -Exception $_) + Complete-Site -Failed + return @() + } + } + + # ================================ DRIVE TASK: scan one drive ================================ + $Drive = [PSCustomObject]@{ id = [string]$Item.DriveId; name = [string]$Item.DriveName } + $DriveKeySegment = ConvertTo-CIPPSharingLinksKeySegment -Value "$($Drive.id)" + $CheckpointRowKey = "chk-$SiteKeySegment~$DriveKeySegment" + $Stopwatch = [System.Diagnostics.Stopwatch]::StartNew() + + function Get-DriveCheckpoint { $Row = Get-CIPPAzDataTableEntity @StateTable -Filter "PartitionKey eq '$SafeTenant' and RowKey eq '$CheckpointRowKey'" if (-not $Row -or [string]$Row.ScanId -ne $ScanId) { return $null } try { ($Row.StateJson | ConvertFrom-Json -ErrorAction Stop) } catch { $null } } - function Save-SiteCheckpoint { + function Save-DriveCheckpoint { param($State) Add-CIPPAzDataTableEntity @StateTable -Entity @{ PartitionKey = $TenantFilter @@ -196,23 +364,23 @@ function Push-DBCacheSharePointSiteSharingLinks { } -Force } - function Remove-SiteCheckpoint { + function Remove-DriveCheckpoint { $Row = Get-CIPPAzDataTableEntity @StateTable -Filter "PartitionKey eq '$SafeTenant' and RowKey eq '$CheckpointRowKey'" if ($Row) { Remove-CIPPAzDataTableEntity @StateTable -Entity $Row -Force } } - # Records a drive's scan outcome: delta token and which scan last saw it. Called on success - # AND failure - LastScanId is how finalisation tells a failed drive (keep its rows one more - # cycle) from a deleted one (prune). An empty DeltaLink forces the next scan to run full. + # Records the drive's scan outcome: delta token and which scan last saw it. Called on success + # AND failure - a current LastScanId is what protects a failed drive's cached rows from + # finalisation pruning. An empty DeltaLink forces the next scan to run full. function Set-DriveState { - param([string]$DriveId, [AllowEmptyString()][string]$DeltaLink = '', [switch]$FullScan) + param([AllowEmptyString()][string]$DeltaLink = '', [switch]$FullScan) $NowUtc = [string]([DateTimeOffset]::UtcNow.ToString('o')) - $Existing = Get-CIPPSharingLinksDriveState -TenantFilter $TenantFilter -DriveId $DriveId + $Existing = Get-CIPPSharingLinksDriveState -TenantFilter $TenantFilter -DriveId $Drive.id $LastFullScanUtc = if ($FullScan) { $NowUtc } else { [string]($Existing.LastFullScanUtc ?? '') } Add-CIPPAzDataTableEntity @StateTable -Entity @{ PartitionKey = $TenantFilter - RowKey = "delta-$(ConvertTo-CIPPSharingLinksKeySegment -Value $DriveId)" - DriveId = $DriveId + RowKey = "delta-$DriveKeySegment" + DriveId = [string]$Drive.id SiteId = $SiteId DeltaLink = [string]$DeltaLink LastScanId = $ScanId @@ -221,252 +389,251 @@ function Push-DBCacheSharePointSiteSharingLinks { } -Force } - # Marks this site finished (successfully or failed) and runs finalisation if it was the last - # pending one. Idempotent: the marker row is an insert (first writer wins), so however many - # times a retry mechanism dispatches this site, the counter is decremented exactly once - a - # duplicate decrement would reach zero early and finalisation would prune rows of sites that - # are still scanning. The decrement itself is ETag-conditional so two DIFFERENT sites - # finishing at once cannot both write the same counter value; the losing writer rereads and - # retries. A superseded scan or a persistent write conflict must never finalise. - function Complete-Site { - param([switch]$Failed) - # A task from a scan that has since been superseded must not write markers or touch - # counters - the current scan owns them. - $CurrentScan = Get-ScanRow - if (-not $CurrentScan -or [string]$CurrentScan.ScanId -ne $ScanId) { return } - - $Marker = @{ - PartitionKey = $TenantFilter - RowKey = "done-$SiteKeySegment" - ScanId = $ScanId - Failed = [bool]$Failed - CompletedUtc = [string]([DateTimeOffset]::UtcNow.ToString('o')) - } - try { - # Insert, not upsert: failing on an existing marker IS the duplicate detection. - Add-CIPPAzDataTableEntity @StateTable -Entity $Marker -ErrorAction Stop - } catch { - # Conflict: either this site was already counted against the current scan (a retry - # racing the original - suppress), or the marker is a leftover of a superseded scan - # that slipped past the parent's cleanup - take it over and count normally. - $Existing = Get-CIPPAzDataTableEntity @StateTable -Filter "PartitionKey eq '$SafeTenant' and RowKey eq 'done-$SiteKeySegment'" - if ($Existing -and [string]$Existing.ScanId -eq $ScanId) { - Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Sharing links: duplicate completion of '$SiteUrl' suppressed (scan $ScanId)" -sev Debug - return - } - Add-CIPPAzDataTableEntity @StateTable -Entity $Marker -Force - } - $Pending = $null - for ($Attempt = 0; $Attempt -lt 10; $Attempt++) { - $ScanRow = Get-ScanRow - if (-not $ScanRow -or [string]$ScanRow.ScanId -ne $ScanId) { return } - $ScanRow.PendingSites = [int]$ScanRow.PendingSites - 1 - if ($Failed) { - $FailedList = @() - try { $FailedList = @($ScanRow.FailedSites | ConvertFrom-Json -ErrorAction Stop) } catch {} - # Capped so the property can never outgrow a table column; the per-site log entry - # carries the detail, and finalisation only needs membership. - if ($FailedList.Count -lt 500) { $FailedList = @($FailedList) + $SiteId } - $ScanRow.FailedSites = [string](ConvertTo-Json @($FailedList) -Compress) - } - try { - # -ErrorAction Stop is load-bearing: the cmdlet reports an ETag conflict (412) - # as a NON-terminating error, which would sail past this catch, skip the retry - # and silently lose the decrement - leaving the counter stuck above zero and - # finalisation never running. - $null = Update-AzDataTableEntity @StateTable -Entity $ScanRow -ErrorAction Stop - $Pending = [int]$ScanRow.PendingSites - break - } catch { - Start-Sleep -Milliseconds (Get-Random -Minimum 50 -Maximum 250) - } - } - if ($null -eq $Pending) { - Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Sharing links: could not update scan counter for scan $ScanId after 10 attempts; finalisation may not run this scan" -sev Warning - return - } - if ($Pending -le 0) { - Push-StoreSharePointSharingLinks -TenantFilter $TenantFilter -ScanId $ScanId - } + # Marks this drive's task complete; when it is the site's last one, completes the site. + function Complete-Drive { + if (-not (Add-ScanMarker -RowKey "ddone-$SiteKeySegment~$DriveKeySegment")) { return } + $DrivesRow = Get-CIPPAzDataTableEntity @StateTable -Filter "PartitionKey eq '$SafeTenant' and RowKey eq 'drives-$SiteKeySegment'" + if (-not $DrivesRow -or [string]$DrivesRow.ScanId -ne $ScanId) { return } + $DoneDrives = @(Get-ScanMarkers -Prefix "ddone-$SiteKeySegment~").Count + if ($DoneDrives -ge [int]$DrivesRow.DriveCount) { Complete-Site } } - # A task from a superseded scan has nothing valid to resume; a fresh scan owns the state - # rows now. Exit without touching counters. - $Scan = Get-ScanRow - $ScanActive = $Scan -and [string]$Scan.ScanId -eq $ScanId - if (-not $ScanActive) { - Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Sharing links: skipping '$SiteUrl' - scan $ScanId superseded" -sev Debug - return @() + # Checkpoints the position, re-dispatches this drive task and returns $true when the timebox + # is spent. The platform kills tasks at Worker:BgTimeoutSeconds WITHOUT retrying them, so a + # long drive must yield on its own; the fresh task resumes from the checkpoint. + function Invoke-TimeboxRequeue { + param($State) + if ($Stopwatch.Elapsed.TotalSeconds -lt $TimeboxSeconds) { return $false } + Save-DriveCheckpoint -State $State + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Sharing links: timebox reached on drive '$($Drive.name)' ($SiteUrl); requeueing to resume" -sev Debug + $null = Start-CIPPOrchestrator -InputObject ([PSCustomObject]@{ + Batch = @($Item) + OrchestratorName = "SharingLinksResume_$($TenantFilter)_$([guid]::NewGuid().ToString('N').Substring(0, 8))" + SkipLog = $true + }) + return $true } - try { - # 1) Drives (document libraries) for this one site. - $Drives = @() - try { - $Drives = @(New-GraphGetRequest -uri "https://graph.microsoft.com/beta/sites/$SiteId/drives?`$select=id,name,driveType,webUrl" -tenantid $TenantFilter -asapp $true) - } catch { - Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Sharing links: could not list drives for '$SiteUrl': $($_.Exception.Message)" -sev Warning - Complete-Site -Failed - return @() - } + $DeltaSelect = 'id,name,webUrl,folder,shared,deleted,size,lastModifiedDateTime' + $FullDeltaUri = "https://graph.microsoft.com/beta/drives/$($Drive.id)/root/delta?`$select=$DeltaSelect&`$top=999" - $SiteContext = [PSCustomObject]@{ - SiteId = $SiteId - SiteName = $SiteName - SiteUrl = $SiteUrl - IsPersonalSite = $IsPersonalSite - } - - # Resume position from an earlier (killed or retried) run of this site, if any. - $Checkpoint = Get-SiteCheckpoint - $CompletedDrives = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) - foreach ($Done in @($Checkpoint.CompletedDrives)) { if ($Done) { [void]$CompletedDrives.Add([string]$Done) } } - - $DeltaSelect = 'id,name,webUrl,folder,shared,deleted,size,lastModifiedDateTime' - - # 2) Scan each drive, page by page, persisting rows and checkpointing as we go. - foreach ($Drive in $Drives) { - if (-not $Drive.id) { continue } - if ($CompletedDrives.Contains([string]$Drive.id)) { continue } - - $DriveKeySegment = ConvertTo-CIPPSharingLinksKeySegment -Value "$($Drive.id)" - $FullDeltaUri = "https://graph.microsoft.com/beta/drives/$($Drive.id)/root/delta?`$select=$DeltaSelect&`$top=999" - - # Where does this drive start: mid-drive checkpoint > stored delta token > full scan. - $Mode = 'Full' - $Uri = $FullDeltaUri - if ($Checkpoint -and [string]$Checkpoint.CurrentDriveId -eq [string]$Drive.id -and $Checkpoint.CurrentUri) { - $Mode = [string]$Checkpoint.CurrentMode - $Uri = [string]$Checkpoint.CurrentUri - } elseif (-not $ForceFull) { - $DriveState = Get-CIPPSharingLinksDriveState -TenantFilter $TenantFilter -DriveId $Drive.id - $LastFull = $(try { [DateTimeOffset]::Parse([string]$DriveState.LastFullScanUtc) } catch { [DateTimeOffset]::MinValue }) - if ($DriveState.DeltaLink -and $LastFull -gt [DateTimeOffset]::UtcNow.AddDays(-$FullScanDays)) { - $Mode = 'Incremental' - $Uri = [string]$DriveState.DeltaLink - } + try { + # Where does this drive start: checkpoint > stored delta token > full scan. Full scans of + # non-personal sites use the PrincipalCount path unless this is a forced ground-truth + # sync; OneDrive keeps the classic path because its shared facet is already selective. + $Checkpoint = Get-DriveCheckpoint + $Mode = if ($IsPersonalSite -or $ForceFull) { 'Full' } else { 'Principal' } + $Uri = $null + $Baseline = $null + if ($Checkpoint -and $Checkpoint.CurrentUri) { + $Mode = [string]$Checkpoint.CurrentMode + $Uri = [string]$Checkpoint.CurrentUri + $Baseline = $Checkpoint.Baseline + } elseif (-not $ForceFull) { + $DriveState = Get-CIPPSharingLinksDriveState -TenantFilter $TenantFilter -DriveId $Drive.id + $LastFull = $(try { [DateTimeOffset]::Parse([string]$DriveState.LastFullScanUtc) } catch { [DateTimeOffset]::MinValue }) + if ($DriveState.DeltaLink -and $LastFull -gt [DateTimeOffset]::UtcNow.AddDays(-$FullScanDays)) { + $Mode = 'Incremental' + $Uri = [string]$DriveState.DeltaLink } + } - # Incremental scans tombstone every changed item's existing rows before re-adding the - # ones it still carries. One keys-only read up front replaces a per-item query: the - # itemId is recoverable from the RowKey because it sits between the known drive - # prefix and the next '_' (SPO item ids never contain underscores). - $ExistingRowsByItem = $null - if ($Mode -eq 'Incremental') { - $ExistingRowsByItem = @{} - $DrivePrefix = "$CacheType-${DriveKeySegment}_" - foreach ($Row in (Get-CIPPSharingLinksRowKeysByPrefix -TenantFilter $TenantFilter -Prefix $DrivePrefix)) { - if (-not $Row.RowKey) { continue } - $Suffix = ([string]$Row.RowKey).Substring($DrivePrefix.Length) - $ItemKey = $Suffix.Split('_')[0] - if (-not $ExistingRowsByItem.ContainsKey($ItemKey)) { $ExistingRowsByItem[$ItemKey] = [System.Collections.Generic.List[object]]::new() } - $ExistingRowsByItem[$ItemKey].Add($Row) + # ---------------- Principal mode: list enumeration filtered on PrincipalCount ---------- + if ($Mode -eq 'Principal') { + try { + if (-not $Uri) { + $List = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/drives/$($Drive.id)/list?`$select=id" -tenantid $TenantFilter -asapp $true + if (-not $List.id) { throw 'drive has no backing list' } + # Baseline = the number of principals an item inherits when nothing was ever + # shared on it. The drive root's permission objects are exactly that set. + $RootPermissions = @(New-GraphGetRequest -uri "https://graph.microsoft.com/beta/drives/$($Drive.id)/root/permissions?`$select=id" -tenantid $TenantFilter -asapp $true) + $Baseline = [int]$RootPermissions.Count + $Uri = "https://graph.microsoft.com/beta/sites/$SiteId/lists/$($List.id)/items?`$top=999&`$select=id&`$expand=fields(`$select=PrincipalCount)" } - } - $DeltaLink = $null - $DriveFailed = $false - while ($Uri) { - try { + $DroppedReads = 0 + while ($Uri) { $Page = New-GraphGetRequest -uri $Uri -tenantid $TenantFilter -asapp $true -noPagination $true -SkipValueExtraction - } catch { - $ErrorMessage = $_.Exception.Message - if ($Mode -eq 'Incremental' -and $ErrorMessage -match 'resync|SyncStateNotFound|Gone|410') { - # Token invalidated server-side; the drive needs a fresh full enumeration. - Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Sharing links: delta token for drive '$($Drive.name)' on '$SiteUrl' expired; falling back to full scan" -sev Debug - $Mode = 'Full' - $Uri = $FullDeltaUri - $ExistingRowsByItem = $null - continue + + # An item whose principal count deviates from the inherited baseline carries + # extra (or unusual) role assignments; the permission read is the ground truth + # that filters inherited-only false positives back out. + $FlaggedIds = [System.Collections.Generic.List[string]]::new() + foreach ($ListItem in @($Page.value)) { + if ([int]$ListItem.fields.PrincipalCount -ne $Baseline -and $ListItem.id) { $FlaggedIds.Add([string]$ListItem.id) } } - Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Sharing links: failed scanning drive '$($Drive.name)' on '$SiteUrl': $ErrorMessage" -sev Warning - $DriveFailed = $true - break - } - $Buffer = [System.Collections.Generic.List[object]]::new() - $TombstoneRows = [System.Collections.Generic.List[object]]::new() - foreach ($PageItem in @($Page.value)) { - if ($Mode -eq 'Incremental' -and $ExistingRowsByItem) { - # Every changed item invalidates whatever rows it had - deleted items, - # items no longer shared, and items whose link set changed all converge - # on: drop the old rows, re-add from the fresh permission read below. - $ItemKey = ConvertTo-CIPPSharingLinksKeySegment -Value "$($PageItem.id)" - if ($ExistingRowsByItem.ContainsKey($ItemKey)) { - foreach ($Row in $ExistingRowsByItem[$ItemKey]) { $TombstoneRows.Add($Row) } - $ExistingRowsByItem.Remove($ItemKey) + $PageRows = [System.Collections.Generic.List[object]]::new() + if ($FlaggedIds.Count -gt 0) { + $RequestId = 0 + $ItemRequests = foreach ($FlaggedId in $FlaggedIds) { + @{ + id = "$RequestId" + method = 'GET' + url = "sites/$SiteId/lists/$($List.id)/items/$FlaggedId/driveItem?`$select=id,name,webUrl,folder,size,lastModifiedDateTime&`$expand=permissions" + } + $RequestId++ + } + $ItemResponses = New-GraphBulkRequest -tenantid $TenantFilter -Requests @($ItemRequests) -asapp $true + foreach ($Response in $ItemResponses) { + if ($Response.status -and $Response.status -ne 200) { $DroppedReads++; continue } + ConvertTo-CIPPSharingRow -Permissions @($Response.body.permissions) -DriveItem $Response.body -Drive $Drive -Site $SiteContext -InternalDomains $InternalDomains -RowsOut $PageRows } } - if ($PageItem.shared -and -not $PageItem.deleted) { $Buffer.Add($PageItem) } + if ($PageRows.Count -gt 0) { + Add-CIPPDbItem -TenantFilter $TenantFilter -Type $CacheType -Data @($PageRows) -Append -RunId $ScanId + } + + $Uri = [string]$Page.'@odata.nextLink' + if ($Uri) { + $State = @{ CurrentUri = $Uri; CurrentMode = 'Principal'; Baseline = $Baseline } + Save-DriveCheckpoint -State $State + if (Invoke-TimeboxRequeue -State $State) { return @() } + } } - # Rows for this page: permission lookups happen per page so the checkpoint below - # never advances past work that has not been persisted. - $PageRows = [System.Collections.Generic.List[object]]::new() - Add-CIPPSharingRows -Buffer $Buffer -Drive $Drive -Site $SiteContext -InternalDomains $InternalDomains -TenantFilter $TenantFilter -RowsOut $PageRows + if ($DroppedReads -gt 0) { + # Throttled/failed batch responses mean some shared items were not rewritten + # this scan. Pruning now would delete their still-valid rows, so keep + # everything and force the next scan to run this drive full again. + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Sharing links: $DroppedReads permission reads dropped on drive '$($Drive.name)' ($SiteUrl); keeping existing rows and deferring the sweep to the next full scan" -sev Warning + Set-DriveState -DeltaLink '' + } else { + # Everything currently shared was rewritten with this scan's id; the rest is + # stale by definition. + $null = Remove-CIPPSharingLinksRowsByPrefix -TenantFilter $TenantFilter -Prefix "$CacheType-${DriveKeySegment}_" -ExceptRunId $ScanId - if ($TombstoneRows.Count -gt 0) { - $Table = Get-CippTable -tablename 'CippReportingDB' - $null = Remove-CIPPAzDataTableEntity @Table -Entity $TombstoneRows.ToArray() -Force - } - if ($PageRows.Count -gt 0) { - Add-CIPPDbItem -TenantFilter $TenantFilter -Type $CacheType -Data @($PageRows) -Append -RunId $ScanId + # Capture the delta position without walking the drive, so the next scan of + # this drive runs incrementally off the classic path. + $DeltaLink = '' + try { + $TokenPage = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/drives/$($Drive.id)/root/delta?token=latest&`$select=id" -tenantid $TenantFilter -asapp $true -noPagination $true -SkipValueExtraction + $DeltaLink = [string]$TokenPage.'@odata.deltaLink' + } catch { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Sharing links: could not capture delta token for drive '$($Drive.name)' on '$SiteUrl': $($_.Exception.Message)" -sev Debug + } + Set-DriveState -DeltaLink $DeltaLink -FullScan } + } catch { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Sharing links: failed scanning drive '$($Drive.name)' on '$SiteUrl': $($_.Exception.Message)" -sev Warning + # A current LastScanId with an empty token both protects this drive's cached + # rows from pruning and forces the next scan to run full. + Set-DriveState -DeltaLink '' + } + Remove-DriveCheckpoint + Complete-Drive + return @() + } - if ($Page.'@odata.deltaLink') { - $DeltaLink = [string]$Page.'@odata.deltaLink' - $Uri = $null - } else { - $Uri = [string]$Page.'@odata.nextLink' + # ---------------- Full / Incremental: classic delta walk ------------------------------- + if (-not $Uri) { $Uri = $FullDeltaUri } + + # Incremental scans tombstone every changed item's existing rows before re-adding the + # ones it still carries. One keys-only read up front replaces a per-item query: the + # itemId is recoverable from the RowKey because it sits between the known drive + # prefix and the next '_' (SPO item ids never contain underscores). + $ExistingRowsByItem = $null + if ($Mode -eq 'Incremental') { + $ExistingRowsByItem = @{} + $DrivePrefix = "$CacheType-${DriveKeySegment}_" + foreach ($Row in (Get-CIPPSharingLinksRowKeysByPrefix -TenantFilter $TenantFilter -Prefix $DrivePrefix)) { + if (-not $Row.RowKey) { continue } + $Suffix = ([string]$Row.RowKey).Substring($DrivePrefix.Length) + $ItemKey = $Suffix.Split('_')[0] + if (-not $ExistingRowsByItem.ContainsKey($ItemKey)) { $ExistingRowsByItem[$ItemKey] = [System.Collections.Generic.List[object]]::new() } + $ExistingRowsByItem[$ItemKey].Add($Row) + } + } + + $DeltaLink = $null + $DriveFailed = $false + while ($Uri) { + try { + $Page = New-GraphGetRequest -uri $Uri -tenantid $TenantFilter -asapp $true -noPagination $true -SkipValueExtraction + } catch { + $ErrorMessage = $_.Exception.Message + if ($Mode -eq 'Incremental' -and $ErrorMessage -match 'resync|SyncStateNotFound|Gone|410') { + # Token invalidated server-side; the drive needs a fresh full enumeration. + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Sharing links: delta token for drive '$($Drive.name)' on '$SiteUrl' expired; falling back to full scan" -sev Debug + $Mode = 'Full' + $Uri = $FullDeltaUri + $ExistingRowsByItem = $null + continue } + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Sharing links: failed scanning drive '$($Drive.name)' on '$SiteUrl': $ErrorMessage" -sev Warning + $DriveFailed = $true + break + } - # This page's rows are persisted, so the resume position may advance past it. - if ($Uri) { - Save-SiteCheckpoint -State @{ - CompletedDrives = @($CompletedDrives) - CurrentDriveId = [string]$Drive.id - CurrentUri = $Uri - CurrentMode = $Mode + $Buffer = [System.Collections.Generic.List[object]]::new() + $TombstoneRows = [System.Collections.Generic.List[object]]::new() + foreach ($PageItem in @($Page.value)) { + if ($Mode -eq 'Incremental' -and $ExistingRowsByItem) { + # Every changed item invalidates whatever rows it had - deleted items, + # items no longer shared, and items whose link set changed all converge + # on: drop the old rows, re-add from the fresh permission read below. + $ItemKey = ConvertTo-CIPPSharingLinksKeySegment -Value "$($PageItem.id)" + if ($ExistingRowsByItem.ContainsKey($ItemKey)) { + foreach ($Row in $ExistingRowsByItem[$ItemKey]) { $TombstoneRows.Add($Row) } + $ExistingRowsByItem.Remove($ItemKey) } } + if ($PageItem.shared -and -not $PageItem.deleted) { $Buffer.Add($PageItem) } } - if ($DriveFailed) { - # An empty token in Full mode forces the next scan to start over, while a - # preserved token in Incremental mode simply retries the same delta next scan. - $KeepToken = if ($Mode -eq 'Incremental') { - [string](Get-CIPPSharingLinksDriveState -TenantFilter $TenantFilter -DriveId $Drive.id).DeltaLink - } else { '' } - Set-DriveState -DriveId $Drive.id -DeltaLink $KeepToken + # Rows for this page: permission lookups happen per page so the checkpoint below + # never advances past work that has not been persisted. + $PageRows = [System.Collections.Generic.List[object]]::new() + Add-CIPPSharingRows -Buffer $Buffer -Drive $Drive -Site $SiteContext -InternalDomains $InternalDomains -TenantFilter $TenantFilter -RowsOut $PageRows + + if ($TombstoneRows.Count -gt 0) { + $Table = Get-CippTable -tablename 'CippReportingDB' + $null = Remove-CIPPAzDataTableEntity @Table -Entity $TombstoneRows.ToArray() -Force + } + if ($PageRows.Count -gt 0) { + Add-CIPPDbItem -TenantFilter $TenantFilter -Type $CacheType -Data @($PageRows) -Append -RunId $ScanId + } + + if ($Page.'@odata.deltaLink') { + $DeltaLink = [string]$Page.'@odata.deltaLink' + $Uri = $null } else { - if ($Mode -eq 'Full') { - # The scan rewrote every shared item's rows with this scan's id; anything left - # under the drive's prefix without it is a link that no longer exists. - $null = Remove-CIPPSharingLinksRowsByPrefix -TenantFilter $TenantFilter -Prefix "$CacheType-${DriveKeySegment}_" -ExceptRunId $ScanId - } - Set-DriveState -DriveId $Drive.id -DeltaLink ($DeltaLink ?? '') -FullScan:($Mode -eq 'Full') + $Uri = [string]$Page.'@odata.nextLink' + } + + # This page's rows are persisted, so the resume position may advance past it. + if ($Uri) { + $State = @{ CurrentUri = $Uri; CurrentMode = $Mode } + Save-DriveCheckpoint -State $State + if (Invoke-TimeboxRequeue -State $State) { return @() } } + } - [void]$CompletedDrives.Add([string]$Drive.id) - $Checkpoint = $null - # Advance the persisted position past the finished drive so a crash before the next - # drive's first page cannot resume into a drive that already completed. - Save-SiteCheckpoint -State @{ - CompletedDrives = @($CompletedDrives) - CurrentDriveId = '' - CurrentUri = '' - CurrentMode = '' + if ($DriveFailed) { + # An empty token in Full mode forces the next scan to start over, while a + # preserved token in Incremental mode simply retries the same delta next scan. + $KeepToken = if ($Mode -eq 'Incremental') { + [string](Get-CIPPSharingLinksDriveState -TenantFilter $TenantFilter -DriveId $Drive.id).DeltaLink + } else { '' } + Set-DriveState -DeltaLink $KeepToken + } else { + if ($Mode -eq 'Full') { + # The scan rewrote every shared item's rows with this scan's id; anything left + # under the drive's prefix without it is a link that no longer exists. + $null = Remove-CIPPSharingLinksRowsByPrefix -TenantFilter $TenantFilter -Prefix "$CacheType-${DriveKeySegment}_" -ExceptRunId $ScanId } + Set-DriveState -DeltaLink ($DeltaLink ?? '') -FullScan:($Mode -eq 'Full') } - # 3) Site complete. - Remove-SiteCheckpoint - Complete-Site + Remove-DriveCheckpoint + Complete-Drive return @() } catch { - Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Sharing links: failed scanning site '$SiteUrl': $($_.Exception.Message)" -sev Error -LogData (Get-CippException -Exception $_) - Complete-Site -Failed + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Sharing links: failed scanning drive '$($Drive.name)' on '$SiteUrl': $($_.Exception.Message)" -sev Error -LogData (Get-CippException -Exception $_) + Set-DriveState -DeltaLink '' + Remove-DriveCheckpoint + Complete-Drive return @() } } diff --git a/backend/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/SharePoint Sharing/Push-StoreSharePointSharingLinks.ps1 b/backend/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/SharePoint Sharing/Push-StoreSharePointSharingLinks.ps1 index 9e7f239965..a7448c6e4b 100644 --- a/backend/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/SharePoint Sharing/Push-StoreSharePointSharingLinks.ps1 +++ b/backend/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/SharePoint Sharing/Push-StoreSharePointSharingLinks.ps1 @@ -45,9 +45,18 @@ function Push-StoreSharePointSharingLinks { $Scan = Get-CIPPAzDataTableEntity @StateTable -Filter "PartitionKey eq '$SafeTenant' and RowKey eq 'scan'" $ScanMatches = $Scan -and [string]$Scan.ScanId -eq $ScanId + # Failed sites come from the completion markers, never from a list on the scan row: a + # marker row per site has no aggregate size cap, where the old JSON property overflowed + # the 64KB column limit at ~315 SharePoint composite site ids. The marker key holds the + # sanitised site id, which is what the drive-state rows' SiteId sanitises to as well. $FailedSites = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) - if ($ScanMatches -and $Scan.FailedSites) { - try { foreach ($Failed in @($Scan.FailedSites | ConvertFrom-Json -ErrorAction Stop)) { [void]$FailedSites.Add([string]$Failed) } } catch {} + if ($ScanMatches) { + $DoneMarkers = @(Get-CIPPAzDataTableEntity @StateTable -Filter ("PartitionKey eq '{0}' and RowKey ge 'done-' and RowKey lt 'done-~'" -f $SafeTenant) -Property @('PartitionKey', 'RowKey', 'ScanId', 'Failed')) + foreach ($Marker in $DoneMarkers) { + if ([string]$Marker.ScanId -ne $ScanId) { continue } + if ([string]$Marker.Failed -ne 'True') { continue } + [void]$FailedSites.Add(([string]$Marker.RowKey).Substring('done-'.Length)) + } } # Prune drives this scan never saw: deleted drives and deleted sites. Failed sites keep @@ -58,7 +67,10 @@ function Push-StoreSharePointSharingLinks { foreach ($DriveState in @(Get-CIPPSharingLinksDriveState -TenantFilter $TenantFilter)) { if (-not $DriveState) { continue } if ([string]$DriveState.LastScanId -eq $ScanId) { continue } - if ($FailedSites.Contains([string]$DriveState.SiteId)) { continue } + # Marker keys carry the sanitised site id; sanitise this row's SiteId the same + # way before membership testing. + $DriveSiteKey = [string]$DriveState.SiteId + if ($DriveSiteKey -and $FailedSites.Contains((ConvertTo-CIPPSharingLinksKeySegment -Value $DriveSiteKey))) { continue } $DriveKeySegment = ConvertTo-CIPPSharingLinksKeySegment -Value "$($DriveState.DriveId)" $PrunedRows += Remove-CIPPSharingLinksRowsByPrefix -TenantFilter $TenantFilter -Prefix "$CacheType-${DriveKeySegment}_" Remove-CIPPAzDataTableEntity @StateTable -Entity $DriveState -Force diff --git a/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheSharePointSharingLinks.ps1 b/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheSharePointSharingLinks.ps1 index 49e9bca51f..ee0a83797c 100644 --- a/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheSharePointSharingLinks.ps1 +++ b/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheSharePointSharingLinks.ps1 @@ -1,4 +1,4 @@ -function Set-CIPPDBCacheSharePointSharingLinks { +function Set-CIPPDBCacheSharePointSharingLinks { <# .SYNOPSIS Fans out SharePoint & OneDrive sharing link collection, one resumable activity per site. @@ -73,25 +73,19 @@ function Set-CIPPDBCacheSharePointSharingLinks { # A forced full sync gets the same sweep for the same reason. $FullSweep = [bool]$ForceFullSync -or (@(Get-CIPPSharingLinksDriveState -TenantFilter $TenantFilter).Count -eq 0) - # Scan state lives in CippSharingLinksState, partitioned per tenant: - # RowKey 'scan' - this row: scan identity, pending/total site counters, - # failed-site list, FullSweep flag. One scan per tenant at - # a time; writing it supersedes any scan still in flight. - # RowKey 'chk-{siteId}' - an in-progress site's resume position (written by the - # site activity after every persisted page). - # RowKey 'done-{siteId}' - a site's completion marker for the current scan; its - # insert-only write is what makes counting a site idempotent - # when a retry mechanism dispatches a task more than once. - # RowKey 'delta-{driveId}' - per-drive delta token + scan bookkeeping (written by the - # site activity, read via Get-CIPPSharingLinksDriveState). + # Scan state lives in CippSharingLinksState, partitioned per tenant. Completion is + # tracked purely with insert-only marker rows (see the site/drive activity for the row + # vocabulary) - deliberately no pending counter and no failed-site list on this row: + # concurrent counter decrements lost ETag races, and the failed-site JSON overflowed the + # 64KB table property cap at ~315 SharePoint composite site ids, both of which left + # scans permanently uncompletable. $StateTable = Get-CippTable -tablename 'CippSharingLinksState' - # Completion markers are per scan: clear the previous scan's before any site of this one - # can finish, or every site would look like a duplicate and the counter would never move. - # Stale checkpoints are ScanId-gated by the reader, but sweep them too so table state - # always reflects at most one scan. + # Markers are per scan: clear the previous scan's before any site of this one can + # finish, or every site would look like a duplicate. Stale rows are ScanId-gated by + # their readers, but sweep them too so table state always reflects at most one scan. $SafeTenant = ConvertTo-CIPPODataFilterValue -Value $TenantFilter -Type String - foreach ($Prefix in @('done-', 'chk-')) { + foreach ($Prefix in @('done-', 'ddone-', 'drives-', 'chk-', 'final')) { $Stale = @(Get-CIPPAzDataTableEntity @StateTable -Filter ("PartitionKey eq '{0}' and RowKey ge '{1}' and RowKey lt '{1}~'" -f $SafeTenant, $Prefix) -Property @('PartitionKey', 'RowKey', 'ETag')) if ($Stale.Count -gt 0) { $null = Remove-CIPPAzDataTableEntity @StateTable -Entity $Stale -Force } } @@ -100,9 +94,7 @@ function Set-CIPPDBCacheSharePointSharingLinks { PartitionKey = $TenantFilter RowKey = 'scan' ScanId = $ScanId - PendingSites = [int]$Sites.Count TotalSites = [int]$Sites.Count - FailedSites = '[]' FullSweep = [bool]$FullSweep StartedUtc = [string]([DateTimeOffset]::UtcNow.ToString('o')) } -Force @@ -117,7 +109,6 @@ function Set-CIPPDBCacheSharePointSharingLinks { IsPersonalSite = [bool]$Site.isPersonalSite InternalDomains = @($InternalDomains) ScanId = $ScanId - Slice = 1 ForceFull = [bool]$ForceFullSync QueueId = $QueueId QueueName = "Sharing Links - $($Site.webUrl)" diff --git a/backend/Tests/DBCache/SharePointSharingLinks.Resume.Tests.ps1 b/backend/Tests/DBCache/SharePointSharingLinks.Resume.Tests.ps1 index f2a70e567d..c6e2927467 100644 --- a/backend/Tests/DBCache/SharePointSharingLinks.Resume.Tests.ps1 +++ b/backend/Tests/DBCache/SharePointSharingLinks.Resume.Tests.ps1 @@ -1,22 +1,19 @@ -# Pester tests for the resumable, delta-persisted sharing-links scan. +# Pester tests for the per-drive, marker-completed, resumable sharing-links scan. # # The scan's correctness lives in state transitions - checkpoints, delta tokens, tombstones, -# completion counting - so these tests run the real activity, finaliser, state helpers and the -# real Add-CIPPDbItem against an in-memory stand-in for table storage that understands the +# marker-based completion - so these tests run the real activity, finaliser, state helpers and +# the real Add-CIPPDbItem against an in-memory stand-in for table storage that understands the # handful of OData filter shapes the code generates. Graph is scripted per test. BeforeAll { $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) # --- in-memory table storage ------------------------------------------------------------- - # Entities are stored per table and cloned on read so mutations only land via an explicit - # write-back, the same contract the real service gives the code under test. function Get-CippTable { param($tablename) @{ TableName = $tablename } } function Get-FakeTableRows { param([string]$TableName) if (-not $script:FakeTables.ContainsKey($TableName)) { $script:FakeTables[$TableName] = [System.Collections.Generic.List[object]]::new() } - # Comma operator: return the List itself, not its unrolled elements. , $script:FakeTables[$TableName] } @@ -33,7 +30,6 @@ BeforeAll { function ConvertTo-FakeEntity { param($Entity) if ($Entity -is [hashtable]) { return [pscustomobject]$Entity } - # Clone PSCustomObjects so later caller-side mutation cannot silently edit the store. $Clone = [ordered]@{} foreach ($Property in $Entity.PSObject.Properties) { $Clone[$Property.Name] = $Property.Value } [pscustomobject]$Clone @@ -46,7 +42,6 @@ BeforeAll { } function Add-CIPPAzDataTableEntity { - # CmdletBinding so the fake honours the caller's -ErrorAction, like the real wrapper. [CmdletBinding()] param($TableName, $Entity, [switch]$Force, [switch]$CreateTableIfNotExists) $Rows = Get-FakeTableRows -TableName $TableName @@ -78,17 +73,8 @@ BeforeAll { } function Update-AzDataTableEntity { - # CmdletBinding so the fake honours the caller's -ErrorAction, like the real cmdlet. [CmdletBinding()] param($TableName, $Entity, [switch]$Force) - if ($script:FailScanRowUpdates -gt 0 -and $Entity.RowKey -eq 'scan') { - $script:FailScanRowUpdates-- - # Faithful to AzBobbyTables: an ETag conflict surfaces as a NON-terminating error, - # so only call sites passing -ErrorAction Stop can catch and retry it. - Write-Error 'The update condition specified in the request was not satisfied. Status: 412 (Precondition Failed) ErrorCode: UpdateConditionNotSatisfied' - return - } - # An update overwrites by definition - the insert-only rule above applies to Add alone. Add-CIPPAzDataTableEntity -TableName $TableName -Entity $Entity -Force } @@ -106,17 +92,38 @@ BeforeAll { $script:Orchestrations.Add($InputObject) } - # Graph GET routed through a per-test handler; the shared default serves the drives listing. function New-GraphGetRequest { param($uri, $tenantid, $scope, $AsApp, [bool]$noPagination, $NoAuthCheck, [bool]$skipTokenCache, $Caller, [switch]$ComplexFilter, [switch]$CountOnly, [switch]$IncludeResponseHeaders, [hashtable]$extraHeaders, [switch]$ReturnRawResponse, [switch]$SkipValueExtraction, [switch]$Stream, [switch]$UseCertificate, $Headers) $script:GraphGetCalls.Add($uri) & $script:GraphGetHandler $uri } - # Every requested item gets one anonymous view link back, unless a test swaps the handler. + # Serves both bulk shapes the activity issues: classic per-item permission reads + # (.../items/{id}/permissions -> body.value) and Principal-mode driveItem reads + # (.../listitems/{id}/driveItem?...$expand=permissions -> body is the driveItem). function New-GraphBulkRequest { param($tenantid, $NoAuthCheck, $scope, $asapp, $Requests, $NoPaginateIds, $Version, $Headers) foreach ($Request in @($Requests)) { + if ($Request.url -match '^sites/[^/]+/lists/[^/]+/items/([^/]+)/driveItem') { + $ListItemId = $Matches[1] + [pscustomobject]@{ + id = $Request.id + status = 200 + body = [pscustomobject]@{ + id = "01DRV$ListItemId" + name = "item-$ListItemId.docx" + size = 1 + permissions = @( + [pscustomobject]@{ + id = "perm-$ListItemId" + roles = @('read') + link = [pscustomobject]@{ scope = 'anonymous'; type = 'view'; webUrl = "https://share/$ListItemId" } + } + ) + } + } + continue + } $ItemId = ($Request.url -split '/')[3] [pscustomobject]@{ id = $Request.id @@ -135,8 +142,6 @@ BeforeAll { } . (Join-Path $RepoRoot 'Modules/CIPPCore/Public/Add-CIPPDbItem.ps1') - # The scan-state helpers live one function per file (the Craft runtime resolves functions - # by file name); load every one of them plus the collector. foreach ($HelperFile in (Get-ChildItem (Join-Path $RepoRoot 'Modules/CIPPDB/Public/DBCache') -Filter '*-CIPPSharingLinks*.ps1')) { . $HelperFile.FullName } @@ -146,23 +151,35 @@ BeforeAll { # --- shared builders ------------------------------------------------------------------------ function New-SiteItem { - param([string]$ScanId, [string]$SiteId = 'contoso.sharepoint.com,site1,web1', [string]$SiteUrl = 'https://contoso.sharepoint.com/sites/one') + param([string]$ScanId, [string]$SiteId = 'contoso.sharepoint.com,site1,web1', [string]$SiteUrl = 'https://contoso.sharepoint.com/sites/one', [bool]$IsPersonalSite = $false, [bool]$ForceFull = $false) [pscustomobject]@{ FunctionName = 'DBCacheSharePointSiteSharingLinks' TenantFilter = 'contoso.com' SiteId = $SiteId SiteName = 'Site One' SiteUrl = $SiteUrl - IsPersonalSite = $false + IsPersonalSite = $IsPersonalSite InternalDomains = @('contoso.com') ScanId = $ScanId - Slice = 1 - ForceFull = $false + ForceFull = $ForceFull QueueId = $null QueueName = 'Sharing Links - test' } } + # Runs a site task, then every drive task it queued (and any tasks those queue in turn), + # the way the orchestrator would. + function Invoke-SiteAndDrives { + param($SiteItem) + Push-DBCacheSharePointSiteSharingLinks -Item $SiteItem + $Cursor = 0 + while ($Cursor -lt $script:Orchestrations.Count) { + $Queued = $script:Orchestrations[$Cursor] + $Cursor++ + foreach ($Task in @($Queued.Batch)) { Push-DBCacheSharePointSiteSharingLinks -Item $Task } + } + } + function New-DeltaPage { param($Items = @(), [string]$NextLink, [string]$DeltaLink) $Page = [ordered]@{ value = @($Items) } @@ -182,36 +199,50 @@ BeforeAll { @((Get-FakeTableRows -TableName 'CippReportingDB') | ForEach-Object { $_.RowKey }) | Sort-Object } - # The scan row is written inline by the fan-out parent (no public initialiser), so tests - # seed and read it as raw entities. function Initialize-TestScan { param([string]$ScanId, [int]$TotalSites, [bool]$FullSweep = $false) Add-CIPPAzDataTableEntity -TableName 'CippSharingLinksState' -Entity @{ - PartitionKey = 'contoso.com'; RowKey = 'scan'; ScanId = $ScanId; PendingSites = $TotalSites; TotalSites = $TotalSites - FailedSites = '[]'; FullSweep = $FullSweep; StartedUtc = '2026-08-12T00:00:00Z' + PartitionKey = 'contoso.com'; RowKey = 'scan'; ScanId = $ScanId; TotalSites = $TotalSites + FullSweep = $FullSweep; StartedUtc = '2026-08-12T00:00:00Z' } } - function Get-TestScanRow { - (Get-FakeTableRows -TableName 'CippSharingLinksState') | Where-Object { $_.RowKey -eq 'scan' } | Select-Object -First 1 + function Get-StateRowKeys { + @((Get-FakeTableRows -TableName 'CippSharingLinksState') | ForEach-Object { $_.RowKey }) | Sort-Object } } -Describe 'Resumable sharing-links scan' { +Describe 'Per-drive sharing-links scan' { BeforeEach { $script:FakeTables = @{} $script:Orchestrations = [System.Collections.Generic.List[object]]::new() $script:QueueUpdates = [System.Collections.Generic.List[object]]::new() $script:GraphGetCalls = [System.Collections.Generic.List[string]]::new() - $script:FailScanRowUpdates = 0 $env:CIPP_SHARINGLINKS_FULLSCAN_DAYS = $null + $env:CIPP_SHARINGLINKS_TIMEBOX_SECONDS = $null - # Default Graph: one drive with one delta page holding one shared file. + # Default Graph: a personal-site style drive whose full scan is a classic delta walk + # with one shared file, plus the Principal-mode routes for team-site tests. $script:GraphGetHandler = { param($Uri) - if ($Uri -match '/sites/[^/]+/drives') { - return @([pscustomobject]@{ id = 'b!driveone'; name = 'Documents'; driveType = 'documentLibrary' }) + if ($Uri -match '/sites/[^/]+/drives\?') { + return @([pscustomobject]@{ id = 'b!driveone'; name = 'Documents'; driveType = 'documentLibrary'; webUrl = 'https://contoso.sharepoint.com/sites/one/Shared%20Documents' }) + } + if ($Uri -match '/drives/b!driveone/list\?') { return [pscustomobject]@{ id = 'list1' } } + if ($Uri -match '/drives/b!driveone/root/permissions') { + return @([pscustomobject]@{ id = 'g1' }, [pscustomobject]@{ id = 'g2' }, [pscustomobject]@{ id = 'g3' }) + } + if ($Uri -match '/lists/list1/items\?') { + return [pscustomobject]@{ + value = @( + [pscustomobject]@{ id = '11'; fields = [pscustomobject]@{ PrincipalCount = 3 } } + [pscustomobject]@{ id = '12'; fields = [pscustomobject]@{ PrincipalCount = 4 } } # extra principal = shared + ) + } + } + if ($Uri -match 'token=latest') { + return New-DeltaPage -DeltaLink 'https://graph.microsoft.com/beta/drives/b!driveone/root/delta?token=captured' } if ($Uri -match '/root/delta') { return New-DeltaPage -Items @( @@ -223,44 +254,122 @@ Describe 'Resumable sharing-links scan' { } } - Context 'full scan of a site' { - It 'writes rows stamped with the scan id and stores the drive delta token' { - $ScanId = 'scan-full-1' + Context 'site task fan-out' { + It 'dispatches one drive task per drive and skips the Preservation Hold Library' { + $ScanId = 'scan-dispatch-1' + Initialize-TestScan -ScanId $ScanId -TotalSites 1 + $script:GraphGetHandler = { + param($Uri) + if ($Uri -match '/sites/[^/]+/drives\?') { + return @( + [pscustomobject]@{ id = 'b!docs'; name = 'Documents'; webUrl = 'https://contoso.sharepoint.com/sites/one/Shared%20Documents' } + [pscustomobject]@{ id = 'b!phl'; name = 'Preservation Hold Library'; webUrl = 'https://contoso.sharepoint.com/sites/one/PreservationHoldLibrary' } + ) + } + throw "Unrouted GET: $Uri" + } + + Push-DBCacheSharePointSiteSharingLinks -Item (New-SiteItem -ScanId $ScanId) + + $script:Orchestrations.Count | Should -Be 1 + $Tasks = @($script:Orchestrations[0].Batch) + $Tasks.Count | Should -Be 1 + $Tasks[0].DriveId | Should -Be 'b!docs' + # The dispatch total the drive tasks complete against matches what was dispatched. + $DrivesRow = (Get-FakeTableRows -TableName 'CippSharingLinksState') | Where-Object { $_.RowKey -like 'drives-*' } + [int]$DrivesRow.DriveCount | Should -Be 1 + } + + It 'completes the site as failed when the drive listing is refused' { + $ScanId = 'scan-dispatch-2' Initialize-TestScan -ScanId $ScanId -TotalSites 2 + $script:GraphGetHandler = { param($Uri) throw 'Access to this site has been blocked.' } Push-DBCacheSharePointSiteSharingLinks -Item (New-SiteItem -ScanId $ScanId) - $Rows = @((Get-FakeTableRows -TableName 'CippReportingDB') | Where-Object { $_.RowKey -like 'SharePointSharingLinks-b!driveone_01ITEMA_*' }) + $Marker = (Get-FakeTableRows -TableName 'CippSharingLinksState') | Where-Object { $_.RowKey -eq 'done-contoso.sharepoint.com,site1,web1' } + [string]$Marker.Failed | Should -Be 'True' + # Not the last site, so no finalisation. + Get-CacheRowKeys | Should -Not -Contain 'SharePointSharingLinks-Count' + } + } + + Context 'Principal-mode full scan of a team-site drive' { + It 'permission-reads only items whose principal count deviates and captures a delta token' { + $ScanId = 'scan-principal-1' + Initialize-TestScan -ScanId $ScanId -TotalSites 1 + Add-CacheRow -RowKey 'SharePointSharingLinks-b!driveone_01GONE_permOld' + + Invoke-SiteAndDrives -SiteItem (New-SiteItem -ScanId $ScanId) + + # Only the deviating list item (id 12) was read; its row carries the scan id. + $Rows = @((Get-FakeTableRows -TableName 'CippReportingDB') | Where-Object { $_.RowKey -like 'SharePointSharingLinks-b!driveone_01DRV12_*' }) $Rows.Count | Should -Be 1 $Rows[0].RunId | Should -Be $ScanId + # The full-scan prune removed what this scan did not rewrite. + Get-CacheRowKeys | Should -Not -Contain 'SharePointSharingLinks-b!driveone_01GONE_permOld' $DriveState = Get-CIPPSharingLinksDriveState -TenantFilter 'contoso.com' -DriveId 'b!driveone' - $DriveState.DeltaLink | Should -Be 'https://graph.microsoft.com/beta/drives/b!driveone/root/delta?token=fresh' + $DriveState.DeltaLink | Should -BeLike '*token=captured' $DriveState.LastScanId | Should -Be $ScanId $DriveState.LastFullScanUtc | Should -Not -BeNullOrEmpty + + # Last drive of the last site: the scan finalised and wrote the count row. + Get-CacheRowKeys | Should -Contain 'SharePointSharingLinks-Count' + Get-StateRowKeys | Should -Contain 'final' } + } - It 'prunes rows a full rescan of the drive did not rewrite' { - $ScanId = 'scan-full-2' - Initialize-TestScan -ScanId $ScanId -TotalSites 2 - Add-CacheRow -RowKey 'SharePointSharingLinks-b!driveone_01GONE_permOld' + Context 'Principal-mode scan with dropped permission reads' { + It 'keeps existing rows and defers the sweep when batch reads are throttled away' { + $ScanId = 'scan-principal-drop-1' + Initialize-TestScan -ScanId $ScanId -TotalSites 1 + Add-CacheRow -RowKey 'SharePointSharingLinks-b!driveone_01SURVIVOR_permOld' + # Every Principal-mode driveItem read comes back throttled. + Mock New-GraphBulkRequest { + foreach ($Request in @($Requests)) { + [pscustomobject]@{ id = $Request.id; status = 429; body = $null } + } + } - Push-DBCacheSharePointSiteSharingLinks -Item (New-SiteItem -ScanId $ScanId) + Invoke-SiteAndDrives -SiteItem (New-SiteItem -ScanId $ScanId) - Get-CacheRowKeys | Should -Not -Contain 'SharePointSharingLinks-b!driveone_01GONE_permOld' - Get-CacheRowKeys | Should -Contain 'SharePointSharingLinks-b!driveone_01ITEMA_perm-01ITEMA' + # Nothing was rewritten, so nothing may be pruned - and the drive must not claim a + # completed full scan, so the next run starts over. + Get-CacheRowKeys | Should -Contain 'SharePointSharingLinks-b!driveone_01SURVIVOR_permOld' + $DriveState = Get-CIPPSharingLinksDriveState -TenantFilter 'contoso.com' -DriveId 'b!driveone' + [string]$DriveState.DeltaLink | Should -BeNullOrEmpty + [string]$DriveState.LastFullScanUtc | Should -BeNullOrEmpty + # The drive still completes its task so the scan can finalise. + Get-CacheRowKeys | Should -Contain 'SharePointSharingLinks-Count' + } + } + + Context 'classic full scan (personal site)' { + It 'writes rows stamped with the scan id and stores the drive delta token' { + $ScanId = 'scan-full-1' + Initialize-TestScan -ScanId $ScanId -TotalSites 1 + + Invoke-SiteAndDrives -SiteItem (New-SiteItem -ScanId $ScanId -IsPersonalSite $true) + + $Rows = @((Get-FakeTableRows -TableName 'CippReportingDB') | Where-Object { $_.RowKey -like 'SharePointSharingLinks-b!driveone_01ITEMA_*' }) + $Rows.Count | Should -Be 1 + $Rows[0].RunId | Should -Be $ScanId + + $DriveState = Get-CIPPSharingLinksDriveState -TenantFilter 'contoso.com' -DriveId 'b!driveone' + $DriveState.DeltaLink | Should -Be 'https://graph.microsoft.com/beta/drives/b!driveone/root/delta?token=fresh' + $DriveState.LastScanId | Should -Be $ScanId + $DriveState.LastFullScanUtc | Should -Not -BeNullOrEmpty } - It 'decrements the pending counter and only finalises on the last site' { + It 'only finalises when the last site completes' { $ScanId = 'scan-full-3' Initialize-TestScan -ScanId $ScanId -TotalSites 2 - Push-DBCacheSharePointSiteSharingLinks -Item (New-SiteItem -ScanId $ScanId) - ([int](Get-TestScanRow).PendingSites) | Should -Be 1 + Invoke-SiteAndDrives -SiteItem (New-SiteItem -ScanId $ScanId -IsPersonalSite $true) Get-CacheRowKeys | Should -Not -Contain 'SharePointSharingLinks-Count' - Push-DBCacheSharePointSiteSharingLinks -Item (New-SiteItem -ScanId $ScanId -SiteId 'contoso.sharepoint.com,site2,web2' -SiteUrl 'https://contoso.sharepoint.com/sites/two') - ([int](Get-TestScanRow).PendingSites) | Should -Be 0 + Invoke-SiteAndDrives -SiteItem (New-SiteItem -ScanId $ScanId -SiteId 'contoso.sharepoint.com,site2,web2' -SiteUrl 'https://contoso.sharepoint.com/sites/two' -IsPersonalSite $true) $CountRow = (Get-FakeTableRows -TableName 'CippReportingDB') | Where-Object { $_.RowKey -eq 'SharePointSharingLinks-Count' } # Two sites sharing one fake drive id: the same rows get upserted, so one link remains. [int]$CountRow.DataCount | Should -Be 1 @@ -271,13 +380,11 @@ Describe 'Resumable sharing-links scan' { BeforeEach { $script:ScanId = 'scan-incr-1' Initialize-TestScan -ScanId $script:ScanId -TotalSites 1 - # Drive completed a full scan recently, so the next scan is incremental. Add-CIPPAzDataTableEntity -TableName 'CippSharingLinksState' -Entity @{ PartitionKey = 'contoso.com'; RowKey = 'delta-b!driveone'; DriveId = 'b!driveone'; SiteId = 'contoso.sharepoint.com,site1,web1' DeltaLink = 'https://graph.microsoft.com/beta/drives/b!driveone/root/delta?token=stored' LastScanId = 'previous-scan'; LastScanUtc = '2026-08-10T00:00:00Z'; LastFullScanUtc = '2026-08-10T00:00:00Z' } - # Existing cache rows: X will change, Y is untouched, Z will arrive deleted. Add-CacheRow -RowKey 'SharePointSharingLinks-b!driveone_01ITEMX_permOld' Add-CacheRow -RowKey 'SharePointSharingLinks-b!driveone_01ITEMY_permKeep' Add-CacheRow -RowKey 'SharePointSharingLinks-b!driveone_01ITEMZ_permDead' @@ -286,7 +393,7 @@ Describe 'Resumable sharing-links scan' { It 'scans from the stored token, tombstones changed items and keeps untouched rows' { $script:GraphGetHandler = { param($Uri) - if ($Uri -match '/sites/[^/]+/drives') { return @([pscustomobject]@{ id = 'b!driveone'; name = 'Documents' }) } + if ($Uri -match '/sites/[^/]+/drives\?') { return @([pscustomobject]@{ id = 'b!driveone'; name = 'Documents'; webUrl = 'https://contoso.sharepoint.com/sites/one/Shared%20Documents' }) } if ($Uri -eq 'https://graph.microsoft.com/beta/drives/b!driveone/root/delta?token=stored') { return New-DeltaPage -Items @( [pscustomobject]@{ id = '01ITEMX'; name = 'x.docx'; shared = [pscustomobject]@{ scope = 'anonymous' } } @@ -296,7 +403,7 @@ Describe 'Resumable sharing-links scan' { throw "Unrouted GET: $Uri" } - Push-DBCacheSharePointSiteSharingLinks -Item (New-SiteItem -ScanId $script:ScanId) + Invoke-SiteAndDrives -SiteItem (New-SiteItem -ScanId $script:ScanId) $Keys = Get-CacheRowKeys $Keys | Should -Not -Contain 'SharePointSharingLinks-b!driveone_01ITEMX_permOld' # replaced @@ -310,10 +417,10 @@ Describe 'Resumable sharing-links scan' { $DriveState.LastFullScanUtc | Should -Be '2026-08-10T00:00:00Z' } - It 'falls back to a full scan when the stored token is rejected' { + It 'falls back to a classic full scan when the stored token is rejected' { $script:GraphGetHandler = { param($Uri) - if ($Uri -match '/sites/[^/]+/drives') { return @([pscustomobject]@{ id = 'b!driveone'; name = 'Documents' }) } + if ($Uri -match '/sites/[^/]+/drives\?') { return @([pscustomobject]@{ id = 'b!driveone'; name = 'Documents'; webUrl = 'https://contoso.sharepoint.com/sites/one/Shared%20Documents' }) } if ($Uri -match 'token=stored') { throw 'resyncRequired: The delta token is no longer valid, and the app must obtain a new one.' } if ($Uri -match '/root/delta') { return New-DeltaPage -Items @( @@ -323,9 +430,8 @@ Describe 'Resumable sharing-links scan' { throw "Unrouted GET: $Uri" } - Push-DBCacheSharePointSiteSharingLinks -Item (New-SiteItem -ScanId $script:ScanId) + Invoke-SiteAndDrives -SiteItem (New-SiteItem -ScanId $script:ScanId) - # The full rescan rewrote Y and pruned everything it did not rewrite. $Keys = Get-CacheRowKeys $Keys | Should -Contain 'SharePointSharingLinks-b!driveone_01ITEMY_perm-01ITEMY' $Keys | Should -Not -Contain 'SharePointSharingLinks-b!driveone_01ITEMX_permOld' @@ -337,32 +443,24 @@ Describe 'Resumable sharing-links scan' { } } - Context 'resume from a checkpoint' { - It 'skips completed drives and resumes the current drive at the checkpointed page' { + Context 'resume and timebox' { + It 'resumes a drive task at the checkpointed page' { $ScanId = 'scan-resume-1' Initialize-TestScan -ScanId $ScanId -TotalSites 1 - # Checkpoint CRUD is nested inside the activity, so the resume position is seeded as - # the raw entity the activity persists. Add-CIPPAzDataTableEntity -TableName 'CippSharingLinksState' -Entity @{ PartitionKey = 'contoso.com' - RowKey = 'chk-contoso.sharepoint.com,site1,web1' + RowKey = 'chk-contoso.sharepoint.com,site1,web1~b!driveone' ScanId = $ScanId StateJson = (@{ - CompletedDrives = @('b!drivedone') - CurrentDriveId = 'b!driveone' - CurrentUri = 'https://graph.microsoft.com/beta/drives/b!driveone/root/delta?token=page7' - CurrentMode = 'Full' + CurrentUri = 'https://graph.microsoft.com/beta/drives/b!driveone/root/delta?token=page7' + CurrentMode = 'Full' } | ConvertTo-Json -Compress) } - + Add-CIPPAzDataTableEntity -TableName 'CippSharingLinksState' -Entity @{ + PartitionKey = 'contoso.com'; RowKey = 'drives-contoso.sharepoint.com,site1,web1'; ScanId = $ScanId; DriveCount = 1 + } $script:GraphGetHandler = { param($Uri) - if ($Uri -match '/sites/[^/]+/drives') { - return @( - [pscustomobject]@{ id = 'b!drivedone'; name = 'Done' } - [pscustomobject]@{ id = 'b!driveone'; name = 'Documents' } - ) - } if ($Uri -match 'token=page7') { return New-DeltaPage -Items @( [pscustomobject]@{ id = '01ITEMC'; name = 'c.docx'; shared = [pscustomobject]@{ scope = 'anonymous' } } @@ -371,80 +469,82 @@ Describe 'Resumable sharing-links scan' { throw "Unrouted GET: $Uri" } - Push-DBCacheSharePointSiteSharingLinks -Item (New-SiteItem -ScanId $ScanId) + $DriveTask = New-SiteItem -ScanId $ScanId + $DriveTask | Add-Member -NotePropertyName DriveId -NotePropertyValue 'b!driveone' + $DriveTask | Add-Member -NotePropertyName DriveName -NotePropertyValue 'Documents' + Push-DBCacheSharePointSiteSharingLinks -Item $DriveTask - # No call ever targeted the completed drive or the start of the current one. - @($script:GraphGetCalls | Where-Object { $_ -match 'drivedone' }).Count | Should -Be 0 + # No call restarted the drive from the beginning. + @($script:GraphGetCalls | Where-Object { $_ -match '\$top=999' }).Count | Should -Be 0 Get-CacheRowKeys | Should -Contain 'SharePointSharingLinks-b!driveone_01ITEMC_perm-01ITEMC' - # Site finished, so the checkpoint is gone. + # Drive finished: checkpoint gone, drive marker present, scan finalised. (Get-FakeTableRows -TableName 'CippSharingLinksState') | Where-Object { $_.RowKey -like 'chk-*' } | Should -BeNullOrEmpty + Get-CacheRowKeys | Should -Contain 'SharePointSharingLinks-Count' + } + + It 'checkpoints and requeues itself when the timebox is spent instead of completing' { + $ScanId = 'scan-timebox-1' + Initialize-TestScan -ScanId $ScanId -TotalSites 1 + $env:CIPP_SHARINGLINKS_TIMEBOX_SECONDS = '1' + $script:GraphGetHandler = { + param($Uri) + if ($Uri -match '/sites/[^/]+/drives\?') { return @([pscustomobject]@{ id = 'b!driveone'; name = 'Documents'; webUrl = 'https://contoso.sharepoint.com/sites/one/Shared%20Documents' }) } + if ($Uri -match '/root/delta') { + Start-Sleep -Seconds 2 # burn the timebox on the first page + return New-DeltaPage -Items @( + [pscustomobject]@{ id = '01ITEMA'; name = 'a.docx'; shared = [pscustomobject]@{ scope = 'anonymous' } } + ) -NextLink 'https://graph.microsoft.com/beta/drives/b!driveone/root/delta?token=page2' + } + throw "Unrouted GET: $Uri" + } + + Push-DBCacheSharePointSiteSharingLinks -Item (New-SiteItem -ScanId $ScanId -IsPersonalSite $true) + $DriveTask = @($script:Orchestrations[0].Batch)[0] + Push-DBCacheSharePointSiteSharingLinks -Item $DriveTask + + # Page 1's rows are persisted, the resume position is saved, and the task re-queued + # itself rather than finishing the drive. + Get-CacheRowKeys | Should -Contain 'SharePointSharingLinks-b!driveone_01ITEMA_perm-01ITEMA' + $Checkpoint = (Get-FakeTableRows -TableName 'CippSharingLinksState') | Where-Object { $_.RowKey -like 'chk-*' } + $Checkpoint.StateJson | Should -BeLike '*token=page2*' + $Requeued = @($script:Orchestrations | Where-Object { $_.OrchestratorName -like 'SharingLinksResume_*' }) + $Requeued.Count | Should -Be 1 + @($Requeued[0].Batch)[0].DriveId | Should -Be 'b!driveone' + (Get-FakeTableRows -TableName 'CippSharingLinksState') | Where-Object { $_.RowKey -like 'ddone-*' } | Should -BeNullOrEmpty } } - Context 'superseded scans' { - It 'exits without scanning or touching the counter when a newer scan owns the state' { + Context 'superseded scans and duplicate dispatch' { + It 'exits without scanning when a newer scan owns the state' { Initialize-TestScan -ScanId 'scan-new' -TotalSites 5 Push-DBCacheSharePointSiteSharingLinks -Item (New-SiteItem -ScanId 'scan-old') $script:GraphGetCalls.Count | Should -Be 0 - ([int](Get-TestScanRow).PendingSites) | Should -Be 5 + Get-StateRowKeys | Should -Not -Contain 'done-contoso.sharepoint.com,site1,web1' } - } - Context 'completion counter under contention' { - It 'counts a site exactly once however many times its task is dispatched' { + It 'counts a site exactly once however many times its tasks are dispatched' { $ScanId = 'scan-dup-1' Initialize-TestScan -ScanId $ScanId -TotalSites 2 - # The same site task delivered twice - a retry mechanism re-firing a task that in - # fact completed, or a duplicate delivery. The second run rescans harmlessly but - # must not decrement the counter again, or the scan would finalise early while the - # second site is still pending. - Push-DBCacheSharePointSiteSharingLinks -Item (New-SiteItem -ScanId $ScanId) - Push-DBCacheSharePointSiteSharingLinks -Item (New-SiteItem -ScanId $ScanId) + Invoke-SiteAndDrives -SiteItem (New-SiteItem -ScanId $ScanId -IsPersonalSite $true) + Invoke-SiteAndDrives -SiteItem (New-SiteItem -ScanId $ScanId -IsPersonalSite $true) - ([int](Get-TestScanRow).PendingSites) | Should -Be 1 + @((Get-FakeTableRows -TableName 'CippSharingLinksState') | Where-Object { $_.RowKey -like 'done-*' }).Count | Should -Be 1 + # One of two sites complete: no finalisation. Get-CacheRowKeys | Should -Not -Contain 'SharePointSharingLinks-Count' } - - It 'retries a lost ETag race instead of silently dropping the decrement' { - $ScanId = 'scan-race-1' - Initialize-TestScan -ScanId $ScanId -TotalSites 1 - # First conditional write of the scan row 412s, exactly like losing the race to a - # concurrently finishing site. The retry must re-read and land the decrement. - $script:FailScanRowUpdates = 1 - - Push-DBCacheSharePointSiteSharingLinks -Item (New-SiteItem -ScanId $ScanId) - - ([int](Get-TestScanRow).PendingSites) | Should -Be 0 - # Pending reached zero, so finalisation ran and wrote the count row. - Get-CacheRowKeys | Should -Contain 'SharePointSharingLinks-Count' - } - } - - Context 'site failure' { - It 'records the failed site and still decrements the counter' { - $ScanId = 'scan-fail-1' - Initialize-TestScan -ScanId $ScanId -TotalSites 2 - $script:GraphGetHandler = { param($Uri) throw 'drives listing failed' } - - Push-DBCacheSharePointSiteSharingLinks -Item (New-SiteItem -ScanId $ScanId) - - $Scan = Get-TestScanRow - ([int]$Scan.PendingSites) | Should -Be 1 - @($Scan.FailedSites | ConvertFrom-Json) | Should -Contain 'contoso.sharepoint.com,site1,web1' - } } Context 'finalisation' { It 'prunes rows and state of drives the scan never saw, but keeps failed sites intact' { $ScanId = 'scan-final-1' + Initialize-TestScan -ScanId $ScanId -TotalSites 3 + # The failed site's completion marker is where the failed set now lives. Add-CIPPAzDataTableEntity -TableName 'CippSharingLinksState' -Entity @{ - PartitionKey = 'contoso.com'; RowKey = 'scan'; ScanId = $ScanId; PendingSites = 0; TotalSites = 3 - FailedSites = '["contoso.sharepoint.com,siteF,webF"]'; FullSweep = $false; StartedUtc = '2026-08-12T00:00:00Z' + PartitionKey = 'contoso.com'; RowKey = 'done-contoso.sharepoint.com,siteF,webF'; ScanId = $ScanId; Failed = $true } - # Current drive, vanished drive, and a drive on the failed site. foreach ($State in @( @{ RowKey = 'delta-b!current'; DriveId = 'b!current'; SiteId = 's1'; LastScanId = $ScanId } @{ RowKey = 'delta-b!vanished'; DriveId = 'b!vanished'; SiteId = 's2'; LastScanId = 'previous-scan' } @@ -471,10 +571,7 @@ Describe 'Resumable sharing-links scan' { It 'sweeps every row the scan did not write when the scan was a full sweep' { $ScanId = 'scan-final-2' - Add-CIPPAzDataTableEntity -TableName 'CippSharingLinksState' -Entity @{ - PartitionKey = 'contoso.com'; RowKey = 'scan'; ScanId = $ScanId; PendingSites = 0; TotalSites = 1 - FailedSites = '[]'; FullSweep = $true; StartedUtc = '2026-08-12T00:00:00Z' - } + Initialize-TestScan -ScanId $ScanId -TotalSites 1 -FullSweep $true Add-CacheRow -RowKey 'SharePointSharingLinks-b!current_01ITEMA_p1' -RunId $ScanId Add-CacheRow -RowKey 'SharePointSharingLinks-b!orphandrive_01ITEMO_p1' -RunId 'ancient-scan' @@ -486,10 +583,7 @@ Describe 'Resumable sharing-links scan' { } It 'does no housekeeping when a newer scan owns the state' { - Add-CIPPAzDataTableEntity -TableName 'CippSharingLinksState' -Entity @{ - PartitionKey = 'contoso.com'; RowKey = 'scan'; ScanId = 'scan-newer'; PendingSites = 3; TotalSites = 3 - FailedSites = '[]'; FullSweep = $true; StartedUtc = '2026-08-12T00:00:00Z' - } + Initialize-TestScan -ScanId 'scan-newer' -TotalSites 3 -FullSweep $true Add-CIPPAzDataTableEntity -TableName 'CippSharingLinksState' -Entity @{ PartitionKey = 'contoso.com'; RowKey = 'delta-b!inflight'; DriveId = 'b!inflight'; SiteId = 's1' DeltaLink = 'x'; LastScanId = 'scan-newer'; LastScanUtc = 'x'; LastFullScanUtc = 'x' From 76834eb1ff53bbcea1a7297d875c936ace468f3c Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Tue, 18 Aug 2026 19:51:11 +0800 Subject: [PATCH 139/226] feat(tools): sharing-links seeding and scan measurement dev scripts New-SharingLinksTestData seeds a dedicated document library with many small files and organization-scope links (chunked and resumable via -StartIndex, Retry-After aware, -Cleanup to remove). The measurement script times the scan phases (delta paging, permission batches, the real site activity) and projects wall time against the background activity budget; the comparison script benchmarks the classic delta-plus-permissions collection against the PrincipalCount pre-filter and Graph Search discovery on the same drive. All three require a dot-sourced Initialize-DevEnvironment session. --- build/tools/Compare-SharingLinksMethods.ps1 | 195 +++++++++++++++++ build/tools/Measure-SharingLinksScan.ps1 | 190 +++++++++++++++++ build/tools/New-SharingLinksTestData.ps1 | 218 ++++++++++++++++++++ 3 files changed, 603 insertions(+) create mode 100644 build/tools/Compare-SharingLinksMethods.ps1 create mode 100644 build/tools/Measure-SharingLinksScan.ps1 create mode 100644 build/tools/New-SharingLinksTestData.ps1 diff --git a/build/tools/Compare-SharingLinksMethods.ps1 b/build/tools/Compare-SharingLinksMethods.ps1 new file mode 100644 index 0000000000..2cf2296bed --- /dev/null +++ b/build/tools/Compare-SharingLinksMethods.ps1 @@ -0,0 +1,195 @@ +<# +.SYNOPSIS + Benchmarks alternative ways to collect SharePoint sharing-link data for the report + cache, against the same drive, and reports requests + wall time per method. + +.DESCRIPTION + Methods compared (full-scan collection for one document library): + + A. delta + permissions-for-all-shared (current Push-DBCacheSharePointSiteSharingLinks + shape). On group-connected team sites every item carries the shared facet, so this + permission-reads EVERY item: items/20 $batch requests. + + B. list-items PrincipalCount pre-filter + permissions-for-flagged. Enumerates + /sites/{sid}/lists/{lid}/items with $expand=fields($select=...,PrincipalCount). + An item whose PrincipalCount exceeds the list's inherited baseline has extra role + assignments - i.e. a sharing link or unique grant. Only those items get a + $batch driveItem?$expand=permissions read (metadata + permissions in one call). + + C. Graph Search API discovery (informational): KQL queries for user-specific, + external and anonymous shares. Cheap but cannot see organization-scope links, + and depends on index freshness - reported for completeness. + + Requires a dev session: dot-source build/tools/Initialize-DevEnvironment.ps1 first. + +.EXAMPLE + ./Compare-SharingLinksMethods.ps1 -TenantFilter zacgoose.onmicrosoft.com -SiteUrl https://zacgoose.sharepoint.com/sites/ZacRichards -LibraryName CippSharingPerfTest -SearchRegion AUS +#> +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter, + + [Parameter(Mandatory = $true)] + [string]$SiteUrl, + + [string]$LibraryName = 'CippSharingPerfTest', + + # Graph Search app-only requires the tenant's region (the API error names the right one). + [string]$SearchRegion = 'AUS', + + [switch]$SkipMethodA +) + +$ErrorActionPreference = 'Stop' +if (-not (Get-Command Get-GraphToken -ErrorAction SilentlyContinue)) { + throw 'Dev session not initialized. Dot-source build/tools/Initialize-DevEnvironment.ps1 first.' +} + +$SiteUri = [uri]$SiteUrl +$SitePath = $SiteUri.AbsolutePath.TrimEnd('/') +$SiteLookup = if ([string]::IsNullOrEmpty($SitePath)) { $SiteUri.Host } else { "$($SiteUri.Host):$SitePath" } +$Site = New-GraphGetRequest -uri "https://graph.microsoft.com/v1.0/sites/$SiteLookup" -tenantid $TenantFilter -asapp $true +$Lists = New-GraphGetRequest -uri "https://graph.microsoft.com/v1.0/sites/$($Site.id)/lists?`$filter=displayName eq '$LibraryName'" -tenantid $TenantFilter -asapp $true +$List = @($Lists) | Where-Object { $_.displayName -eq $LibraryName } | Select-Object -First 1 +if (-not $List) { throw "Library '$LibraryName' not found." } +$Drive = New-GraphGetRequest -uri "https://graph.microsoft.com/v1.0/sites/$($Site.id)/lists/$($List.id)/drive?`$select=id,name" -tenantid $TenantFilter -asapp $true +Write-Host "Site $($Site.id)" +Write-Host "List $($List.id), Drive $($Drive.id)" +$Results = [System.Collections.Generic.List[object]]::new() + +# ---- Method A: delta + permissions for every shared-facet item -------------------------------- +if (-not $SkipMethodA) { + Write-Host '' + Write-Host '--- Method A: delta + permissions for all shared-facet items (current) ---' + $DeltaSelect = 'id,name,webUrl,folder,shared,deleted,size,lastModifiedDateTime' + $Uri = "https://graph.microsoft.com/beta/drives/$($Drive.id)/root/delta?`$select=$DeltaSelect&`$top=999" + $Sw = [System.Diagnostics.Stopwatch]::StartNew() + $Pages = 0 + $Items = 0 + $SharedIds = [System.Collections.Generic.List[string]]::new() + while ($Uri) { + $Page = New-GraphGetRequest -uri $Uri -tenantid $TenantFilter -asapp $true -noPagination $true -SkipValueExtraction + $Pages++ + foreach ($PageItem in @($Page.value)) { + if ($PageItem.name -eq 'root') { continue } + $Items++ + if ($PageItem.shared -and -not $PageItem.deleted) { $SharedIds.Add([string]$PageItem.id) } + } + $Uri = if ($Page.'@odata.deltaLink') { $null } else { [string]$Page.'@odata.nextLink' } + } + $DeltaSeconds = $Sw.Elapsed.TotalSeconds + + $RequestId = 0 + $PermRequests = foreach ($ItemId in $SharedIds) { + @{ id = "$RequestId"; method = 'GET'; url = "drives/$($Drive.id)/items/$ItemId/permissions" } + $RequestId++ + } + $PermSw = [System.Diagnostics.Stopwatch]::StartNew() + $PermResponses = @(New-GraphBulkRequest -tenantid $TenantFilter -Requests @($PermRequests) -asapp $true) + $PermSw.Stop() + $Sw.Stop() + $LinkRows = 0 + foreach ($Response in $PermResponses) { + if ($Response.status -and $Response.status -ne 200) { continue } + $LinkRows += @($Response.body.value | Where-Object { $_.link -and -not $_.inheritedFrom }).Count + } + $BatchCount = [Math]::Ceiling($SharedIds.Count / 20) + $Results.Add([PSCustomObject]@{ + Method = 'A: delta + perms for all shared' + Items = $Items + PermTargets = $SharedIds.Count + LinkPerms = $LinkRows + Requests = $Pages + $BatchCount + EnumSeconds = [Math]::Round($DeltaSeconds, 1) + PermSeconds = [Math]::Round($PermSw.Elapsed.TotalSeconds, 1) + TotalSeconds = [Math]::Round($Sw.Elapsed.TotalSeconds, 1) + }) + Write-Host (" items {0}, perm-targets {1}, {2} delta pages + {3} batches, {4:N1}s (delta {5:N1}s + perms {6:N1}s)" -f ` + $Items, $SharedIds.Count, $Pages, $BatchCount, $Sw.Elapsed.TotalSeconds, $DeltaSeconds, $PermSw.Elapsed.TotalSeconds) +} + +# ---- Method B: list-items PrincipalCount pre-filter ------------------------------------------- +Write-Host '' +Write-Host '--- Method B: list-items PrincipalCount pre-filter + perms for flagged ---' +$Uri = "https://graph.microsoft.com/v1.0/sites/$($Site.id)/lists/$($List.id)/items?`$top=999&`$select=id&`$expand=fields(`$select=FileLeafRef,FileRef,PrincipalCount)" +$Sw = [System.Diagnostics.Stopwatch]::StartNew() +$Pages = 0 +$Items = 0 +$Rows = [System.Collections.Generic.List[object]]::new() +while ($Uri) { + $Page = New-GraphGetRequest -uri $Uri -tenantid $TenantFilter -asapp $true -noPagination $true -SkipValueExtraction + $Pages++ + foreach ($ListItem in @($Page.value)) { + $Items++ + $Rows.Add([PSCustomObject]@{ FileRef = [string]$ListItem.fields.FileRef; PrincipalCount = [int]$ListItem.fields.PrincipalCount }) + } + $Uri = [string]$Page.'@odata.nextLink' +} +$EnumSeconds = $Sw.Elapsed.TotalSeconds + +# Baseline = the list's dominant (inherited) principal count; anything above it has extra +# role assignments. Items BELOW the mode (rare custom-permission cases) are read too. +$Mode = ($Rows | Group-Object PrincipalCount | Sort-Object Count -Descending | Select-Object -First 1).Name +$Flagged = @($Rows | Where-Object { [string]$_.PrincipalCount -ne $Mode }) +Write-Host (" enumerated {0} items in {1} pages / {2:N1}s; baseline PrincipalCount={3}; flagged {4}" -f $Items, $Pages, $EnumSeconds, $Mode, $Flagged.Count) + +# Permissions + metadata in one batched call per flagged item, addressed by server-relative path. +$SitePathLength = ([uri]$SiteUrl).AbsolutePath.TrimEnd('/').Length +$RequestId = 0 +$PermRequests = foreach ($FlaggedItem in $Flagged) { + # FileRef is server-relative (/sites/x/Lib/folder/file); drive addressing wants the + # path relative to the drive root, so strip "/sites/x//". + $DriveRelative = $FlaggedItem.FileRef.Substring($SitePathLength).TrimStart('/') + $DriveRelative = ($DriveRelative -split '/', 2)[1] + if (-not $DriveRelative) { continue } + $Encoded = ($DriveRelative -split '/' | ForEach-Object { [uri]::EscapeDataString($_) }) -join '/' + @{ id = "$RequestId"; method = 'GET'; url = "drives/$($Drive.id)/root:/${Encoded}?`$select=id,name,size,webUrl,lastModifiedDateTime,folder&`$expand=permissions" } + $RequestId++ +} +$PermSw = [System.Diagnostics.Stopwatch]::StartNew() +$PermResponses = if (@($PermRequests).Count -gt 0) { @(New-GraphBulkRequest -tenantid $TenantFilter -Requests @($PermRequests) -asapp $true) } else { @() } +$PermSw.Stop() +$Sw.Stop() +$LinkRows = 0 +foreach ($Response in $PermResponses) { + if ($Response.status -and $Response.status -ne 200) { continue } + $LinkRows += @($Response.body.permissions | Where-Object { $_.link -and -not $_.inheritedFrom }).Count +} +$BatchCount = [Math]::Ceiling([Math]::Max(@($PermRequests).Count, 1) / 20) +$Results.Add([PSCustomObject]@{ + Method = 'B: PrincipalCount pre-filter' + Items = $Items + PermTargets = $Flagged.Count + LinkPerms = $LinkRows + Requests = $Pages + $BatchCount + EnumSeconds = [Math]::Round($EnumSeconds, 1) + PermSeconds = [Math]::Round($PermSw.Elapsed.TotalSeconds, 1) + TotalSeconds = [Math]::Round($Sw.Elapsed.TotalSeconds, 1) + }) +Write-Host (" perm-read {0} flagged items in {1} batches / {2:N1}s; {3} link permissions found; total {4:N1}s" -f ` + $Flagged.Count, $BatchCount, $PermSw.Elapsed.TotalSeconds, $LinkRows, $Sw.Elapsed.TotalSeconds) + +# ---- Method C: Graph Search discovery (informational) ----------------------------------------- +Write-Host '' +Write-Host '--- Method C: Graph Search API discovery (informational) ---' +foreach ($Query in @( + @{ Label = 'user-specific shares'; KQL = "SharedWithUsersOWSUSER:* path:$SiteUrl" }, + @{ Label = 'external-viewable'; KQL = "ViewableByExternalUsers:true path:$SiteUrl" }, + @{ Label = 'anonymous-viewable'; KQL = "ViewableByAnonymousUsers:true path:$SiteUrl" } + )) { + $Body = @{ requests = @(@{ entityTypes = @('driveItem'); query = @{ queryString = $Query.KQL }; from = 0; size = 25; region = $SearchRegion }) } | ConvertTo-Json -Depth 8 -Compress + try { + $Sw = [System.Diagnostics.Stopwatch]::StartNew() + $SearchResult = New-GraphPOSTRequest -uri 'https://graph.microsoft.com/v1.0/search/query' -tenantid $TenantFilter -asApp $true -type POST -body $Body + $Sw.Stop() + $Container = if ($SearchResult.hitsContainers) { $SearchResult.hitsContainers[0] } else { $SearchResult.value[0].hitsContainers[0] } + Write-Host (" [{0}] total={1} ({2:N0} ms) - org-scope links are NOT visible to search" -f $Query.Label, $Container.total, $Sw.Elapsed.TotalMilliseconds) + } catch { + Write-Host (" [{0}] FAILED: {1}" -f $Query.Label, $_.Exception.Message) + } +} + +Write-Host '' +$Results | Format-Table -AutoSize +$Results diff --git a/build/tools/Measure-SharingLinksScan.ps1 b/build/tools/Measure-SharingLinksScan.ps1 new file mode 100644 index 0000000000..fd342e16a9 --- /dev/null +++ b/build/tools/Measure-SharingLinksScan.ps1 @@ -0,0 +1,190 @@ +<# +.SYNOPSIS + Measures sharing-links scan throughput on a drive and projects site-scan wall time + against the Craft background activity budget (Worker:BgTimeoutSeconds). + +.DESCRIPTION + Times the two Graph phases Push-DBCacheSharePointSiteSharingLinks actually runs: + + Phase A - full delta enumeration of the drive (same URI shape as the scan: + /beta/drives/{id}/root/delta?$select=...&$top=999), per-page latency. + Phase B - permission reads for the shared items found, via New-GraphBulkRequest + in the same 20-per-$batch shape as Add-CIPPSharingRows. + Phase C - (optional, -RunActivity) end-to-end run of the real site activity with a + synthetic scan row, timing the whole thing including table writes. + + Then extrapolates: given measured seconds/page and seconds/permission-batch, at what + item count does one site activity exceed the platform kill limit (BgTimeoutSeconds, + default 1200s)? Craft marks a timed-out task Failed without retry, so a site that + cannot finish inside the budget never completes its scan. + + Requires a dev session: dot-source build/tools/Initialize-DevEnvironment.ps1 first. + +.EXAMPLE + ./Measure-SharingLinksScan.ps1 -TenantFilter zacgoose.onmicrosoft.com -DriveId b!xxxx +.EXAMPLE + ./Measure-SharingLinksScan.ps1 -TenantFilter zacgoose.onmicrosoft.com -SiteUrl https://zacgoose.sharepoint.com/sites/ZacRichards -LibraryName CippSharingPerfTest -RunActivity +#> +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter, + + [string]$DriveId, + + [string]$SiteUrl, + + [string]$LibraryName = 'CippSharingPerfTest', + + # Also run the real Push-DBCacheSharePointSiteSharingLinks end-to-end (writes to the + # dev reporting DB tables). + [switch]$RunActivity, + + # Activity wall-clock budget to project against (Craft Worker:BgTimeoutSeconds). + [int]$BudgetSeconds = 1200 +) + +$ErrorActionPreference = 'Stop' +if (-not (Get-Command Get-GraphToken -ErrorAction SilentlyContinue)) { + throw 'Dev session not initialized. Dot-source build/tools/Initialize-DevEnvironment.ps1 first.' +} + +# --- resolve drive ----------------------------------------------------------------------------- +$Site = $null +if (-not $DriveId) { + if (-not $SiteUrl) { throw 'Provide -DriveId or -SiteUrl + -LibraryName.' } + $SiteUri = [uri]$SiteUrl + $SitePath = $SiteUri.AbsolutePath.TrimEnd('/') + $SiteLookup = if ([string]::IsNullOrEmpty($SitePath)) { $SiteUri.Host } else { "$($SiteUri.Host):$SitePath" } + $Site = New-GraphGetRequest -uri "https://graph.microsoft.com/v1.0/sites/$SiteLookup" -tenantid $TenantFilter -asapp $true + $Lists = New-GraphGetRequest -uri "https://graph.microsoft.com/v1.0/sites/$($Site.id)/lists?`$filter=displayName eq '$LibraryName'" -tenantid $TenantFilter -asapp $true + $List = @($Lists) | Where-Object { $_.displayName -eq $LibraryName } | Select-Object -First 1 + if (-not $List) { throw "Library '$LibraryName' not found on $SiteUrl" } + $Drive = New-GraphGetRequest -uri "https://graph.microsoft.com/v1.0/sites/$($Site.id)/lists/$($List.id)/drive?`$select=id,name" -tenantid $TenantFilter -asapp $true + $DriveId = $Drive.id +} +Write-Host "Drive: $DriveId" + +# --- Phase A: delta enumeration ---------------------------------------------------------------- +$DeltaSelect = 'id,name,webUrl,folder,shared,deleted,size,lastModifiedDateTime' +$Uri = "https://graph.microsoft.com/beta/drives/$DriveId/root/delta?`$select=$DeltaSelect&`$top=999" +$PageTimes = [System.Collections.Generic.List[double]]::new() +$Items = 0 +$SharedItems = [System.Collections.Generic.List[object]]::new() +$TotalSw = [System.Diagnostics.Stopwatch]::StartNew() +while ($Uri) { + $PageSw = [System.Diagnostics.Stopwatch]::StartNew() + $Page = New-GraphGetRequest -uri $Uri -tenantid $TenantFilter -asapp $true -noPagination $true -SkipValueExtraction + $PageSw.Stop() + $PageTimes.Add($PageSw.Elapsed.TotalMilliseconds) + $PageItems = @($Page.value) + $Items += $PageItems.Count + foreach ($PageItem in $PageItems) { + if ($PageItem.shared -and -not $PageItem.deleted) { $SharedItems.Add($PageItem) } + } + Write-Host (" page {0,3}: {1,4} items, {2,6:N0} ms" -f $PageTimes.Count, $PageItems.Count, $PageSw.Elapsed.TotalMilliseconds) + $Uri = if ($Page.'@odata.deltaLink') { $null } else { [string]$Page.'@odata.nextLink' } +} +$TotalSw.Stop() +$Sorted = @($PageTimes | Sort-Object) +$AvgPage = ($PageTimes | Measure-Object -Average).Average +$P50 = $Sorted[[Math]::Floor($Sorted.Count * 0.5)] +$P95 = $Sorted[[Math]::Min([Math]::Floor($Sorted.Count * 0.95), $Sorted.Count - 1)] +$ItemsPerPage = if ($PageTimes.Count -gt 0) { $Items / $PageTimes.Count } else { 0 } + +Write-Host '' +Write-Host ('Phase A (delta): {0:N0} items, {1} shared, {2} pages in {3:N1}s' -f $Items, $SharedItems.Count, $PageTimes.Count, $TotalSw.Elapsed.TotalSeconds) +Write-Host (' page ms avg {0:N0} / p50 {1:N0} / p95 {2:N0}; items/page avg {3:N0}; items/s {4:N0}' -f $AvgPage, $P50, $P95, $ItemsPerPage, ($Items / $TotalSw.Elapsed.TotalSeconds)) + +# --- Phase B: permission batches --------------------------------------------------------------- +$BatchSeconds = $null +$PermBatches = 0 +if ($SharedItems.Count -gt 0) { + $RequestId = 0 + $PermissionRequests = foreach ($SharedItem in $SharedItems) { + @{ id = "$RequestId"; method = 'GET'; url = "drives/$DriveId/items/$($SharedItem.id)/permissions" } + $RequestId++ + } + $PermSw = [System.Diagnostics.Stopwatch]::StartNew() + $Responses = New-GraphBulkRequest -tenantid $TenantFilter -Requests @($PermissionRequests) -asapp $true + $PermSw.Stop() + $PermBatches = [Math]::Ceiling($SharedItems.Count / 20) + $BatchSeconds = $PermSw.Elapsed.TotalSeconds / $PermBatches + $OkCount = @($Responses | Where-Object { -not $_.status -or $_.status -eq 200 }).Count + $ThrottledCount = @($Responses | Where-Object { $_.status -eq 429 }).Count + Write-Host '' + Write-Host ('Phase B (permissions): {0} items in {1} batches of 20, {2:N1}s total, {3:N2}s/batch ({4} ok, {5} throttled-and-dropped)' -f ` + $SharedItems.Count, $PermBatches, $PermSw.Elapsed.TotalSeconds, $BatchSeconds, $OkCount, $ThrottledCount) +} else { + Write-Host 'Phase B skipped: no shared items found.' +} + +# --- Phase C: real activity end-to-end --------------------------------------------------------- +if ($RunActivity) { + if (-not $Site) { throw '-RunActivity needs -SiteUrl (site context for the activity payload).' } + foreach ($Module in 'CIPPDB', 'CIPPActivityTriggers') { + if (-not (Get-Module $Module)) { Import-Module (Join-Path $env:CIPPRootPath "Modules\$Module") -Force } + } + $ScanId = [guid]::NewGuid().ToString() + $StateTable = Get-CippTable -tablename 'CippSharingLinksState' + Add-CIPPAzDataTableEntity @StateTable -Entity @{ + PartitionKey = $TenantFilter + RowKey = 'scan' + ScanId = $ScanId + PendingSites = 1 + TotalSites = 1 + FailedSites = '[]' + FullSweep = $false + StartedUtc = [string]([DateTimeOffset]::UtcNow.ToString('o')) + } -Force + + $Domains = New-GraphGetRequest -uri "https://graph.microsoft.com/v1.0/domains?`$select=id,isVerified" -tenantid $TenantFilter -asapp $true + $Item = [PSCustomObject]@{ + TenantFilter = $TenantFilter + SiteId = $Site.id + SiteName = $Site.displayName + SiteUrl = $SiteUrl + IsPersonalSite = $false + InternalDomains = @(@($Domains | Where-Object { $_.isVerified }).id) + ScanId = $ScanId + ForceFull = $true + } + Write-Host '' + Write-Host "Phase C: running Push-DBCacheSharePointSiteSharingLinks end-to-end (scan $ScanId)..." + $ActSw = [System.Diagnostics.Stopwatch]::StartNew() + $null = Push-DBCacheSharePointSiteSharingLinks -Item $Item + $ActSw.Stop() + Write-Host ('Phase C (full site activity incl. table writes + finalisation): {0:N1}s' -f $ActSw.Elapsed.TotalSeconds) +} + +# --- Projection -------------------------------------------------------------------------------- +Write-Host '' +Write-Host "=== Projection against the ${BudgetSeconds}s activity budget ===" +$PageSec = $AvgPage / 1000 +$ShareRatio = if ($Items -gt 0) { $SharedItems.Count / $Items } else { 0.05 } +$BatchSec = $BatchSeconds ?? 1.0 +Write-Host (' model: {0:N2}s/page ({1:N0} items/page), {2:N2}s/permission-batch, {3:P1} items shared' -f $PageSec, $ItemsPerPage, $BatchSec, $ShareRatio) +Write-Host '' +Write-Host (' {0,12} {1,10} {2,12} {3,14} {4,12}' -f 'items', 'pages', 'perm-batches', 'est wall (min)', 'vs budget') +foreach ($N in 10000, 25000, 50000, 100000, 250000, 500000, 1000000, 2000000, 5000000) { + $Pages = [Math]::Ceiling($N / [Math]::Max($ItemsPerPage, 1)) + $Batches = [Math]::Ceiling(($N * $ShareRatio) / 20) + $Est = $Pages * $PageSec + $Batches * $BatchSec + $Flag = if ($Est -gt $BudgetSeconds) { 'OVER' } elseif ($Est -gt $BudgetSeconds * 0.75) { 'at risk' } else { 'ok' } + Write-Host (' {0,12:N0} {1,10:N0} {2,12:N0} {3,14:N1} {4,12}' -f $N, $Pages, $Batches, ($Est / 60), $Flag) +} +$MaxItems = [Math]::Floor($BudgetSeconds / ($PageSec / [Math]::Max($ItemsPerPage, 1) + $ShareRatio / 20 * $BatchSec)) +Write-Host '' +Write-Host (' -> one activity can scan roughly {0:N0} items inside {1}s at this share ratio (no throttling headroom included)' -f $MaxItems, $BudgetSeconds) + +[PSCustomObject]@{ + DriveId = $DriveId + Items = $Items + SharedItems = $SharedItems.Count + Pages = $PageTimes.Count + AvgPageMs = [Math]::Round($AvgPage, 0) + P95PageMs = [Math]::Round($P95, 0) + ItemsPerPage = [Math]::Round($ItemsPerPage, 0) + SecPerPermBatch = if ($BatchSeconds) { [Math]::Round($BatchSeconds, 2) } else { $null } + MaxItemsInBudget = $MaxItems +} diff --git a/build/tools/New-SharingLinksTestData.ps1 b/build/tools/New-SharingLinksTestData.ps1 new file mode 100644 index 0000000000..f6f5a1cc25 --- /dev/null +++ b/build/tools/New-SharingLinksTestData.ps1 @@ -0,0 +1,218 @@ +<# +.SYNOPSIS + Seeds a SharePoint document library with many small files (and sharing links on a + fraction of them) to measure sharing-links scan performance on large drives. + +.DESCRIPTION + Dev tool for sizing the SharePoint sharing-links cache fan-out + (Set-CIPPDBCacheSharePointSharingLinks / Push-DBCacheSharePointSiteSharingLinks). + + Creates (or reuses) a dedicated document library on the target site, uploads + -FileCount small text files into per-500-file subfolders with a shared pooled + HttpClient (parallel PUTs, Retry-After-aware), then creates an organization-scope + view link on every -ShareEvery'th file so the scan's permission-read phase has + realistic work to do. + + Idempotent: file names are deterministic (file-000001.txt ...), so re-running with a + larger -FileCount tops the library up; existing files are simply overwritten. + + Requires a dev session: dot-source build/tools/Initialize-DevEnvironment.ps1 first. + + Cleanup: -Cleanup deletes the seed folder (one call), leaving the empty library. + +.EXAMPLE + ./New-SharingLinksTestData.ps1 -TenantFilter zacgoose.onmicrosoft.com -SiteUrl https://zacgoose.sharepoint.com/sites/ZacRichards -FileCount 20000 + +.EXAMPLE + ./New-SharingLinksTestData.ps1 -TenantFilter zacgoose.onmicrosoft.com -SiteUrl https://zacgoose.sharepoint.com/sites/ZacRichards -Cleanup +#> +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter, + + [Parameter(Mandatory = $true)] + [string]$SiteUrl, + + [string]$LibraryName = 'CippSharingPerfTest', + + [int]$FileCount = 5000, + + # First file index to upload (1-based). Lets a large seed run in chunks: + # -StartIndex 10001 -FileCount 20000 uploads files 10001..20000. + [int]$StartIndex = 1, + + # Create a sharing link on every Nth file. 20 = 5% of files shared. + [int]$ShareEvery = 20, + + [int]$Concurrency = 8, + + # Files per subfolder; keeps any single folder from getting huge. + [int]$FolderSize = 500, + + [switch]$Cleanup +) + +$ErrorActionPreference = 'Stop' +if (-not (Get-Command Get-GraphToken -ErrorAction SilentlyContinue)) { + throw 'Dev session not initialized. Dot-source build/tools/Initialize-DevEnvironment.ps1 first.' +} + +# --- resolve site and library ------------------------------------------------------------------ +$SiteUri = [uri]$SiteUrl +$SitePath = $SiteUri.AbsolutePath.TrimEnd('/') +$SiteLookup = if ([string]::IsNullOrEmpty($SitePath)) { $SiteUri.Host } else { "$($SiteUri.Host):$SitePath" } +$Site = New-GraphGetRequest -uri "https://graph.microsoft.com/v1.0/sites/$SiteLookup" -tenantid $TenantFilter -asapp $true +Write-Host "Site: $($Site.displayName) ($($Site.id))" + +$Lists = New-GraphGetRequest -uri "https://graph.microsoft.com/v1.0/sites/$($Site.id)/lists?`$filter=displayName eq '$LibraryName'" -tenantid $TenantFilter -asapp $true +$List = @($Lists) | Where-Object { $_.displayName -eq $LibraryName } | Select-Object -First 1 +if (-not $List) { + if ($Cleanup) { Write-Host "Library '$LibraryName' does not exist; nothing to clean."; return } + Write-Host "Creating document library '$LibraryName'..." + $List = New-GraphPOSTRequest -uri "https://graph.microsoft.com/v1.0/sites/$($Site.id)/lists" -tenantid $TenantFilter -asApp $true -type POST -body (@{ + displayName = $LibraryName + list = @{ template = 'documentLibrary' } + } | ConvertTo-Json -Compress) +} +$Drive = New-GraphGetRequest -uri "https://graph.microsoft.com/v1.0/sites/$($Site.id)/lists/$($List.id)/drive?`$select=id,name,webUrl" -tenantid $TenantFilter -asapp $true +Write-Host "Drive: $($Drive.id)" + +$Token = (Get-GraphToken -tenantid $TenantFilter -AsApp $true).Authorization + +if ($Cleanup) { + Write-Host 'Deleting seed folder...' + $Handler = [System.Net.Http.HttpClientHandler]::new() + $Client = [System.Net.Http.HttpClient]::new($Handler) + try { + $Req = [System.Net.Http.HttpRequestMessage]::new('DELETE', "https://graph.microsoft.com/v1.0/drives/$($Drive.id)/root:/SeedData") + $Req.Headers.TryAddWithoutValidation('Authorization', $Token) | Out-Null + $Resp = $Client.Send($Req) + Write-Host "Delete returned $([int]$Resp.StatusCode). Library '$LibraryName' kept (empty)." + } finally { $Client.Dispose() } + return +} + +# --- seed files -------------------------------------------------------------------------------- +# One pooled HttpClient shared across parallel workers; each worker PUTs file content and +# retries on 429/503 honoring Retry-After. Item ids are collected for the share step. +$Client = [System.Net.Http.HttpClient]::new() +$Client.Timeout = [TimeSpan]::FromSeconds(100) + +$Sw = [System.Diagnostics.Stopwatch]::StartNew() +$AllItems = [System.Collections.Generic.List[object]]::new() +$ChunkSize = 500 +$Throttle429 = 0 + +for ($ChunkStart = $StartIndex; $ChunkStart -le $FileCount; $ChunkStart += $ChunkSize) { + $ChunkEnd = [Math]::Min($ChunkStart + $ChunkSize - 1, $FileCount) + $ChunkSw = [System.Diagnostics.Stopwatch]::StartNew() + + $Results = $ChunkStart..$ChunkEnd | ForEach-Object -ThrottleLimit $Concurrency -Parallel { + $i = $_ + $Client = $using:Client + $Token = $using:Token + $DriveId = ($using:Drive).id + $FolderSize = $using:FolderSize + $Folder = 'f{0:D4}' -f [int][Math]::Floor(($i - 1) / $FolderSize) + $Name = 'file-{0:D6}.txt' -f $i + $Url = "https://graph.microsoft.com/v1.0/drives/$DriveId/root:/SeedData/$Folder/${Name}:/content" + $Body = "CIPP sharing links perf seed file $i" + $Attempt = 0 + $Throttled = 0 + while ($true) { + $Attempt++ + $Req = [System.Net.Http.HttpRequestMessage]::new('PUT', $Url) + $Req.Headers.TryAddWithoutValidation('Authorization', $Token) | Out-Null + $Req.Content = [System.Net.Http.StringContent]::new($Body, [System.Text.Encoding]::UTF8, 'text/plain') + try { + $Resp = $Client.Send($Req) + $Status = [int]$Resp.StatusCode + if ($Status -in 200, 201) { + $Json = $Resp.Content.ReadAsStringAsync().GetAwaiter().GetResult() | ConvertFrom-Json + [PSCustomObject]@{ Index = $i; ItemId = $Json.id; Throttled = $Throttled } + break + } + if ($Status -in 429, 503, 504 -and $Attempt -lt 8) { + $Throttled++ + $Wait = 5 + $Vals = $null + if ($Resp.Headers.TryGetValues('Retry-After', [ref]$Vals)) { $Wait = [int](@($Vals)[0]) } + Start-Sleep -Seconds ([Math]::Min($Wait, 120)) + continue + } + $ErrBody = $Resp.Content.ReadAsStringAsync().GetAwaiter().GetResult() + [PSCustomObject]@{ Index = $i; ItemId = $null; Error = "HTTP ${Status}: $ErrBody"; Throttled = $Throttled } + break + } catch { + if ($Attempt -lt 8) { Start-Sleep -Seconds 5; continue } + [PSCustomObject]@{ Index = $i; ItemId = $null; Error = $_.Exception.Message; Throttled = $Throttled } + break + } finally { + $Req.Dispose() + } + } + } + + foreach ($R in @($Results)) { + if ($R.ItemId) { $AllItems.Add($R) } else { Write-Warning "file $($R.Index): $($R.Error)" } + $Throttle429 += $R.Throttled + } + $Rate = [Math]::Round(($ChunkEnd - $ChunkStart + 1) / $ChunkSw.Elapsed.TotalSeconds, 1) + Write-Host (" {0}/{1} files ({2}/s this chunk, {3} 429-retries total, {4:mm\:ss} elapsed)" -f $ChunkEnd, $FileCount, $Rate, $Throttle429, $Sw.Elapsed) +} + +Write-Host ("Upload done: {0} files in {1:mm\:ss} ({2}/s overall)" -f $AllItems.Count, $Sw.Elapsed, [Math]::Round($AllItems.Count / $Sw.Elapsed.TotalSeconds, 1)) + +# --- create sharing links ---------------------------------------------------------------------- +$ToShare = @($AllItems | Where-Object { $_.Index % $ShareEvery -eq 0 }) +Write-Host "Creating $($ToShare.Count) sharing links (every $($ShareEvery)th file)..." +$ShareSw = [System.Diagnostics.Stopwatch]::StartNew() +$Shared = 0 +$ShareResults = $ToShare | ForEach-Object -ThrottleLimit $Concurrency -Parallel { + $Item = $_ + $Client = $using:Client + $Token = $using:Token + $DriveId = ($using:Drive).id + $Url = "https://graph.microsoft.com/v1.0/drives/$DriveId/items/$($Item.ItemId)/createLink" + $Body = '{"type":"view","scope":"organization"}' + $Attempt = 0 + while ($true) { + $Attempt++ + $Req = [System.Net.Http.HttpRequestMessage]::new('POST', $Url) + $Req.Headers.TryAddWithoutValidation('Authorization', $Token) | Out-Null + $Req.Content = [System.Net.Http.StringContent]::new($Body, [System.Text.Encoding]::UTF8, 'application/json') + try { + $Resp = $Client.Send($Req) + $Status = [int]$Resp.StatusCode + if ($Status -in 200, 201) { $true; break } + if ($Status -in 429, 503, 504 -and $Attempt -lt 8) { + $Wait = 5 + $Vals = $null + if ($Resp.Headers.TryGetValues('Retry-After', [ref]$Vals)) { $Wait = [int](@($Vals)[0]) } + Start-Sleep -Seconds ([Math]::Min($Wait, 120)) + continue + } + Write-Warning "createLink $($Item.Index): HTTP $Status" + $false; break + } catch { + if ($Attempt -lt 8) { Start-Sleep -Seconds 5; continue } + Write-Warning "createLink $($Item.Index): $($_.Exception.Message)" + $false; break + } finally { + $Req.Dispose() + } + } +} +$Shared = @($ShareResults | Where-Object { $_ }).Count +$Client.Dispose() +Write-Host ("Links done: {0}/{1} in {2:mm\:ss} ({3}/s)" -f $Shared, $ToShare.Count, $ShareSw.Elapsed, [Math]::Round($Shared / [Math]::Max($ShareSw.Elapsed.TotalSeconds, 1), 1)) + +[PSCustomObject]@{ + TenantFilter = $TenantFilter + SiteId = $Site.id + SiteUrl = $SiteUrl + DriveId = $Drive.id + Files = $AllItems.Count + SharedFiles = $Shared +} From 007298969aeda09fe36633cda0f54e4d83097f03 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Tue, 18 Aug 2026 19:57:38 +0800 Subject: [PATCH 140/226] fix(auth): enforce tenant scope on AnyTenant live and write endpoints AnyTenant skips the framework per-tenant check, so each endpoint now gates itself: restricted callers resolve the target tenant through the scope-narrowed Get-Tenants (or filter rows via Select-CippAllowedTenantData), failing closed. Estate-wide config writes (extension/custom-data mappings, tenant onboarding, tenant group rules) require an unrestricted scope. Covers 26 endpoints, with Pester tests for the three gate shapes. --- .../CIPP/Core/Invoke-ExecRemoveSnooze.ps1 | 15 +++ .../CIPP/Core/Invoke-ExecSnoozeAlert.ps1 | 9 ++ .../Core/Invoke-ExecUniversalSearchV2.ps1 | 13 ++- .../CIPP/Core/Invoke-ListDirectoryObjects.ps1 | 11 ++ .../Invoke-ExecExtensionMapping.ps1 | 12 +++ .../Invoke-ListScheduledItemDetails.ps1 | 11 ++ .../Scheduler/Invoke-RemoveScheduledItem.ps1 | 14 +++ .../CIPP/Settings/Invoke-ExecCustomData.ps1 | 26 +++++ .../CIPP/Settings/Invoke-ExecDnsConfig.ps1 | 16 ++- .../Invoke-ExecRunTenantGroupRule.ps1 | 9 ++ .../CIPP/Setup/Invoke-ExecAddTenant.ps1 | 9 ++ .../Spamfilter/Invoke-AddSpamFilter.ps1 | 9 ++ .../Endpoint/MEM/Invoke-AddIntuneTemplate.ps1 | 7 ++ .../MEM/Invoke-ExecCompareIntunePolicy.ps1 | 10 ++ .../Endpoint/MEM/Invoke-ListCVEManagement.ps1 | 6 ++ .../Users/Invoke-ListUserPhoto.ps1 | 9 ++ .../Invoke-ExecSharePointTemplate.ps1 | 6 ++ .../Alerts/Invoke-ListAuditLogTest.ps1 | 10 ++ .../Tenant/Invoke-EditTenant.ps1 | 8 ++ .../Invoke-EditTenantOffboardingDefaults.ps1 | 9 ++ .../Invoke-RemoveTenantCapabilitiesCache.ps1 | 9 ++ .../Conditional/Invoke-AddNamedLocation.ps1 | 9 ++ .../Conditional/Invoke-ExecNamedLocation.ps1 | 6 ++ .../Tenant/Standards/Invoke-ExecBPA.ps1 | 12 +++ .../Standards/Invoke-ExecDomainAnalyser.ps1 | 13 +++ .../Standards/Invoke-ListDomainHealth.ps1 | 12 ++- .../Invoke-ExecRemoveSnooze.Tests.ps1 | 100 ++++++++++++++++++ .../Endpoint/Invoke-ExecSnoozeAlert.Tests.ps1 | 89 ++++++++++++++++ .../Invoke-ListCVEManagement.Tests.ps1 | 26 +++++ .../Invoke-ListDirectoryObjects.Tests.ps1 | 95 +++++++++++++++++ 30 files changed, 585 insertions(+), 5 deletions(-) create mode 100644 backend/Tests/Endpoint/Invoke-ExecRemoveSnooze.Tests.ps1 create mode 100644 backend/Tests/Endpoint/Invoke-ExecSnoozeAlert.Tests.ps1 create mode 100644 backend/Tests/Endpoint/Invoke-ListDirectoryObjects.Tests.ps1 diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecRemoveSnooze.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecRemoveSnooze.ps1 index ccfc01f630..e20117935b 100644 --- a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecRemoveSnooze.ps1 +++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecRemoveSnooze.ps1 @@ -23,6 +23,21 @@ function Invoke-ExecRemoveSnooze { } $SnoozeTable = Get-CIPPTable -tablename 'AlertSnooze' + + # AnyTenant: restricted callers may only remove snoozes for tenants in scope + $AllowedTenants = Test-CIPPAccess -Request $Request -TenantList + if ($AllowedTenants -notcontains 'AllTenants') { + $SafePartitionKey = ConvertTo-CIPPODataFilterValue -Value $PartitionKey -Type String + $SafeRowKey = ConvertTo-CIPPODataFilterValue -Value $RowKey -Type String + $Existing = Get-CIPPAzDataTableEntity @SnoozeTable -Filter "PartitionKey eq '$SafePartitionKey' and RowKey eq '$SafeRowKey'" + if (-not $Existing.Tenant -or -not (Get-Tenants -TenantFilter $Existing.Tenant)) { + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::Forbidden + Body = @{ Results = 'Access to this snooze is not allowed' } + }) + } + } + Remove-CIPPAzDataTableEntity @SnoozeTable -Entity @{ PartitionKey = $PartitionKey RowKey = $RowKey diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecSnoozeAlert.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecSnoozeAlert.ps1 index fc57f7ab11..b378074eed 100644 --- a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecSnoozeAlert.ps1 +++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecSnoozeAlert.ps1 @@ -35,6 +35,15 @@ function Invoke-ExecSnoozeAlert { }) } + # AnyTenant: enforce tenant scope here; Get-Tenants is narrowed to the caller's allowed tenants + $AllowedTenants = Test-CIPPAccess -Request $Request -TenantList + if ($AllowedTenants -notcontains 'AllTenants' -and -not (Get-Tenants -TenantFilter $TenantFilter)) { + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::Forbidden + Body = @{ Results = 'Access to this tenant is not allowed' } + }) + } + # Compute content hash for this alert item $HashResult = Get-AlertContentHash -AlertItem $AlertItem diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecUniversalSearchV2.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecUniversalSearchV2.ps1 index b52eadbac5..fb9b39de6c 100644 --- a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecUniversalSearchV2.ps1 +++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecUniversalSearchV2.ps1 @@ -18,6 +18,13 @@ function Invoke-ExecUniversalSearchV2 { if ($AllowedTenants -notcontains 'AllTenants') { $TenantFilter = Get-Tenants | Select-Object -ExpandProperty defaultDomainName + # Empty scope: a null filter would search every tenant + if (-not $TenantFilter) { + return [HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::OK + Body = @() + } + } } else { $TenantFilter = 'allTenants' } @@ -34,10 +41,10 @@ function Invoke-ExecUniversalSearchV2 { $Results = Search-CIPPDbData -SearchTerms $SearchTerms -Types 'Apps', 'ServicePrincipals' -Limit $Limit -Properties 'id', 'appId', 'displayName', 'publisherName', 'appOwnerOrganizationId' -TenantFilter $TenantFilter } 'Licenses' { - # SKU lookup is universal — always search across all tenants regardless of caller scope. # No Properties filter so service plan names / friendly names embedded in the JSON - # still pass the secondary verification pass. - $Raw = Search-CIPPDbData -SearchTerms $SearchTerms -Types 'LicenseOverview' -TenantFilter 'allTenants' + # still pass the secondary verification pass. Scoped like the other types: the + # per-SKU result embeds per-tenant names and counts. + $Raw = Search-CIPPDbData -SearchTerms $SearchTerms -Types 'LicenseOverview' -TenantFilter $TenantFilter $BySku = [ordered]@{} foreach ($Row in $Raw) { diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ListDirectoryObjects.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ListDirectoryObjects.ps1 index 360f777b7d..30264a08f1 100644 --- a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ListDirectoryObjects.ps1 +++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ListDirectoryObjects.ps1 @@ -13,6 +13,17 @@ function Invoke-ListDirectoryObjects { $AsApp = $Request.Body.asApp $Ids = $Request.Body.ids + # AnyTenant: enforce tenant scope here; Get-Tenants is narrowed to the caller's allowed tenants + if (-not $Request.Body.partnerLookup) { + $AllowedTenants = Test-CIPPAccess -Request $Request -TenantList + if ($AllowedTenants -notcontains 'AllTenants' -and -not (Get-Tenants -TenantFilter $TenantFilter)) { + return ([HttpResponseContext]@{ + StatusCode = [System.Net.HttpStatusCode]::Forbidden + Body = 'Access to this tenant is not allowed' + }) + } + } + $BaseUri = 'https://graph.microsoft.com/beta/directoryObjects/getByIds' if ($Request.Body.'$select') { $Uri = '{0}?$select={1}' -f $BaseUri, $Request.Body.'$select' diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Extensions/Invoke-ExecExtensionMapping.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Extensions/Invoke-ExecExtensionMapping.ps1 index 3482998609..3acb04722b 100644 --- a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Extensions/Invoke-ExecExtensionMapping.ps1 +++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Extensions/Invoke-ExecExtensionMapping.ps1 @@ -57,6 +57,18 @@ Function Invoke-ExecExtensionMapping { } } + # AnyTenant: mapping writes wipe and rewrite whole partitions and re-register per-tenant + # sync tasks, so they require an unrestricted tenant scope + if ($Request.Query.AddMapping -or $Request.Query.AutoMapping) { + $AllowedTenants = Test-CIPPAccess -Request $Request -TenantList + if ($AllowedTenants -notcontains 'AllTenants') { + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::Forbidden + Body = 'Editing extension mappings requires unrestricted tenant access' + }) + } + } + try { if ($Request.Query.AddMapping) { switch ($Request.Query.AddMapping) { diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Scheduler/Invoke-ListScheduledItemDetails.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Scheduler/Invoke-ListScheduledItemDetails.ps1 index 432943270d..88ee45df5b 100644 --- a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Scheduler/Invoke-ListScheduledItemDetails.ps1 +++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Scheduler/Invoke-ListScheduledItemDetails.ps1 @@ -37,6 +37,17 @@ function Invoke-ListScheduledItemDetails { return } + # AnyTenant: restricted callers may only read tasks for tenants in scope + $AllowedTenants = Test-CIPPAccess -Request $Request -TenantList + if ($AllowedTenants -notcontains 'AllTenants') { + if (-not $Task.Tenant -or -not (Get-Tenants -TenantFilter $Task.Tenant)) { + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::Forbidden + Body = 'Access to this scheduled task is not allowed' + }) + } + } + # Process the task (similar to the way it's done in Invoke-ListScheduledItems) if ($Task.Parameters) { $Task.Parameters = $Task.Parameters | ConvertFrom-Json -ErrorAction SilentlyContinue diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Scheduler/Invoke-RemoveScheduledItem.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Scheduler/Invoke-RemoveScheduledItem.ps1 index a739ec6836..31f53f445e 100644 --- a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Scheduler/Invoke-RemoveScheduledItem.ps1 +++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Scheduler/Invoke-RemoveScheduledItem.ps1 @@ -20,6 +20,20 @@ function Invoke-RemoveScheduledItem { } try { $Table = Get-CIPPTable -TableName 'ScheduledTasks' + + # AnyTenant: restricted callers may only remove tasks for tenants in scope + $AllowedTenants = Test-CIPPAccess -Request $Request -TenantList + if ($AllowedTenants -notcontains 'AllTenants') { + $SafeRowKey = ConvertTo-CIPPODataFilterValue -Value $RowKey -Type String + $Existing = Get-CIPPAzDataTableEntity @Table -Filter "PartitionKey eq 'ScheduledTask' and RowKey eq '$SafeRowKey'" + if (-not $Existing.Tenant -or -not (Get-Tenants -TenantFilter $Existing.Tenant)) { + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::Forbidden + Body = @{ Results = 'Access to this scheduled task is not allowed' } + }) + } + } + Remove-CIPPAzDataTableEntity -Force @Table -Entity $task $DetailTable = Get-CIPPTable -TableName 'ScheduledTaskDetails' diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecCustomData.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecCustomData.ps1 index 09574746fa..0a7b3a1662 100644 --- a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecCustomData.ps1 +++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecCustomData.ps1 @@ -14,6 +14,18 @@ function Invoke-ExecCustomData { Write-Information "Executing action '$Action'" + # AnyTenant: mapping writes re-register per-tenant sync tasks estate-wide, so they + # require an unrestricted tenant scope + if ($Action -in @('AddEditMapping', 'DeleteMapping')) { + $AllowedTenants = Test-CIPPAccess -Request $Request -TenantList + if ($AllowedTenants -notcontains 'AllTenants') { + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::Forbidden + Body = @{ Results = @(@{ state = 'error'; resultText = 'Editing custom data mappings requires unrestricted tenant access' }) } + }) + } + } + switch ($Action) { 'ListSchemaExtensions' { try { @@ -358,8 +370,22 @@ function Invoke-ExecCustomData { } 'ListMappings' { try { + # AnyTenant: restricted callers only see mappings for tenants in scope + $AllowedTenants = Test-CIPPAccess -Request $Request -TenantList + $Restricted = $AllowedTenants -notcontains 'AllTenants' + if ($Restricted) { + $AllowedIdentifiers = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($Tenant in (Get-Tenants -IncludeErrors)) { + foreach ($Value in @($Tenant.customerId, $Tenant.defaultDomainName)) { + if ($Value) { [void]$AllowedIdentifiers.Add([string]$Value) } + } + } + } $Mappings = Get-CIPPAzDataTableEntity @CustomDataMappingsTable | ForEach-Object { $Mapping = $_.JSON | ConvertFrom-Json -AsHashtable + if ($Restricted -and -not (@($Mapping.tenantFilter.value) | Where-Object { $_ -and $AllowedIdentifiers.Contains([string]$_) })) { + return + } Write-Information ($Mapping | ConvertTo-Json -Depth 5) [PSCustomObject]@{ diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecDnsConfig.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecDnsConfig.ps1 index bd4f4db30f..7d437c1e2d 100644 --- a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecDnsConfig.ps1 +++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecDnsConfig.ps1 @@ -69,6 +69,13 @@ function Invoke-ExecDnsConfig { $DomainTable = Get-CIPPTable -Table 'Domains' $Filter = "RowKey eq '{0}'" -f $Domain $DomainInfo = Get-CIPPAzDataTableEntity @DomainTable -Filter $Filter + + # AnyTenant: restricted callers may only edit domains for tenants in scope + $AllowedTenants = Test-CIPPAccess -Request $Request -TenantList + if ($AllowedTenants -notcontains 'AllTenants' -and -not ($DomainInfo | Select-CippAllowedTenantData -TenantProperty 'TenantGUID', 'TenantId')) { + throw 'Access to this domain is not allowed' + } + $DkimSelectors = [string]($Selector | ConvertTo-Json -Compress) if ($DomainInfo) { $DomainInfo.DkimSelectors = $DkimSelectors @@ -93,7 +100,14 @@ function Invoke-ExecDnsConfig { } 'RemoveDomain' { $Filter = "RowKey eq '{0}'" -f $Domain - $DomainRow = Get-CIPPAzDataTableEntity @DomainTable -Filter $Filter -Property PartitionKey, RowKey + $DomainRow = Get-CIPPAzDataTableEntity @DomainTable -Filter $Filter -Property PartitionKey, RowKey, TenantGUID, TenantId + + # AnyTenant: restricted callers may only remove domains for tenants in scope + $AllowedTenants = Test-CIPPAccess -Request $Request -TenantList + if ($AllowedTenants -notcontains 'AllTenants' -and -not ($DomainRow | Select-CippAllowedTenantData -TenantProperty 'TenantGUID', 'TenantId')) { + throw 'Access to this domain is not allowed' + } + Remove-CIPPAzDataTableEntity -Force @DomainTable -Entity $DomainRow Write-LogMessage -API $APIName -tenant 'Global' -headers $Headers -message "Removed Domain - $Domain " -Sev 'Info' $body = [pscustomobject]@{ 'Results' = "Domain removed - $Domain" } diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecRunTenantGroupRule.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecRunTenantGroupRule.ps1 index 31aaec5c4d..4ce8658e9f 100644 --- a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecRunTenantGroupRule.ps1 +++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecRunTenantGroupRule.ps1 @@ -14,6 +14,15 @@ function Invoke-ExecRunTenantGroupRule { $GroupId = $Request.Body.groupId ?? $Request.Query.groupId + # Same gate as Invoke-ExecTenantGroup: group management requires unrestricted group scope + $AllowedGroups = Test-CippAccess -Request $Request -GroupList + if ($AllowedGroups -notcontains 'AllGroups') { + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::Forbidden + Body = @{ Results = 'You do not have permission to manage tenant groups.' } + }) + } + try { $GroupTable = Get-CippTable -tablename 'TenantGroups' $Group = Get-CIPPAzDataTableEntity @GroupTable -Filter "PartitionKey eq 'TenantGroup' and RowKey eq '$GroupId'" diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Setup/Invoke-ExecAddTenant.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Setup/Invoke-ExecAddTenant.ps1 index 15a5868806..7a07c581cf 100644 --- a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Setup/Invoke-ExecAddTenant.ps1 +++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Setup/Invoke-ExecAddTenant.ps1 @@ -9,6 +9,15 @@ function Invoke-ExecAddTenant { param($Request, $TriggerMetadata) try { + # AnyTenant: onboarding writes tenant credentials; require unrestricted tenant scope + $AllowedTenants = Test-CIPPAccess -Request $Request -TenantList + if ($AllowedTenants -notcontains 'AllTenants') { + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::Forbidden + Body = @{'message' = 'Adding a tenant requires unrestricted tenant access'; 'severity' = 'error' } + }) + } + # Get the tenant ID from the request body $tenantId = $Request.body.tenantId $defaultDomainName = $Request.body.defaultDomainName diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Spamfilter/Invoke-AddSpamFilter.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Spamfilter/Invoke-AddSpamFilter.ps1 index f998de70ff..e5896a1f50 100644 --- a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Spamfilter/Invoke-AddSpamFilter.ps1 +++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Spamfilter/Invoke-AddSpamFilter.ps1 @@ -17,6 +17,15 @@ Function Invoke-AddSpamFilter { $RequestPriority = $Request.Body.Priority $Tenants = ($Request.body.selectedTenants).value + + # AnyTenant: narrow to the caller's allowed tenants (same as Invoke-AddTransportRule) + $AllowedTenants = Test-CippAccess -Request $Request -TenantList + if ($AllowedTenants -ne 'AllTenants') { + $AllTenants = Get-Tenants -IncludeErrors + $AllowedTenantList = $AllTenants | Where-Object { $_.customerId -in $AllowedTenants } + $Tenants = $Tenants | Where-Object { $_ -in $AllowedTenantList.defaultDomainName } + } + $Result = foreach ($TenantFilter in $tenants) { try { $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'New-HostedContentFilterPolicy' -cmdParams $RequestParams diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-AddIntuneTemplate.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-AddIntuneTemplate.ps1 index 8861c30455..10380b5e2a 100644 --- a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-AddIntuneTemplate.ps1 +++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-AddIntuneTemplate.ps1 @@ -41,6 +41,13 @@ function Invoke-AddIntuneTemplate { $StatusCode = [HttpStatusCode]::OK } else { $TenantFilter = $Request.Body.tenantFilter ?? $Request.Query.tenantFilter + + # AnyTenant: template is built from a live read of this tenant; enforce scope + $AllowedTenants = Test-CIPPAccess -Request $Request -TenantList + if ($AllowedTenants -notcontains 'AllTenants' -and -not ($TenantFilter -and (Get-Tenants -TenantFilter $TenantFilter))) { + throw 'Access to this tenant is not allowed' + } + $URLName = $Request.Body.URLName ?? $Request.Query.URLName $ID = $Request.Body.ID ?? $Request.Query.ID $ODataType = $Request.Body.ODataType ?? $Request.Query.ODataType diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ExecCompareIntunePolicy.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ExecCompareIntunePolicy.ps1 index c87ff50b15..1d8404c00b 100644 --- a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ExecCompareIntunePolicy.ps1 +++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ExecCompareIntunePolicy.ps1 @@ -36,6 +36,16 @@ function Invoke-ExecCompareIntunePolicy { throw 'Both sourceA and sourceB are required' } + # AnyTenant: source tenants must be in the caller's scope; Get-Tenants is narrowed + $AllowedTenants = Test-CIPPAccess -Request $Request -TenantList + if ($AllowedTenants -notcontains 'AllTenants') { + foreach ($SourceTenant in @($SourceA.tenantFilter, $SourceB.tenantFilter)) { + if ($SourceTenant -and -not (Get-Tenants -TenantFilter $SourceTenant)) { + throw 'Access to this tenant is not allowed' + } + } + } + # Load a stored Intune template. When a tenant is supplied the template is put through the # same preparation the IntuneTemplate standard uses - nesting repair, reusable settings sync # and text replacement - so a comparison made here matches what drift reports for that tenant. diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ListCVEManagement.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ListCVEManagement.ps1 index 1eb2981b14..97e92b83af 100644 --- a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ListCVEManagement.ps1 +++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ListCVEManagement.ps1 @@ -32,6 +32,12 @@ function Invoke-ListCVEManagement { try { Write-LogMessage -API 'ListCVEManagement' -tenant $TenantFilter -message 'retrieving CVEs' -sev 'info' + # AnyTenant: the live path queries this tenant's Defender TVM; enforce scope + $AllowedTenants = Test-CIPPAccess -Request $Request -TenantList + if ($AllowedTenants -notcontains 'AllTenants' -and -not ($TenantFilter -and (Get-Tenants -TenantFilter $TenantFilter))) { + throw 'Access to this tenant is not allowed' + } + # Retrieve Exceptions from Exception database. These are resolved before the CVE # fetch so the fetch can be streamed straight into the merge below. $CveExceptionsTable = Get-CIPPTable -TableName 'CveExceptions' diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListUserPhoto.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListUserPhoto.ps1 index 8e262240fa..afae7ded9c 100644 --- a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListUserPhoto.ps1 +++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListUserPhoto.ps1 @@ -13,6 +13,15 @@ Function Invoke-ListUserPhoto { $tenantFilter = $Request.Query.tenantFilter $userId = $Request.Query.UserID + # AnyTenant: enforce tenant scope here; Get-Tenants is narrowed to the caller's allowed tenants + $AllowedTenants = Test-CIPPAccess -Request $Request -TenantList + if ($AllowedTenants -notcontains 'AllTenants' -and -not (Get-Tenants -TenantFilter $tenantFilter)) { + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::Forbidden + Body = 'Access to this tenant is not allowed' + }) + } + $URI = "/users/$userId/photo/`$value" $Requests = @( diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecSharePointTemplate.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecSharePointTemplate.ps1 index d437123d0c..fbb70900fd 100644 --- a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecSharePointTemplate.ps1 +++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecSharePointTemplate.ps1 @@ -143,6 +143,12 @@ function Invoke-ExecSharePointTemplate { throw 'A tenant is required to deploy this template.' } + # AnyTenant: deployment provisions sites in this tenant; enforce scope + $AllowedTenants = Test-CIPPAccess -Request $Request -TenantList + if ($AllowedTenants -notcontains 'AllTenants' -and -not (Get-Tenants -TenantFilter $TenantFilter)) { + throw 'Access to this tenant is not allowed' + } + # Pre-create a status row so the frontend can poll live progress from queue time. $JobId = New-CIPPAsyncDeployment -Names @($TenantFilter) -StepTitles @(@($TemplateData.siteTemplates) | ForEach-Object { $_.displayName }) -Source 'SharePointTemplate' diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Alerts/Invoke-ListAuditLogTest.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Alerts/Invoke-ListAuditLogTest.ps1 index 7a779a245d..44a42794c6 100644 --- a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Alerts/Invoke-ListAuditLogTest.ps1 +++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Alerts/Invoke-ListAuditLogTest.ps1 @@ -13,6 +13,16 @@ function Invoke-ListAuditLogTest { TenantFilter = $Request.Query.TenantFilter SearchId = $Request.Query.SearchId } + + # AnyTenant: enforce tenant scope here; Get-Tenants is narrowed to the caller's allowed tenants + $AllowedTenants = Test-CIPPAccess -Request $Request -TenantList + if ($AllowedTenants -notcontains 'AllTenants' -and -not ($AuditLogQuery.TenantFilter -and (Get-Tenants -TenantFilter $AuditLogQuery.TenantFilter))) { + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::Forbidden + Body = @{ Results = 'Access to this tenant is not allowed' } + }) + } + try { $TestResults = Test-CIPPAuditLogRules @AuditLogQuery } catch { diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Tenant/Invoke-EditTenant.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Tenant/Invoke-EditTenant.ps1 index ac9a6c15e7..1f034f561d 100644 --- a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Tenant/Invoke-EditTenant.ps1 +++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Tenant/Invoke-EditTenant.ps1 @@ -20,6 +20,14 @@ function Invoke-EditTenant { $PropertiesTable = Get-CippTable -TableName 'TenantProperties' $Existing = Get-CIPPAzDataTableEntity @PropertiesTable -Filter "PartitionKey eq '$customerId'" $Tenant = Get-Tenants -TenantFilter $customerId + # AnyTenant: Get-Tenants is narrowed to the caller's allowed tenants; no match means + # unknown or out-of-scope, either way nothing may be written + if (-not $Tenant) { + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::Forbidden + Body = @{ Results = "Tenant '$customerId' not found or access denied" } + }) + } $TenantTable = Get-CippTable -TableName 'Tenants' $GroupMembersTable = Get-CippTable -TableName 'TenantGroupMembers' diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Tenant/Invoke-EditTenantOffboardingDefaults.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Tenant/Invoke-EditTenantOffboardingDefaults.ps1 index aebd8f1236..3aedbd78ef 100644 --- a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Tenant/Invoke-EditTenantOffboardingDefaults.ps1 +++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Tenant/Invoke-EditTenantOffboardingDefaults.ps1 @@ -29,6 +29,15 @@ function Invoke-EditTenantOffboardingDefaults { return } + # AnyTenant: enforce tenant scope here; Get-Tenants is narrowed to the caller's allowed tenants + $AllowedTenants = Test-CIPPAccess -Request $Request -TenantList + if ($AllowedTenants -notcontains 'AllTenants' -and -not (Get-Tenants -TenantFilter $customerId)) { + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::Forbidden + Body = @{ state = 'error'; resultText = 'Access to this tenant is not allowed' } + }) + } + $PropertiesTable = Get-CippTable -TableName 'TenantProperties' try { diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Tenant/Invoke-RemoveTenantCapabilitiesCache.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Tenant/Invoke-RemoveTenantCapabilitiesCache.ps1 index 52db556502..776386a310 100644 --- a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Tenant/Invoke-RemoveTenantCapabilitiesCache.ps1 +++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Tenant/Invoke-RemoveTenantCapabilitiesCache.ps1 @@ -25,6 +25,15 @@ function Invoke-RemoveTenantCapabilitiesCache { } try { + # AnyTenant: enforce tenant scope here; Get-Tenants is narrowed to the caller's allowed tenants + $AllowedTenants = Test-CIPPAccess -Request $Request -TenantList + if ($AllowedTenants -notcontains 'AllTenants' -and -not (Get-Tenants -TenantFilter $DefaultDomainName)) { + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::Forbidden + Body = [pscustomobject]@{'Results' = 'Access to this tenant is not allowed' } + }) + } + # Get the CacheCapabilities table $Table = Get-CippTable -tablename 'CacheCapabilities' diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Conditional/Invoke-AddNamedLocation.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Conditional/Invoke-AddNamedLocation.ps1 index 941bcdef57..457a620610 100644 --- a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Conditional/Invoke-AddNamedLocation.ps1 +++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Conditional/Invoke-AddNamedLocation.ps1 @@ -13,6 +13,15 @@ function Invoke-AddNamedLocation { $Tenants = $request.body.selectedTenants.value Write-Host ($Request.body | ConvertTo-Json) if ($Tenants -eq 'AllTenants') { $Tenants = (Get-Tenants).defaultDomainName } + + # AnyTenant: narrow to the caller's allowed tenants (same as Invoke-AddTransportRule) + $AllowedTenants = Test-CippAccess -Request $Request -TenantList + if ($AllowedTenants -ne 'AllTenants') { + $AllTenants = Get-Tenants -IncludeErrors + $AllowedTenantList = $AllTenants | Where-Object { $_.customerId -in $AllowedTenants } + $Tenants = $Tenants | Where-Object { $_ -in $AllowedTenantList.defaultDomainName } + } + $results = foreach ($Tenant in $tenants) { try { $ObjBody = if ($Request.body.Type -eq 'IPLocation') { diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Conditional/Invoke-ExecNamedLocation.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Conditional/Invoke-ExecNamedLocation.ps1 index 038e00dc02..59b414ff46 100644 --- a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Conditional/Invoke-ExecNamedLocation.ps1 +++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Conditional/Invoke-ExecNamedLocation.ps1 @@ -21,6 +21,12 @@ function Invoke-ExecNamedLocation { try { + # AnyTenant: enforce tenant scope here; Get-Tenants is narrowed to the caller's allowed tenants + $AllowedTenants = Test-CIPPAccess -Request $Request -TenantList + if ($AllowedTenants -notcontains 'AllTenants' -and -not ($TenantFilter -and (Get-Tenants -TenantFilter $TenantFilter))) { + throw 'Access to this tenant is not allowed' + } + $Results = Set-CIPPNamedLocation -NamedLocationId $NamedLocationId -TenantFilter $TenantFilter -Change $Change -Content $Content -Headers $Headers $StatusCode = [HttpStatusCode]::OK } catch { diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ExecBPA.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ExecBPA.ps1 index 7f3e7495f4..aaf763e7ea 100644 --- a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ExecBPA.ps1 +++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ExecBPA.ps1 @@ -10,6 +10,18 @@ function Invoke-ExecBPA { $TenantFilter = $Request.Query.tenantFilter ? $Request.Query.tenantFilter.value : $Request.Body.tenantfilter.value + # AnyTenant: the orchestrator runs outside this request's scope, so restricted callers + # need a single in-scope tenant; Get-Tenants is narrowed to the caller's allowed tenants + $AllowedTenants = Test-CIPPAccess -Request $Request -TenantList + if ($AllowedTenants -notcontains 'AllTenants') { + if (-not $TenantFilter -or $TenantFilter -eq 'AllTenants' -or -not (Get-Tenants -TenantFilter $TenantFilter)) { + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::Forbidden + Body = [pscustomobject]@{'Results' = 'Access to this tenant is not allowed' } + }) + } + } + # Start the orchestrator - it will handle queuing internally Start-BPAOrchestrator -TenantFilter $TenantFilter -Force diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ExecDomainAnalyser.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ExecDomainAnalyser.ps1 index b48d0ca647..34fc4be7f5 100644 --- a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ExecDomainAnalyser.ps1 +++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ExecDomainAnalyser.ps1 @@ -13,6 +13,19 @@ function Invoke-ExecDomainAnalyser { if ($Request.Body.tenantFilter) { $Params.TenantFilter = $Request.Body.tenantFilter.value ?? $Request.Body.tenantFilter } + + # AnyTenant: the orchestrator runs outside this request's scope, so restricted callers + # need a single in-scope tenant; Get-Tenants is narrowed to the caller's allowed tenants + $AllowedTenants = Test-CIPPAccess -Request $Request -TenantList + if ($AllowedTenants -notcontains 'AllTenants') { + if (-not $Params.TenantFilter -or $Params.TenantFilter -eq 'AllTenants' -or -not (Get-Tenants -TenantFilter $Params.TenantFilter)) { + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::Forbidden + Body = [pscustomobject]@{'Results' = 'Access to this tenant is not allowed' } + }) + } + } + $OrchStatus = Start-DomainOrchestrator @Params if ($OrchStatus) { $Message = 'Domain Analyser started' diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ListDomainHealth.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ListDomainHealth.ps1 index 1e0174b193..cc89f2b8b4 100644 --- a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ListDomainHealth.ps1 +++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ListDomainHealth.ps1 @@ -65,6 +65,15 @@ function Invoke-ListDomainHealth { $DomainTable = Get-CIPPTable -Table 'Domains' $Filter = "RowKey eq '{0}'" -f $Request.Query.Domain $DomainInfo = Get-CIPPAzDataTableEntity @DomainTable -Filter $Filter + + # AnyTenant: the Domains row is per-tenant data; hide it from out-of-scope callers. + # The DNS checks themselves are public data and stay open. + $AllowedTenants = Test-CIPPAccess -Request $Request -TenantList + $Restricted = $AllowedTenants -notcontains 'AllTenants' + if ($Restricted) { + $DomainInfo = $DomainInfo | Select-CippAllowedTenantData -TenantProperty 'TenantGUID', 'TenantId' + } + switch ($Request.Query.Action) { 'ListDomainInfo' { $Body = $DomainInfo @@ -98,7 +107,8 @@ function Invoke-ListDomainHealth { if ($Request.Query.Selector) { $DkimQuery.Selectors = ($Request.Query.Selector).trim() -split '\s*,\s*' - if ('admin' -in $UserRoles -or 'editor' -in $UserRoles) { + # Restricted callers may only persist selectors onto an in-scope row + if (('admin' -in $UserRoles -or 'editor' -in $UserRoles) -and (-not $Restricted -or $DomainInfo)) { $DkimSelectors = [string]($DkimQuery.Selectors | ConvertTo-Json -Compress) if ($DomainInfo) { $DomainInfo.DkimSelectors = $DkimSelectors diff --git a/backend/Tests/Endpoint/Invoke-ExecRemoveSnooze.Tests.ps1 b/backend/Tests/Endpoint/Invoke-ExecRemoveSnooze.Tests.ps1 new file mode 100644 index 0000000000..4594eb305e --- /dev/null +++ b/backend/Tests/Endpoint/Invoke-ExecRemoveSnooze.Tests.ps1 @@ -0,0 +1,100 @@ +# Pester tests for Invoke-ExecRemoveSnooze +# +# The delete is keyed by raw PartitionKey/RowKey, so for restricted callers the endpoint +# reads the row first and only deletes when the row's Tenant resolves through the +# scope-narrowed Get-Tenants. Unrestricted callers keep the direct delete. + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $FunctionPath = Get-ChildItem -Path (Join-Path $RepoRoot 'Modules') -Recurse -Filter 'Invoke-ExecRemoveSnooze.ps1' -File -ErrorAction SilentlyContinue | + Select-Object -First 1 -ExpandProperty FullName + if (-not $FunctionPath) { throw 'Could not locate Invoke-ExecRemoveSnooze.ps1 under Modules/' } + + class HttpResponseContext { + [object]$StatusCode + [object]$Body + } + + $Accelerators = [psobject].Assembly.GetType('System.Management.Automation.TypeAccelerators') + if (-not $Accelerators::Get.ContainsKey('HttpStatusCode')) { + $Accelerators::Add('HttpStatusCode', [System.Net.HttpStatusCode]) + } + + function Get-CIPPTable { param($tablename) } + function Get-CIPPAzDataTableEntity { param($Context, $Filter, $Property) } + function Remove-CIPPAzDataTableEntity { param($Context, $Entity, [switch]$Force) } + function ConvertTo-CIPPODataFilterValue { param($Value, $Type) } + function Write-LogMessage { param($headers, $API, $message, $Sev, $LogData) } + function Test-CIPPAccess { param($Request, [switch]$TenantList, [switch]$GroupList) } + function Get-Tenants { param($TenantFilter, [switch]$IncludeErrors) } + function Get-CippException { param($Exception) } + + . $FunctionPath + + function New-RemoveRequest { + param($PartitionKey = 'Get-CIPPAlertSomething', $RowKey = 'contoso.onmicrosoft.com-hash123') + [pscustomobject]@{ + Params = @{ CIPPEndpoint = 'ExecRemoveSnooze' } + Headers = @{ } + Body = [pscustomobject]@{ PartitionKey = $PartitionKey; RowKey = $RowKey } + Query = [pscustomobject]@{ } + } + } +} + +Describe 'Invoke-ExecRemoveSnooze' { + BeforeEach { + Mock -CommandName Write-LogMessage -MockWith { } + Mock -CommandName Get-CIPPTable -MockWith { @{ TableName = 'AlertSnooze' } } + Mock -CommandName Remove-CIPPAzDataTableEntity -MockWith { } + Mock -CommandName ConvertTo-CIPPODataFilterValue -MockWith { $Value } + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { + [pscustomobject]@{ PartitionKey = 'Get-CIPPAlertSomething'; RowKey = 'contoso.onmicrosoft.com-hash123'; Tenant = 'contoso.onmicrosoft.com' } + } + Mock -CommandName Test-CIPPAccess -MockWith { @('AllTenants') } + Mock -CommandName Get-Tenants -MockWith { + [pscustomobject]@{ customerId = 'tenant-guid'; defaultDomainName = 'contoso.onmicrosoft.com' } + } + } + + It 'removes directly for an unrestricted caller without reading the row back' { + $Response = Invoke-ExecRemoveSnooze -Request (New-RemoveRequest) -TriggerMetadata $null + + $Response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::OK) + Should -Invoke Remove-CIPPAzDataTableEntity -Times 1 -Exactly + Should -Invoke Get-CIPPAzDataTableEntity -Times 0 -Exactly + } + + It 'removes for a restricted caller when the row belongs to a tenant in scope' { + Mock -CommandName Test-CIPPAccess -MockWith { @('tenant-guid') } + + $Response = Invoke-ExecRemoveSnooze -Request (New-RemoveRequest) -TriggerMetadata $null + + $Response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::OK) + Should -Invoke Remove-CIPPAzDataTableEntity -Times 1 -Exactly + } + + It 'refuses a restricted caller when the row belongs to a tenant outside their scope' { + Mock -CommandName Test-CIPPAccess -MockWith { @('tenant-guid') } + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { + [pscustomobject]@{ PartitionKey = 'Get-CIPPAlertSomething'; RowKey = 'other.onmicrosoft.com-hash123'; Tenant = 'other.onmicrosoft.com' } + } + # Scope-narrowed Get-Tenants: the row's tenant resolves to nothing. + Mock -CommandName Get-Tenants -MockWith { } + + $Response = Invoke-ExecRemoveSnooze -Request (New-RemoveRequest -RowKey 'other.onmicrosoft.com-hash123') -TriggerMetadata $null + + $Response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::Forbidden) + Should -Invoke Remove-CIPPAzDataTableEntity -Times 0 -Exactly + } + + It 'refuses a restricted caller when the row does not exist' { + Mock -CommandName Test-CIPPAccess -MockWith { @('tenant-guid') } + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { } + + $Response = Invoke-ExecRemoveSnooze -Request (New-RemoveRequest) -TriggerMetadata $null + + $Response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::Forbidden) + Should -Invoke Remove-CIPPAzDataTableEntity -Times 0 -Exactly + } +} diff --git a/backend/Tests/Endpoint/Invoke-ExecSnoozeAlert.Tests.ps1 b/backend/Tests/Endpoint/Invoke-ExecSnoozeAlert.Tests.ps1 new file mode 100644 index 0000000000..7dd5587719 --- /dev/null +++ b/backend/Tests/Endpoint/Invoke-ExecSnoozeAlert.Tests.ps1 @@ -0,0 +1,89 @@ +# Pester tests for Invoke-ExecSnoozeAlert +# +# The endpoint is AnyTenant, so the framework's per-tenant check is skipped and the +# endpoint gates the caller-supplied TenantFilter itself: restricted callers may only +# snooze alerts for tenants the scope-narrowed Get-Tenants can resolve. + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $FunctionPath = Get-ChildItem -Path (Join-Path $RepoRoot 'Modules') -Recurse -Filter 'Invoke-ExecSnoozeAlert.ps1' -File -ErrorAction SilentlyContinue | + Select-Object -First 1 -ExpandProperty FullName + if (-not $FunctionPath) { throw 'Could not locate Invoke-ExecSnoozeAlert.ps1 under Modules/' } + + class HttpResponseContext { + [object]$StatusCode + [object]$Body + } + + $Accelerators = [psobject].Assembly.GetType('System.Management.Automation.TypeAccelerators') + if (-not $Accelerators::Get.ContainsKey('HttpStatusCode')) { + $Accelerators::Add('HttpStatusCode', [System.Net.HttpStatusCode]) + } + + function Get-AlertContentHash { param($AlertItem) } + function Get-CIPPTable { param($tablename) } + function Add-CIPPAzDataTableEntity { param($Context, $Entity, [switch]$Force) } + function Write-LogMessage { param($headers, $API, $message, $Sev, $tenant, $LogData) } + function Test-CIPPAccess { param($Request, [switch]$TenantList, [switch]$GroupList) } + function Get-Tenants { param($TenantFilter, [switch]$IncludeErrors) } + function Get-CippException { param($Exception) } + + . $FunctionPath + + function New-SnoozeRequest { + param($TenantFilter = 'contoso.onmicrosoft.com') + [pscustomobject]@{ + Params = @{ CIPPEndpoint = 'ExecSnoozeAlert' } + Headers = @{ } + Body = [pscustomobject]@{ + CmdletName = 'Get-CIPPAlertSomething' + TenantFilter = $TenantFilter + AlertItem = @{ Message = 'alert text' } + Duration = 7 + Reason = 'test' + } + } + } +} + +Describe 'Invoke-ExecSnoozeAlert' { + BeforeEach { + Mock -CommandName Write-LogMessage -MockWith { } + Mock -CommandName Get-CIPPTable -MockWith { @{ TableName = 'AlertSnooze' } } + Mock -CommandName Add-CIPPAzDataTableEntity -MockWith { } + Mock -CommandName Get-AlertContentHash -MockWith { + @{ ContentHash = 'hash123'; ContentPreview = 'alert text'; RawKey = 'raw' } + } + Mock -CommandName Test-CIPPAccess -MockWith { @('AllTenants') } + Mock -CommandName Get-Tenants -MockWith { + [pscustomobject]@{ customerId = 'tenant-guid'; defaultDomainName = 'contoso.onmicrosoft.com' } + } + } + + It 'writes the snooze row for an unrestricted caller' { + $Response = Invoke-ExecSnoozeAlert -Request (New-SnoozeRequest) -TriggerMetadata $null + + $Response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::OK) + Should -Invoke Add-CIPPAzDataTableEntity -Times 1 -Exactly + } + + It 'refuses a restricted caller naming a tenant outside their scope' { + Mock -CommandName Test-CIPPAccess -MockWith { @('tenant-guid') } + # Scope-narrowed Get-Tenants: the requested tenant resolves to nothing. + Mock -CommandName Get-Tenants -MockWith { } + + $Response = Invoke-ExecSnoozeAlert -Request (New-SnoozeRequest -TenantFilter 'other.onmicrosoft.com') -TriggerMetadata $null + + $Response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::Forbidden) + Should -Invoke Add-CIPPAzDataTableEntity -Times 0 -Exactly + } + + It 'writes the snooze row for a restricted caller scoped to the tenant' { + Mock -CommandName Test-CIPPAccess -MockWith { @('tenant-guid') } + + $Response = Invoke-ExecSnoozeAlert -Request (New-SnoozeRequest) -TriggerMetadata $null + + $Response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::OK) + Should -Invoke Add-CIPPAzDataTableEntity -Times 1 -Exactly + } +} diff --git a/backend/Tests/Endpoint/Invoke-ListCVEManagement.Tests.ps1 b/backend/Tests/Endpoint/Invoke-ListCVEManagement.Tests.ps1 index cd5bf6e1ad..b5ff772519 100644 --- a/backend/Tests/Endpoint/Invoke-ListCVEManagement.Tests.ps1 +++ b/backend/Tests/Endpoint/Invoke-ListCVEManagement.Tests.ps1 @@ -33,6 +33,7 @@ BeforeAll { function Get-CIPPAzDataTableEntity { param($TableName, $Filter, $Property) } function Get-Tenants { param($TenantFilter, [switch]$IncludeErrors) } function Write-LogMessage { param($headers, $API, $tenant, $message, $sev, $LogData) } + function Test-CIPPAccess { param($Request, [switch]$TenantList, [switch]$GroupList) } . $FunctionPath @@ -77,6 +78,7 @@ Describe 'Invoke-ListCVEManagement' { Mock -CommandName Get-Tenants -MockWith { [pscustomobject]@{ customerId = 'tenant-guid'; defaultDomainName = 'contoso.onmicrosoft.com' } } + Mock -CommandName Test-CIPPAccess -MockWith { @('AllTenants') } } Context 'live branch response shape' { @@ -206,6 +208,30 @@ Describe 'Invoke-ListCVEManagement' { } } + Context 'tenant scope enforcement (AnyTenant)' { + It 'refuses the live path for a tenant the restricted caller cannot resolve' { + Mock -CommandName Test-CIPPAccess -MockWith { @('tenant-guid') } + # Scope-narrowed Get-Tenants: the requested tenant resolves to nothing. + Mock -CommandName Get-Tenants -MockWith { } + Mock -CommandName Get-DefenderCVEs -MockWith { New-CveRow } + + $Response = Invoke-ListCVEManagement -Request (New-CveRequest -TenantFilter 'other.onmicrosoft.com') -TriggerMetadata $null + + $Response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::InternalServerError) + Should -Invoke Get-DefenderCVEs -Times 0 -Exactly + } + + It 'serves the live path when the restricted caller is scoped to the tenant' { + Mock -CommandName Test-CIPPAccess -MockWith { @('tenant-guid') } + Mock -CommandName Get-DefenderCVEs -MockWith { New-CveRow } + + $Response = Invoke-ListCVEManagement -Request (New-CveRequest) -TriggerMetadata $null + + $Response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::OK) + Should -Invoke Get-DefenderCVEs -Times 1 -Exactly + } + } + Context 'reporting database branch' { It 'reads the cache and never queries Defender live when UseReportDB is true' { Mock -CommandName Get-CIPPCVEReport -MockWith { @([pscustomobject]@{ cveId = 'CVE-CACHED' }) } diff --git a/backend/Tests/Endpoint/Invoke-ListDirectoryObjects.Tests.ps1 b/backend/Tests/Endpoint/Invoke-ListDirectoryObjects.Tests.ps1 new file mode 100644 index 0000000000..d5e0c930a5 --- /dev/null +++ b/backend/Tests/Endpoint/Invoke-ListDirectoryObjects.Tests.ps1 @@ -0,0 +1,95 @@ +# Pester tests for Invoke-ListDirectoryObjects +# +# The endpoint is AnyTenant and calls Graph with -NoAuthCheck, so the caller-supplied +# tenantFilter is gated here: restricted callers may only resolve objects in tenants the +# scope-narrowed Get-Tenants can resolve. partnerLookup pins the partner tenant instead +# and stays open by design. + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $FunctionPath = Get-ChildItem -Path (Join-Path $RepoRoot 'Modules') -Recurse -Filter 'Invoke-ListDirectoryObjects.ps1' -File -ErrorAction SilentlyContinue | + Select-Object -First 1 -ExpandProperty FullName + if (-not $FunctionPath) { throw 'Could not locate Invoke-ListDirectoryObjects.ps1 under Modules/' } + + class HttpResponseContext { + [object]$StatusCode + [object]$Body + } + + function New-GraphPOSTRequest { param($tenantid, $uri, $body, $AsApp, $NoAuthCheck) } + function Test-CIPPAccess { param($Request, [switch]$TenantList, [switch]$GroupList) } + function Get-Tenants { param($TenantFilter, [switch]$IncludeErrors) } + + . $FunctionPath + + function New-DirectoryObjectsRequest { + param($TenantFilter = 'contoso.onmicrosoft.com', $PartnerLookup) + [pscustomobject]@{ + Params = @{ CIPPEndpoint = 'ListDirectoryObjects' } + Headers = @{ } + Body = [pscustomobject]@{ + tenantFilter = $TenantFilter + partnerLookup = $PartnerLookup + ids = @('00000000-0000-0000-0000-000000000001') + } + } + } + + $script:PriorTenantID = $env:TenantID + $env:TenantID = 'partner-tenant-guid' +} + +AfterAll { + $env:TenantID = $script:PriorTenantID +} + +Describe 'Invoke-ListDirectoryObjects' { + BeforeEach { + Mock -CommandName New-GraphPOSTRequest -MockWith { @{ value = @() } } + Mock -CommandName Test-CIPPAccess -MockWith { @('AllTenants') } + Mock -CommandName Get-Tenants -MockWith { + [pscustomobject]@{ customerId = 'tenant-guid'; defaultDomainName = 'contoso.onmicrosoft.com' } + } + } + + It 'resolves objects for an unrestricted caller' { + $Response = Invoke-ListDirectoryObjects -Request (New-DirectoryObjectsRequest) -TriggerMetadata $null + + $Response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::OK) + Should -Invoke New-GraphPOSTRequest -Times 1 -Exactly -ParameterFilter { + $tenantid -eq 'contoso.onmicrosoft.com' + } + } + + It 'refuses a restricted caller naming a tenant outside their scope' { + Mock -CommandName Test-CIPPAccess -MockWith { @('tenant-guid') } + # Scope-narrowed Get-Tenants: the requested tenant resolves to nothing. + Mock -CommandName Get-Tenants -MockWith { } + + $Response = Invoke-ListDirectoryObjects -Request (New-DirectoryObjectsRequest -TenantFilter 'other.onmicrosoft.com') -TriggerMetadata $null + + $Response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::Forbidden) + Should -Invoke New-GraphPOSTRequest -Times 0 -Exactly + } + + It 'resolves objects for a restricted caller scoped to the tenant' { + Mock -CommandName Test-CIPPAccess -MockWith { @('tenant-guid') } + + $Response = Invoke-ListDirectoryObjects -Request (New-DirectoryObjectsRequest) -TriggerMetadata $null + + $Response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::OK) + Should -Invoke New-GraphPOSTRequest -Times 1 -Exactly + } + + It 'keeps partnerLookup open for restricted callers and pins the partner tenant' { + Mock -CommandName Test-CIPPAccess -MockWith { @('tenant-guid') } + Mock -CommandName Get-Tenants -MockWith { } + + $Response = Invoke-ListDirectoryObjects -Request (New-DirectoryObjectsRequest -PartnerLookup $true) -TriggerMetadata $null + + $Response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::OK) + Should -Invoke New-GraphPOSTRequest -Times 1 -Exactly -ParameterFilter { + $tenantid -eq 'partner-tenant-guid' + } + } +} From 4887e6bd7266b2bf140cf0be9c2cf7e517b912d3 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Tue, 18 Aug 2026 20:02:17 +0800 Subject: [PATCH 141/226] fix(autocomplete): disambiguate default match Adjust single-select default resolution in `CippAutocomplete` to handle duplicate option values safely. The component now only auto-resolves by `value` when there is exactly one match; when multiple options share the same value, it additionally requires a `label` match and otherwise keeps the stored form value to avoid incorrect remapping after option refreshes. --- .../components/CippComponents/CippAutocomplete.jsx | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/CippComponents/CippAutocomplete.jsx b/frontend/src/components/CippComponents/CippAutocomplete.jsx index 585f995219..9cf5ca09c0 100644 --- a/frontend/src/components/CippComponents/CippAutocomplete.jsx +++ b/frontend/src/components/CippComponents/CippAutocomplete.jsx @@ -332,7 +332,10 @@ export const CippAutoComplete = React.forwardRef((props, ref) => { ]) // single mode: live options win over the form-held copy, a stored label goes stale - // when its option refetches under it (e.g. renamed preset), resolve by value id + // when its option refetches under it (e.g. renamed preset), resolve by value id. + // Values are not always unique (the alert wizard's property options share a type + // string as value), so only let a value-only match win when it's unambiguous — + // otherwise require the label to match too, and keep the stored copy if none does. const resolvedDefaultValue = useMemo(() => { if ( multiple || @@ -342,7 +345,11 @@ export const CippAutoComplete = React.forwardRef((props, ref) => { ) { return defaultValue } - return memoizedOptions.find((option) => option.value === defaultValue.value) ?? defaultValue + const valueMatches = memoizedOptions.filter((option) => option.value === defaultValue.value) + if (valueMatches.length === 1) { + return valueMatches[0] + } + return valueMatches.find((option) => option.label === defaultValue.label) ?? defaultValue }, [defaultValue, multiple, memoizedOptions]) // Create a stable key that only changes when necessary inputs change From 4616513b25d61af1cf37132556a66bb1820df0c8 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Tue, 18 Aug 2026 20:02:56 +0800 Subject: [PATCH 142/226] feat(orchestrator): add priority-aware queue scheduling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Improve queue priority resolution for orchestrations and starter jobs: - Start-CIPPOrchestrator now resolves priority via a fallback chain: explicit InputObject.Priority (range-validated) → ambient CraftOperationContext → HTTP-triggered default (P2) → background default (P4) - Start-UserTasksOrchestrator explicitly sets P2 for user task orchestrations so they don't queue behind P4 background fan-outs - Add-CippQueueMessage gains a Priority parameter, defaults to P2 for HTTP requests and P5 otherwise, with graceful fallback for older Craft runtimes that lack the priority overload --- .../Start-CIPPOrchestrator.ps1 | 29 ++++++++++++++++--- .../Start-UserTasksOrchestrator.ps1 | 6 ++++ .../GraphHelper/Add-CippQueueMessage.ps1 | 25 ++++++++++++++-- 3 files changed, 53 insertions(+), 7 deletions(-) diff --git a/backend/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-CIPPOrchestrator.ps1 b/backend/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-CIPPOrchestrator.ps1 index 9877b7e16b..cfa107ac77 100644 --- a/backend/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-CIPPOrchestrator.ps1 +++ b/backend/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-CIPPOrchestrator.ps1 @@ -101,10 +101,31 @@ function Start-CIPPOrchestrator { } # The queue claims strictly by priority bucket (P00 first), so this decides who runs - # when the limiter is saturated. Callers that matter more than the default (baseline - # runs racing a fleet-wide test sweep) say so on the InputObject; everything else - # keeps the historical 4. - $Priority = [int]($InputObject.Priority ?? 4) + # when the limiter is saturated. Resolution order: + # 1. Explicit Priority on the InputObject (range-guarded: the store clamps into 0-99 + # buckets, so a stray negative would silently land in the critical P00 bucket). + # 2. The enclosing run's priority (ambient, set by Craft for orchestrator activities and + # post-exec jobs) — a child run belongs to its parent's band, so a baseline run's + # follow-up no longer drops back to the default. + # 3. P2 for HTTP-triggered orchestrations — user-initiated work must not queue behind + # background fan-outs. + # 4. The historical default 4 (timers and other background starters). + $Priority = $InputObject.Priority + if ($null -ne $Priority) { + $Priority = [int]$Priority + if ($Priority -lt 0 -or $Priority -gt 99) { $Priority = $null } + } + if ($null -eq $Priority) { + # $global:CraftOperationContext is stamped per invocation by the Craft worker — the + # pipeline thread never sees OperationContext.Current directly, and on an older Craft + # runtime the variable simply does not exist, so every read here degrades to $null. + $OpContext = $global:CraftOperationContext + $Priority = if ($null -ne $OpContext) { $OpContext.PSObject.Properties['Priority'].Value } + if ($null -eq $Priority) { + $Priority = if ($null -ne $OpContext -and $OpContext.Category -eq 'HTTP') { 2 } else { 4 } + } + $Priority = [int]$Priority + } Write-Information "Craft: Queuing orchestrator '$OrchestratorName' ($TaskCount tasks, P$Priority$(if ($PostExecFunctionName) { ", PostExec: $PostExecFunctionName" }))" [Craft.Services.OrchestratorBridge]::QueueOrchestrationFromFile( $OrchestratorName, diff --git a/backend/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-UserTasksOrchestrator.ps1 b/backend/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-UserTasksOrchestrator.ps1 index 9ddcfcc9b1..131cf714d7 100644 --- a/backend/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-UserTasksOrchestrator.ps1 +++ b/backend/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-UserTasksOrchestrator.ps1 @@ -251,6 +251,10 @@ function Start-UserTasksOrchestrator { OrchestratorName = "UserTaskOrchestrator_$TenantName" Batch = $BatchWithQueue SkipLog = $true + # User band: scheduled/run-now tasks must not queue behind P4 background fan-outs. + # Explicit because the starter jobs that invoke this function expose no ambient + # priority to inherit. Child orchestrations (e.g. OffboardingUser_*) inherit this. + Priority = 2 } if ($PSCmdlet.ShouldProcess('Start-UserTasksOrchestrator', 'Starting Single-Tenant Tasks Orchestrator')) { @@ -300,6 +304,8 @@ function Start-UserTasksOrchestrator { OrchestratorName = "UserTaskOrchestrator_$($ParentTask.Name)" Batch = @($AllBatchItems) SkipLog = $true + # User band - see the single-tenant orchestrator above. + Priority = 2 PostExecution = @{ FunctionName = 'ScheduledTaskPostExecution' Parameters = @{ diff --git a/backend/Modules/CIPPCore/Public/GraphHelper/Add-CippQueueMessage.ps1 b/backend/Modules/CIPPCore/Public/GraphHelper/Add-CippQueueMessage.ps1 index 1df44a16cb..13a2f4e377 100644 --- a/backend/Modules/CIPPCore/Public/GraphHelper/Add-CippQueueMessage.ps1 +++ b/backend/Modules/CIPPCore/Public/GraphHelper/Add-CippQueueMessage.ps1 @@ -9,6 +9,11 @@ function Add-CippQueueMessage { The name of the function to execute (must exist in CIPPCore module) .PARAMETER Parameters Hashtable of parameters to pass to the function + .PARAMETER Priority + Queue priority for the starter job (lower = sooner). The queue claims strictly by priority + bucket, so a starter below the background band (P4) cannot run until that backlog drains. + Defaults to P2 when called from an HTTP request (user-initiated work skips the queue) and + P5 otherwise. .EXAMPLE Add-CippQueueMessage -Cmdlet 'Start-BPAOrchestrator' -Parameters @{ TenantFilter = 'AllTenants'; Force = $true } .FUNCTIONALITY @@ -20,7 +25,11 @@ function Add-CippQueueMessage { [string]$Cmdlet, [Parameter(Mandatory = $false)] - [hashtable]$Parameters = @{} + [hashtable]$Parameters = @{}, + + [Parameter(Mandatory = $false)] + [ValidateRange(0, 99)] + [System.Nullable[int]]$Priority ) $QueueMessage = @{ @@ -30,9 +39,19 @@ function Add-CippQueueMessage { try { if ($env:CIPPNG -eq 'true') { + if ($null -eq $Priority) { + # Stamped per invocation by the Craft worker; absent on older Craft runtimes. + $OpContext = $global:CraftOperationContext + $Priority = if ($null -ne $OpContext -and $OpContext.Category -eq 'HTTP') { 2 } else { 5 } + } $ParametersJson = $Parameters | ConvertTo-Json -Depth 10 -Compress - [Craft.Services.QueueBridge]::Enqueue($Cmdlet, $ParametersJson) - Write-Information "Craft: Queued $Cmdlet for background execution" + try { + [Craft.Services.QueueBridge]::Enqueue($Cmdlet, $ParametersJson, [int]$Priority) + } catch [System.Management.Automation.MethodException] { + # Older Craft runtime without the priority overload - fall back to the default band. + [Craft.Services.QueueBridge]::Enqueue($Cmdlet, $ParametersJson) + } + Write-Information "Craft: Queued $Cmdlet for background execution (P$Priority)" return $true } From e2be9faeb949b63775948752e4fd798234b7a704 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Tue, 18 Aug 2026 20:03:13 +0800 Subject: [PATCH 143/226] chore(api): update api spec --- backend/Config/openapi.json | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/backend/Config/openapi.json b/backend/Config/openapi.json index 35171c71ca..0ca8fe2e00 100644 --- a/backend/Config/openapi.json +++ b/backend/Config/openapi.json @@ -17644,12 +17644,7 @@ } }, "tenantFilter": { - "type": "object", - "properties": { - "label": { - "type": "string" - } - } + "$ref": "#/components/schemas/LabelValue" } } }, @@ -20540,6 +20535,7 @@ { "name": "AddMapping", "in": "query", + "description": "AnyTenant: mapping writes wipe and rewrite whole partitions and re-register per-tenant sync tasks, so they require an unrestricted tenant scope", "required": false, "schema": { "type": "string", @@ -20556,6 +20552,7 @@ { "name": "AutoMapping", "in": "query", + "description": "AnyTenant: mapping writes wipe and rewrite whole partitions and re-register per-tenant sync tasks, so they require an unrestricted tenant scope", "required": false, "schema": { "type": "string", @@ -42772,7 +42769,8 @@ "type": "string" }, "partnerLookup": { - "type": "string" + "type": "string", + "description": "AnyTenant: enforce tenant scope here; Get-Tenants is narrowed to the caller's allowed tenants" }, "tenantFilter": { "type": "string" From f3a9fe1aa4aa7d42b900b12ea16a11967db55dae Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Tue, 18 Aug 2026 20:05:31 +0800 Subject: [PATCH 144/226] fix(exchange): seed contact templates from CIPPRootPath instead of relative path --- .../Administration/Contacts/Invoke-ListContactTemplates.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Contacts/Invoke-ListContactTemplates.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Contacts/Invoke-ListContactTemplates.ps1 index b42fa55a75..b218240885 100644 --- a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Contacts/Invoke-ListContactTemplates.ps1 +++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Contacts/Invoke-ListContactTemplates.ps1 @@ -14,7 +14,7 @@ function Invoke-ListContactTemplates { $Table = Get-CippTable -tablename 'templates' - $Templates = Get-ChildItem 'Config\*.ContactTemplate.json' | ForEach-Object { + $Templates = Get-ChildItem (Join-Path $env:CIPPRootPath 'Config\*.ContactTemplate.json') | ForEach-Object { $Entity = @{ JSON = "$(Get-Content $_)" RowKey = "$($_.name)" From ba81145ef1be763d4327e60faa92b40680d16c37 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Tue, 18 Aug 2026 20:05:49 +0800 Subject: [PATCH 145/226] fix(exchange): resolve connector comment variables per target tenant Invoke-AddExConnector ran Get-CIPPTextReplacement once before the tenant loop with an unassigned $Tenant, so %variable% tokens in the connector comment resolved against a null tenant. Move the replacement inside the per-tenant loop on a per-tenant copy of the params, so tokens resolve against each target tenant and one tenant's resolved values never feed the next tenant's replacement. --- .../Email-Exchange/Transport/Invoke-AddExConnector.ps1 | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Transport/Invoke-AddExConnector.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Transport/Invoke-AddExConnector.ps1 index 5e3ca2d26e..e18a1292a9 100644 --- a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Transport/Invoke-AddExConnector.ps1 +++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Transport/Invoke-AddExConnector.ps1 @@ -15,7 +15,7 @@ function Invoke-AddExConnector { $ConnectorType = ($Request.Body.PowerShellCommand | ConvertFrom-Json).cippConnectorType $RequestParams = $Request.Body.PowerShellCommand | ConvertFrom-Json | Select-Object -Property * -ExcludeProperty GUID, cippConnectorType, SenderRewritingEnabled - if ($RequestParams.comment) { $RequestParams.comment = Get-CIPPTextReplacement -Text $RequestParams.comment -TenantFilter $Tenant } else { $RequestParams | Add-Member -NotePropertyValue 'no comment' -NotePropertyName comment -Force } + if (-not $RequestParams.comment) { $RequestParams | Add-Member -NotePropertyValue 'no comment' -NotePropertyName comment -Force } $Tenants = ($Request.Body.selectedTenants).value $AllowedTenants = Test-CippAccess -Request $Request -TenantList @@ -28,7 +28,12 @@ function Invoke-AddExConnector { $Result = foreach ($TenantFilter in $Tenants) { try { - $null = New-ExoRequest -tenantid $TenantFilter -cmdlet "New-$($ConnectorType)connector" -cmdParams $RequestParams + # Copy per tenant so one tenant's resolved %variable% values never feed the next tenant's replacement. + $CmdParams = $RequestParams | Select-Object -Property * + if ($CmdParams.comment -match '%') { + $CmdParams.comment = Get-CIPPTextReplacement -Text $CmdParams.comment -TenantFilter $TenantFilter + } + $null = New-ExoRequest -tenantid $TenantFilter -cmdlet "New-$($ConnectorType)connector" -cmdParams $CmdParams "Successfully created Connector for $TenantFilter." Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message "Successfully created Connector for $TenantFilter." -sev 'Info' } catch { From ca0f6a4cc26dc07962e6ed1c6c8f967c552c7fc9 Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Tue, 18 Aug 2026 14:12:33 +0200 Subject: [PATCH 146/226] added ability to duplicate name check, and app consent standard changes --- .../EnableAppConsentRequests.json | 14 +++- backend/Config/standards.json | 18 +++-- ...PBaselineEnableAppConsentRequestsState.ps1 | 28 +++++++- ...e-CIPPBaselineEnableAppConsentRequests.ps1 | 38 ++++++++-- ...e-CIPPStandardEnableAppConsentRequests.ps1 | 69 ++++++++++++++----- .../BaselineOneOffStandards.Tests.ps1 | 50 ++++++++++++++ .../CippFormPages/CippAddEditUser.jsx | 34 +++++++++ frontend/src/data/standards.json | 12 +++- .../CippComponents/CippAddUserDrawer.test.jsx | 24 +++++++ 9 files changed, 250 insertions(+), 37 deletions(-) diff --git a/backend/Config/BaselineStandards/Entra (AAD) Standards/EnableAppConsentRequests.json b/backend/Config/BaselineStandards/Entra (AAD) Standards/EnableAppConsentRequests.json index cf4c1ed424..436f27c085 100644 --- a/backend/Config/BaselineStandards/Entra (AAD) Standards/EnableAppConsentRequests.json +++ b/backend/Config/BaselineStandards/Entra (AAD) Standards/EnableAppConsentRequests.json @@ -6,9 +6,9 @@ "CIS M365 7.0.0 (5.3.4)" ], "impact": "Low Impact", - "helpText": "Enables the admin consent workflow so users can request admin approval for applications instead of being blocked outright. The selected reviewer role receives the requests.", + "helpText": "Enables the admin consent workflow so users can request admin approval for applications instead of being blocked outright. The selected reviewer role receives the requests, and specific users (matched by display name) can be added as reviewers alongside it.", "executiveText": "Lets employees request administrator review when an application needs permissions they cannot grant themselves, routing risky consent decisions to IT instead of blocking work or encouraging shadow consent.", - "docsDescription": "Grades whether the admin consent request policy is enabled and whether the reviewer count matches the configured roles. Remediation enables the workflow with 30-day requests and reviewer notifications, and MERGES the configured role's reviewers into the existing list - reviewers added by hand are preserved. No role selected defaults to Global Administrator.", + "docsDescription": "Grades whether the admin consent request policy is enabled and whether the configured roles and users are present among the reviewers. Reviewer users are matched by display name, so a central MSP support account that exists as a guest in each tenant can receive per-request notifications regardless of how the guest was created. Remediation enables the workflow with 30-day requests and reviewer notifications, and MERGES the configured reviewers into the existing list - reviewers added by hand are preserved. No role selected defaults to Global Administrator.", "impactColour": "info", "addedDate": "2026-08-16", "powershellEquivalent": "Update-MgPolicyAdminConsentRequestPolicy", @@ -41,6 +41,13 @@ "valueField": "id", "queryKey": "ListEntraRoleDefinitions" } + }, + "ReviewerUsers": { + "type": "autoComplete", + "multiple": true, + "creatable": true, + "omitWhenBlank": true, + "label": "Additional reviewer users (display names of existing users or guests)" } }, "read": { @@ -49,6 +56,7 @@ "prepare": "Get-CIPPBaselineEnableAppConsentRequestsState", "remediate": { "executor": "EnableAppConsentRequests", - "reviewerRoles": "%ReviewerRoles%" + "reviewerRoles": "%ReviewerRoles%", + "reviewerUsers": "%ReviewerUsers%" } } diff --git a/backend/Config/standards.json b/backend/Config/standards.json index b63ed277cc..239094bb0f 100644 --- a/backend/Config/standards.json +++ b/backend/Config/standards.json @@ -1353,14 +1353,22 @@ "ZTNA21809", "ZTNA21869" ], - "helpText": "Enables App consent admin requests for the tenant via the GA role. Does not overwrite existing reviewer settings", - "docsDescription": "Enables the ability for users to request admin consent for applications. Should be used in conjunction with the \"Require admin consent for applications\" standards", + "helpText": "Enables App consent admin requests for the tenant via the GA role. Optionally adds specific users (matched by display name) as reviewers. Does not overwrite existing reviewer settings", + "docsDescription": "Enables the ability for users to request admin consent for applications. Reviewers can be directory roles and/or specific users matched by display name, e.g. a central MSP support account that exists as a guest in each tenant, so each consent request generates a notification to a monitored mailbox. Should be used in conjunction with the \"Require admin consent for applications\" standards", "executiveText": "Establishes a formal approval process where employees can request access to business applications that require administrative review. This balances security with productivity by allowing controlled access to necessary tools while preventing unauthorized application installations.", "addedComponent": [ { "type": "AdminRolesMultiSelect", "label": "App Consent Reviewer Roles", "name": "standards.EnableAppConsentRequests.ReviewerRoles" + }, + { + "type": "autoComplete", + "multiple": true, + "creatable": true, + "required": false, + "label": "Optional: reviewer users (display names of existing users or guests)", + "name": "standards.EnableAppConsentRequests.ReviewerUsers" } ], "label": "Enable App consent admin requests", @@ -6487,8 +6495,10 @@ "label": "Policy Assignment", "options": [ { "label": "Do not assign", "value": "none" }, - { "label": "All devices", "value": "AllDevices" }, - { "label": "All users and devices", "value": "AllDevicesAndUsers" } + { + "label": "All users (Device Preparation profiles deploy to the enrolling user, so device targets do not apply)", + "value": "AllDevicesAndUsers" + } ] } ], diff --git a/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineEnableAppConsentRequestsState.ps1 b/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineEnableAppConsentRequestsState.ps1 index 32221ef93e..cbadb4b959 100644 --- a/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineEnableAppConsentRequestsState.ps1 +++ b/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineEnableAppConsentRequestsState.ps1 @@ -4,13 +4,20 @@ function Get-CIPPBaselineEnableAppConsentRequestsState { Prepare hook for EnableAppConsentRequests: is the admin consent workflow on with the configured reviewers. .DESCRIPTION - Grades the policy enabled flag and whether each configured role is PRESENT among - the reviewers. The classic graded the reviewer COUNT, which never converges: a + Grades the policy enabled flag and whether each configured role and user is PRESENT + among the reviewers. The classic graded the reviewer COUNT, which never converges: a reviewer an operator added by hand bumps the count, and the remediation merge deliberately preserves that reviewer - so count-graded drift was permanent. Containment is what the merge write actually guarantees, the same reasoning that keeps QuarantineRequestAlert on a contains grade. + Reviewer users are configured as display names (not mail - a guest's mail attribute + depends on how the account was created) and resolved against the Users cache, joined + through Get-CIPPBaselineCacheRows because Users is not this definition's primary + cache. A name that resolves to no cached user is graded missing: the account the + operator expects to review requests does not exist in the tenant. Reviewer queries + are matched on both id and UPN since hand-added user reviewers can carry either. + No role configured defaults to Global Administrator, matching the classic in both the grade and the write. .FUNCTIONALITY @@ -31,11 +38,26 @@ function Get-CIPPBaselineEnableAppConsentRequestsState { $ReviewerQueries = @(@($Policy.reviewers) | ForEach-Object { "$($_.query)" }) $MissingRoles = @($Roles | Where-Object { $Role = $_; -not ($ReviewerQueries | Where-Object { $_ -match $Role }) }) + $UserNames = @(@($Item.Variables.ReviewerUsers) | ForEach-Object { "$($_.value ?? $_)" } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) + $MissingUsers = @() + if ($UserNames.Count -gt 0) { + $Users = @(Get-CIPPBaselineCacheRows -TenantFilter $TenantFilter -Type 'Users') + $MissingUsers = @($UserNames | Where-Object { + $Name = $_ + $Covered = @($Users) | Where-Object { $_.displayName -eq $Name } | Where-Object { + $User = $_ + $ReviewerQueries | Where-Object { $_ -match [regex]::Escape("$($User.id)") -or (-not [string]::IsNullOrWhiteSpace($User.userPrincipalName) -and $_ -match [regex]::Escape("$($User.userPrincipalName)")) } + } + -not $Covered + }) + } + @{ - Expected = [PSCustomObject]@{ appConsentRequestsEnabled = $true; missingReviewerRoles = @() } + Expected = [PSCustomObject]@{ appConsentRequestsEnabled = $true; missingReviewerRoles = @(); missingReviewerUsers = @() } Current = [PSCustomObject]@{ appConsentRequestsEnabled = [bool]$Policy.isEnabled missingReviewerRoles = @($MissingRoles) + missingReviewerUsers = @($MissingUsers) } } } diff --git a/backend/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineEnableAppConsentRequests.ps1 b/backend/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineEnableAppConsentRequests.ps1 index 8872a7d6c9..df3dc880fb 100644 --- a/backend/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineEnableAppConsentRequests.ps1 +++ b/backend/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineEnableAppConsentRequests.ps1 @@ -2,7 +2,7 @@ function Invoke-CIPPBaselineEnableAppConsentRequests { <# .SYNOPSIS EnableAppConsentRequests executor: enables the admin consent workflow with the - configured reviewer roles. + configured reviewer roles and users. .DESCRIPTION Read-merge-write, ported whole from the classic: the policy is fetched LIVE, flipped on with the fixed notification settings, and the configured roles become @@ -10,6 +10,12 @@ function Invoke-CIPPBaselineEnableAppConsentRequests { an operator added by hand survive. The write is a full PUT because the policy does not support PATCH. + Reviewer users are configured as display names and resolved to ids LIVE - display + name rather than mail, because a guest's mail can land in mail, otherMails or + nowhere depending on how the account was created, while the display name is + whatever the operator typed regardless of creation path. A name that resolves to + nothing is logged and skipped so the roles still land. + No role configured defaults to Global Administrator, matching the hook's grade. .FUNCTIONALITY Internal @@ -27,18 +33,31 @@ function Invoke-CIPPBaselineEnableAppConsentRequests { $Roles = @(@($Remediate.reviewerRoles) | ForEach-Object { "$($_.value ?? $_)" } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) if ($Roles.Count -eq 0) { $Roles = @('62e90394-69f5-4237-9190-012177145e10') } + $UserNames = @(@($Remediate.reviewerUsers) | ForEach-Object { "$($_.value ?? $_)" } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) + $Users = [System.Collections.Generic.List[object]]::new() + foreach ($Name in $UserNames) { + $UserFilter = [System.Uri]::EscapeDataString("displayName eq '$($Name -replace "'", "''")'") + $Matched = @(New-GraphGetRequest -uri "https://graph.microsoft.com/beta/users?`$select=id,displayName&`$filter=$UserFilter" -tenantid $TenantFilter) + if ($Matched.Count -eq 0) { + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "EnableAppConsentRequests: no user found with display name '$Name' - not added as reviewer." -Sev 'Warning' + continue + } + foreach ($User in $Matched) { $Users.Add($User) } + } + $Policy.isEnabled = $true $Policy.notifyReviewers = $true $Policy.remindersEnabled = $true $Policy.requestDurationInDays = 30 + $ManagedIds = @($Roles) + @($Users | ForEach-Object { "$($_.id)" }) $Reviewers = [System.Collections.Generic.List[object]]::new() foreach ($Reviewer in @($Policy.reviewers)) { - $RoleFound = $false - foreach ($Role in $Roles) { - if ("$($Reviewer.query)" -match $Role) { $RoleFound = $true } + $Found = $false + foreach ($Id in $ManagedIds) { + if ("$($Reviewer.query)" -match $Id) { $Found = $true } } - if (-not $RoleFound) { $Reviewers.Add($Reviewer) } + if (-not $Found) { $Reviewers.Add($Reviewer) } } foreach ($Role in $Roles) { $Reviewers.Add(@{ @@ -47,8 +66,15 @@ function Invoke-CIPPBaselineEnableAppConsentRequests { queryRoot = 'null' }) } + foreach ($User in $Users) { + $Reviewers.Add(@{ + query = "/users/$($User.id)" + queryType = 'MicrosoftGraph' + queryRoot = 'null' + }) + } $Policy.reviewers = @($Reviewers) $null = New-GraphPostRequest -tenantid $TenantFilter -uri 'https://graph.microsoft.com/beta/policies/adminConsentRequestPolicy' -type PUT -body (ConvertTo-Json -Compress -Depth 10 -InputObject $Policy) - Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "Enabled app consent requests with $($Roles.Count) reviewer role(s)." -Sev 'Info' + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "Enabled app consent requests with $($Roles.Count) reviewer role(s) and $($Users.Count) reviewer user(s)." -Sev 'Info' } diff --git a/backend/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardEnableAppConsentRequests.ps1 b/backend/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardEnableAppConsentRequests.ps1 index 6665ff6c9a..72cc357d31 100644 --- a/backend/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardEnableAppConsentRequests.ps1 +++ b/backend/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardEnableAppConsentRequests.ps1 @@ -7,13 +7,13 @@ function Invoke-CIPPStandardEnableAppConsentRequests { .SYNOPSIS (Label) Enable App consent admin requests .DESCRIPTION - (Helptext) Enables App consent admin requests for the tenant via the GA role. Does not overwrite existing reviewer settings - (DocsDescription) Enables the ability for users to request admin consent for applications. Should be used in conjunction with the "Require admin consent for applications" standards + (Helptext) Enables App consent admin requests for the tenant via the GA role. Optionally adds specific users (matched by display name) as reviewers. Does not overwrite existing reviewer settings + (DocsDescription) Enables the ability for users to request admin consent for applications. Reviewers can be directory roles and/or specific users matched by display name, e.g. a central MSP support account that exists as a guest in each tenant, so each consent request generates a notification to a monitored mailbox. Should be used in conjunction with the "Require admin consent for applications" standards .NOTES CAT Entra (AAD) Standards TAG - "CIS M365 5.0 (1.5.2)" + "CIS M365 7.0.0 (5.1.5.2)" "CISA (MS.AAD.9.1v1)" "EIDSCA.CP04" "EIDSCA.CR01" @@ -22,15 +22,20 @@ function Invoke-CIPPStandardEnableAppConsentRequests { "EIDSCA.CR04" "Essential 8 (1507)" "NIST CSF 2.0 (PR.AA-05)" - "ZTNA21869" + APPLIESTOTEST + "CIS_5_1_5_2" + "EIDSCACP04" "EIDSCACR01" "EIDSCACR02" "EIDSCACR03" "EIDSCACR04" + "ZTNA21809" + "ZTNA21869" EXECUTIVETEXT Establishes a formal approval process where employees can request access to business applications that require administrative review. This balances security with productivity by allowing controlled access to necessary tools while preventing unauthorized application installations. ADDEDCOMPONENT {"type":"AdminRolesMultiSelect","label":"App Consent Reviewer Roles","name":"standards.EnableAppConsentRequests.ReviewerRoles"} + {"type":"autoComplete","multiple":true,"creatable":true,"required":false,"label":"Optional: reviewer users (display names of existing users or guests)","name":"standards.EnableAppConsentRequests.ReviewerUsers"} IMPACT Low Impact ADDEDDATE @@ -40,7 +45,7 @@ function Invoke-CIPPStandardEnableAppConsentRequests { RECOMMENDEDBY "CIS" UPDATECOMMENTBLOCK - Run the Tools\Update-StandardsComments.ps1 script to update this comment block + Run the tools\Update-StandardsComments.ps1 script to update this comment block .LINK https://docs.cipp.app/user-documentation/tenant/standards/alignment/templates/available-standards #> @@ -75,29 +80,53 @@ function Invoke-CIPPStandardEnableAppConsentRequests { $RoleNames = '(Default) Global Administrator' } - $NewReviewers = foreach ($Role in $RolesToAdd) { - @{ - query = "/beta/roleManagement/directory/roleAssignments?`$filter=roleDefinitionId eq '$Role'" - queryType = 'MicrosoftGraph' - queryRoot = 'null' + # Users from standards table, matched on display name so the reviewer account + # can be created any way (invited guest, B2B, manual) regardless of which mail + # attribute ended up populated + $ReviewerUserNames = @(($Settings.ReviewerUsers.value ?? $Settings.ReviewerUsers) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) + $ReviewerUsers = [System.Collections.Generic.List[object]]::new() + foreach ($Name in $ReviewerUserNames) { + $UserFilter = [System.Uri]::EscapeDataString("displayName eq '$($Name -replace "'", "''")'") + $MatchedUsers = @(New-GraphGetRequest -uri "https://graph.microsoft.com/beta/users?`$select=id,displayName&`$filter=$UserFilter" -tenantid $Tenant) + if ($MatchedUsers.Count -eq 0) { + Write-LogMessage -API 'Standards' -tenant $Tenant -message "EnableAppConsentRequests: No user found with display name '$Name', not added as reviewer" -sev Warning + continue } + foreach ($User in $MatchedUsers) { $ReviewerUsers.Add($User) } + } + + $NewReviewers = [System.Collections.Generic.List[object]]::new() + foreach ($Role in $RolesToAdd) { + $NewReviewers.Add(@{ + query = "/beta/roleManagement/directory/roleAssignments?`$filter=roleDefinitionId eq '$Role'" + queryType = 'MicrosoftGraph' + queryRoot = 'null' + }) + } + foreach ($User in $ReviewerUsers) { + $NewReviewers.Add(@{ + query = "/users/$($User.id)" + queryType = 'MicrosoftGraph' + queryRoot = 'null' + }) } - # Add existing reviewers + # Add existing reviewers, skipping any that the configured roles/users already cover + $IdsToAdd = @($RolesToAdd) + @($ReviewerUsers | ForEach-Object { $_.id }) $Reviewers = [System.Collections.Generic.List[object]]::new() foreach ($Reviewer in $CurrentInfo.reviewers) { - $RoleFound = $false - foreach ($Role in $RolesToAdd) { - if ($Reviewer.query -match $Role -or $Reviewers.query -contains $Reviewer.query) { - $RoleFound = $true + $Found = $false + foreach ($Id in $IdsToAdd) { + if ($Reviewer.query -match $Id -or $Reviewers.query -contains $Reviewer.query) { + $Found = $true } } - if (!$RoleFound) { + if (!$Found) { $Reviewers.add($Reviewer) } } - # Add new reviewer roles + # Add new reviewer roles and users foreach ($NewReviewer in $NewReviewers) { $Reviewers.add($NewReviewer) } @@ -107,7 +136,8 @@ function Invoke-CIPPStandardEnableAppConsentRequests { $body = (ConvertTo-Json -Compress -Depth 10 -InputObject $CurrentInfo) New-GraphPostRequest -tenantid $tenant -Uri 'https://graph.microsoft.com/beta/policies/adminConsentRequestPolicy' -Type put -Body $body -ContentType 'application/json' - Write-LogMessage -API 'Standards' -tenant $tenant -message "Enabled App consent admin requests for the following roles: $RoleNames" -sev Info + $UserLogSuffix = if ($ReviewerUsers.Count -gt 0) { " and the following users: $(@($ReviewerUsers | ForEach-Object { $_.displayName }) -join ', ')" } else { '' } + Write-LogMessage -API 'Standards' -tenant $tenant -message "Enabled App consent admin requests for the following roles: $RoleNames$UserLogSuffix" -sev Info } catch { $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message @@ -129,6 +159,7 @@ function Invoke-CIPPStandardEnableAppConsentRequests { if (!$RolesToAdd -or $RolesToAdd.Count -eq 0) { $RolesToAdd = @('62e90394-69f5-4237-9190-012177145e10') } + $ReviewerUserNames = @(($Settings.ReviewerUsers.value ?? $Settings.ReviewerUsers) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) $CurrentValue = [PSCustomObject]@{ EnableAppConsentRequests = [bool]$CurrentInfo.isEnabled @@ -136,7 +167,7 @@ function Invoke-CIPPStandardEnableAppConsentRequests { } $ExpectedValue = [PSCustomObject]@{ EnableAppConsentRequests = $true - ReviewerCount = $RolesToAdd.Count + ReviewerCount = $RolesToAdd.Count + $ReviewerUserNames.Count } Set-CIPPStandardsCompareField -FieldName 'standards.EnableAppConsentRequests' -CurrentValue $CurrentValue -ExpectedValue $ExpectedValue -TenantFilter $Tenant diff --git a/backend/Tests/Baselines/BaselineOneOffStandards.Tests.ps1 b/backend/Tests/Baselines/BaselineOneOffStandards.Tests.ps1 index 61a68fbfae..8abc75415b 100644 --- a/backend/Tests/Baselines/BaselineOneOffStandards.Tests.ps1 +++ b/backend/Tests/Baselines/BaselineOneOffStandards.Tests.ps1 @@ -145,6 +145,56 @@ Describe 'Get-CIPPBaselineEnableAppConsentRequestsState' { $type -eq 'PUT' -and $body -match 'keepme@contoso.com' -and $body -match '62e90394-69f5-4237-9190-012177145e10' -and $body -match '"isEnabled":\s*true' } } + + It 'grades configured reviewer users by display name, not mail' { + # displayName is the match key on purpose: a guest's mail can land in mail, + # otherMails or nowhere depending on how the account was created. + Mock New-CIPPDbRequest { + if ($Type -eq 'Users') { + @(@{ id = '11111111-aaaa-bbbb-cccc-222222222222'; displayName = 'MSP Support'; userPrincipalName = 'support_msp.com#EXT#@contoso.onmicrosoft.com' } | ConvertTo-Cached) + } else { + @(@{ isEnabled = $true; reviewers = @( + @{ query = "/beta/roleManagement/directory/roleAssignments?`$filter=roleDefinitionId eq '62e90394-69f5-4237-9190-012177145e10'" }, + @{ query = '/users/11111111-aaaa-bbbb-cccc-222222222222' } + ) } | ConvertTo-Cached) + } + } + $Item = [PSCustomObject]@{ Variables = [PSCustomObject]@{ ReviewerUsers = @([PSCustomObject]@{ label = 'MSP Support'; value = 'MSP Support' }) } } + $Prepared = Get-CIPPBaselineEnableAppConsentRequestsState -Item $Item -TenantFilter $script:Tenant + @($Prepared.Current.missingReviewerUsers).Count | Should -Be 0 + (Get-Verdict -Expected $Prepared.Expected -Current $Prepared.Current).Count | Should -Be 0 + } + + It 'reports drift when the configured user is absent from the reviewers or does not exist' { + Mock New-CIPPDbRequest { + if ($Type -eq 'Users') { + @(@{ id = '11111111-aaaa-bbbb-cccc-222222222222'; displayName = 'MSP Support'; userPrincipalName = 'support_msp.com#EXT#@contoso.onmicrosoft.com' } | ConvertTo-Cached) + } else { + @(@{ isEnabled = $true; reviewers = @() } | ConvertTo-Cached) + } + } + $Item = [PSCustomObject]@{ Variables = [PSCustomObject]@{ ReviewerUsers = @('MSP Support', 'Ghost Account') } } + $Prepared = Get-CIPPBaselineEnableAppConsentRequestsState -Item $Item -TenantFilter $script:Tenant + $Prepared.Current.missingReviewerUsers | Should -Contain 'MSP Support' + # A name that resolves to no user at all is missing too - the reviewer account + # the operator expects does not exist in the tenant. + $Prepared.Current.missingReviewerUsers | Should -Contain 'Ghost Account' + } + + It 'resolves reviewer users by display name and does not duplicate one already present' { + Mock New-GraphGetRequest { + if ($uri -match '/users\?') { + @(@{ id = '33333333-dddd-eeee-ffff-444444444444'; displayName = 'MSP Support' } | ConvertTo-Cached) + } else { + @{ isEnabled = $false; notifyReviewers = $false; remindersEnabled = $false; requestDurationInDays = 0; reviewers = @(@{ query = '/users/33333333-dddd-eeee-ffff-444444444444'; queryType = 'MicrosoftGraph'; queryRoot = 'null' }) } | ConvertTo-Cached + } + } + Mock New-GraphPostRequest { } + Invoke-CIPPBaselineEnableAppConsentRequests -Remediate ([PSCustomObject]@{ reviewerRoles = @(); reviewerUsers = @('MSP Support') }) -TenantFilter $script:Tenant -Current $null + Should -Invoke New-GraphPostRequest -Times 1 -Exactly -ParameterFilter { + $type -eq 'PUT' -and ([regex]::Matches($body, '33333333-dddd-eeee-ffff-444444444444')).Count -eq 1 -and $body -match '62e90394-69f5-4237-9190-012177145e10' + } + } } Describe 'Get-CIPPBaselineTeamsFederationConfigurationState' { diff --git a/frontend/src/components/CippFormPages/CippAddEditUser.jsx b/frontend/src/components/CippFormPages/CippAddEditUser.jsx index edbf5e1090..3b7290e47d 100644 --- a/frontend/src/components/CippFormPages/CippAddEditUser.jsx +++ b/frontend/src/components/CippFormPages/CippAddEditUser.jsx @@ -10,6 +10,7 @@ import { CippFormLicenseSelector } from '../CippComponents/CippFormLicenseSelect import { Grid } from '@mui/system' import { ApiGetCall } from '../../api/ApiCall' import { useSettings } from '../../hooks/use-settings' +import { useQueryClient } from '@tanstack/react-query' import { useWatch } from 'react-hook-form' import { useEffect, useMemo, useRef, useState } from 'react' import { useRouter } from 'next/router' @@ -158,6 +159,32 @@ const CippAddEditUser = (props) => { AddToGroups: watcher[3], } + // Duplicate-username warning. The Users table already pulled the tenant's user list into the + // tanstack cache when it loaded, so this reads that cache and makes no API request. The entry + // is an infinite query (the table pages through nextLinks), so every page must be flattened - + // checking one page would miss most of the tenant. Warning-only: the cache can be partial or + // stale, so no conflict found is never presented as the name being available. + const queryClient = useQueryClient() + const usernameValue = useWatch({ control: formControl.control, name: 'username' }) + const primDomainValue = useWatch({ control: formControl.control, name: 'primDomain' }) + const usernameConflict = useMemo(() => { + if (formType !== 'add' || !usernameValue || !primDomainValue?.value) return null + const cachedUsers = queryClient + .getQueryData([`Users - ${tenantDomain}`]) + ?.pages?.flatMap((page) => page?.Results ?? []) + if (!cachedUsers?.length) return null + const candidateUPN = `${usernameValue}@${primDomainValue.value}`.toLowerCase() + const candidateSmtp = `smtp:${candidateUPN}` + return ( + cachedUsers.find( + (user) => + user?.userPrincipalName?.toLowerCase() === candidateUPN || + (Array.isArray(user?.proxyAddresses) && + user.proxyAddresses.some((address) => address?.toLowerCase() === candidateSmtp)) + ) ?? null + ) + }, [formType, usernameValue, primDomainValue?.value, tenantDomain, queryClient]) + // Helper function to generate username from template format const generateUsername = ( format, @@ -601,6 +628,13 @@ const CippAddEditUser = (props) => { showRefresh={true} /> + {formType === 'add' && usernameConflict && ( + + + {`${usernameValue}@${primDomainValue?.value} is already in use by "${usernameConflict.displayName}" (${usernameConflict.userPrincipalName}).`} + + + )} vi.mock('../../../src/components/CippComponents/CippApiResults', () => ({ CippApiResults: () => null, })) +// CippFormComponent statically imports the data-table stack for its cippDataTable case; +// nothing in this drawer uses it, but importing it is enough to exhaust the test worker. +vi.mock('../../../src/components/CippTable/CippDataTable', () => ({ + CippDataTable: () =>
    , + default: () =>
    , +})) +// CippAutoComplete statically imports CippJsonView for its option-preview offcanvas, which +// drags in the formatting/code-block/Intune-definition graph - another worker-killer this +// flow never renders. +vi.mock('../../../src/components/CippFormPages/CippJSONView', () => ({ + default: () => null, +})) +// The real drawer shell drags in the property-card/formatting graph, which this test does +// not exercise. The stub keeps the essential contract: content + footer render only while +// the drawer is open. +vi.mock('../../../src/components/CippComponents/CippOffCanvas', () => ({ + CippOffCanvas: ({ visible, children, footer }) => + visible ? ( +
    + {children} + {footer} +
    + ) : null, +})) const idleGet = { isSuccess: false, isFetching: false, isError: false, data: undefined, refetch: vi.fn() } const okGet = (data) => ({ isSuccess: true, isFetching: false, isError: false, data, refetch: vi.fn() }) From 3dedce3ccaf563c0d4d418d3a4ee2053dad0ff66 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Tue, 18 Aug 2026 20:33:48 +0800 Subject: [PATCH 147/226] fix(queue): read Craft context via variable lookup Use `Get-Variable` to fetch `CraftOperationContext` from global scope in orchestrator and queue helpers. This keeps priority detection working on current Craft workers while still degrading cleanly to defaults on older runtimes where the variable is absent. --- .../Orchestrator Functions/Start-CIPPOrchestrator.ps1 | 8 ++++---- .../CIPPCore/Public/GraphHelper/Add-CippQueueMessage.ps1 | 5 +++-- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/backend/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-CIPPOrchestrator.ps1 b/backend/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-CIPPOrchestrator.ps1 index cfa107ac77..d362cb9cad 100644 --- a/backend/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-CIPPOrchestrator.ps1 +++ b/backend/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-CIPPOrchestrator.ps1 @@ -116,10 +116,10 @@ function Start-CIPPOrchestrator { if ($Priority -lt 0 -or $Priority -gt 99) { $Priority = $null } } if ($null -eq $Priority) { - # $global:CraftOperationContext is stamped per invocation by the Craft worker — the - # pipeline thread never sees OperationContext.Current directly, and on an older Craft - # runtime the variable simply does not exist, so every read here degrades to $null. - $OpContext = $global:CraftOperationContext + # $CraftOperationContext is stamped into the global scope per invocation by the Craft + # worker — the pipeline thread never sees OperationContext.Current directly, and on an + # older Craft runtime the variable simply does not exist, so this read degrades to $null. + $OpContext = Get-Variable -Name 'CraftOperationContext' -Scope Global -ValueOnly -ErrorAction SilentlyContinue $Priority = if ($null -ne $OpContext) { $OpContext.PSObject.Properties['Priority'].Value } if ($null -eq $Priority) { $Priority = if ($null -ne $OpContext -and $OpContext.Category -eq 'HTTP') { 2 } else { 4 } diff --git a/backend/Modules/CIPPCore/Public/GraphHelper/Add-CippQueueMessage.ps1 b/backend/Modules/CIPPCore/Public/GraphHelper/Add-CippQueueMessage.ps1 index 13a2f4e377..abb87f0746 100644 --- a/backend/Modules/CIPPCore/Public/GraphHelper/Add-CippQueueMessage.ps1 +++ b/backend/Modules/CIPPCore/Public/GraphHelper/Add-CippQueueMessage.ps1 @@ -40,8 +40,9 @@ function Add-CippQueueMessage { try { if ($env:CIPPNG -eq 'true') { if ($null -eq $Priority) { - # Stamped per invocation by the Craft worker; absent on older Craft runtimes. - $OpContext = $global:CraftOperationContext + # Stamped into the global scope per invocation by the Craft worker; absent on older + # Craft runtimes, so this read degrades to $null and the default band applies. + $OpContext = Get-Variable -Name 'CraftOperationContext' -Scope Global -ValueOnly -ErrorAction SilentlyContinue $Priority = if ($null -ne $OpContext -and $OpContext.Category -eq 'HTTP') { 2 } else { 5 } } $ParametersJson = $Parameters | ConvertTo-Json -Depth 10 -Compress From a0356d8ac5a1478543928e96747b60e3a05bb04d Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Tue, 18 Aug 2026 14:38:00 +0200 Subject: [PATCH 148/226] added sendas to offboarding wizard --- .../Public/Invoke-CIPPOffboardingJob.ps1 | 24 ++++++++ .../CIPPCore/Public/Set-CIPPMailboxAccess.ps1 | 8 ++- .../Public/Test-CIPPOffboardingRequest.ps1 | 2 +- .../Private/Set-CIPPMailboxAccess.Tests.ps1 | 24 ++++++-- .../CippWizard/CippWizardOffboarding.jsx | 60 ++++++++++++++++++- 5 files changed, 108 insertions(+), 10 deletions(-) diff --git a/backend/Modules/CIPPCore/Public/Invoke-CIPPOffboardingJob.ps1 b/backend/Modules/CIPPCore/Public/Invoke-CIPPOffboardingJob.ps1 index c4f40e5264..d7eef5b06c 100644 --- a/backend/Modules/CIPPCore/Public/Invoke-CIPPOffboardingJob.ps1 +++ b/backend/Modules/CIPPCore/Public/Invoke-CIPPOffboardingJob.ps1 @@ -202,6 +202,30 @@ function Invoke-CIPPOffboardingJob { Headers = $Headers } } + @{ + Condition = { $Options.AccessSendAs.Count -gt 0 } + Cmdlet = 'Set-CIPPMailboxAccess' + Parameters = @{ + tenantFilter = $TenantFilter + userid = $Username + AccessUser = $Options.AccessSendAs + PermissionLevel = 'SendAs' + APIName = $APIName + Headers = $Headers + } + } + @{ + Condition = { $Options.AccessSendOnBehalf.Count -gt 0 } + Cmdlet = 'Set-CIPPMailboxAccess' + Parameters = @{ + tenantFilter = $TenantFilter + userid = $Username + AccessUser = $Options.AccessSendOnBehalf + PermissionLevel = 'SendOnBehalf' + APIName = $APIName + Headers = $Headers + } + } @{ Condition = { $Options.removePermissions -eq $true } Cmdlet = 'Remove-CIPPMailboxPermissions' diff --git a/backend/Modules/CIPPCore/Public/Set-CIPPMailboxAccess.ps1 b/backend/Modules/CIPPCore/Public/Set-CIPPMailboxAccess.ps1 index 54a1a075c0..08cfec45b5 100644 --- a/backend/Modules/CIPPCore/Public/Set-CIPPMailboxAccess.ps1 +++ b/backend/Modules/CIPPCore/Public/Set-CIPPMailboxAccess.ps1 @@ -7,7 +7,9 @@ function Set-CIPPMailboxAccess { $TenantFilter, $APIName = 'Manage Shared Mailbox Access', $Headers, - [array]$AccessRights # Retained for caller compatibility; this helper grants FullAccess + [array]$AccessRights, # Retained for caller compatibility; use PermissionLevel instead + [ValidateSet('FullAccess', 'SendAs', 'SendOnBehalf')] + [string]$PermissionLevel = 'FullAccess' ) # Ensure AccessUser is always an array @@ -23,10 +25,10 @@ function Set-CIPPMailboxAccess { $Results = [system.collections.generic.list[string]]::new() # Delegate each grant to Set-CIPPMailboxPermission so the permission-level -> EXO cmdlet mapping, - # logging, cache sync, and error handling all live in one place. This helper grants FullAccess. + # logging, cache sync, and error handling all live in one place. foreach ($User in $AccessUser) { $Results.Add( - (Set-CIPPMailboxPermission -UserId $userid -AccessUser $User -PermissionLevel 'FullAccess' -Action 'Add' -AutoMap $Automap -TenantFilter $TenantFilter -APIName $APIName -Headers $Headers) + (Set-CIPPMailboxPermission -UserId $userid -AccessUser $User -PermissionLevel $PermissionLevel -Action 'Add' -AutoMap $Automap -TenantFilter $TenantFilter -APIName $APIName -Headers $Headers) ) } diff --git a/backend/Modules/CIPPCore/Public/Test-CIPPOffboardingRequest.ps1 b/backend/Modules/CIPPCore/Public/Test-CIPPOffboardingRequest.ps1 index 4938feef3d..0b10b2bb75 100644 --- a/backend/Modules/CIPPCore/Public/Test-CIPPOffboardingRequest.ps1 +++ b/backend/Modules/CIPPCore/Public/Test-CIPPOffboardingRequest.ps1 @@ -71,7 +71,7 @@ function Test-CIPPOffboardingRequest { 'ClearImmutableId', 'ResetPass', 'RemoveMFADevices', 'RemoveTeamsPhoneDID', 'DeleteUser', 'DisableOneDriveSharing', 'disableForwarding' ) - $CollectionActions = @('AccessNoAutomap', 'AccessAutomap', 'OnedriveAccess') + $CollectionActions = @('AccessNoAutomap', 'AccessAutomap', 'AccessSendAs', 'AccessSendOnBehalf', 'OnedriveAccess') $HasAction = $false foreach ($Key in $BooleanActions) { diff --git a/backend/Tests/Private/Set-CIPPMailboxAccess.Tests.ps1 b/backend/Tests/Private/Set-CIPPMailboxAccess.Tests.ps1 index d837ba181d..e817c487d0 100644 --- a/backend/Tests/Private/Set-CIPPMailboxAccess.Tests.ps1 +++ b/backend/Tests/Private/Set-CIPPMailboxAccess.Tests.ps1 @@ -1,9 +1,9 @@ # Pester tests for Set-CIPPMailboxAccess -# Set-CIPPMailboxAccess now delegates each grant to Set-CIPPMailboxPermission (FullAccess / Add), so -# these tests cover the per-user fan-out, extraction of frontend objects with a .value property, -# AutoMap pass-through, and that one user's failure does not stop the rest (the delegate returns an -# error string rather than throwing). The EXO cmdlet mapping itself is covered by -# Set-CIPPMailboxPermission.Tests.ps1. +# Set-CIPPMailboxAccess now delegates each grant to Set-CIPPMailboxPermission (Add, with a +# PermissionLevel that defaults to FullAccess), so these tests cover the per-user fan-out, +# extraction of frontend objects with a .value property, AutoMap and PermissionLevel pass-through, +# and that one user's failure does not stop the rest (the delegate returns an error string rather +# than throwing). The EXO cmdlet mapping itself is covered by Set-CIPPMailboxPermission.Tests.ps1. BeforeAll { $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) @@ -60,6 +60,20 @@ Describe 'Set-CIPPMailboxAccess' { Should -Invoke Set-CIPPMailboxPermission -Times 1 -Exactly -ParameterFilter { $AutoMap -eq $false } } + It 'passes an explicit PermissionLevel through to the delegate' { + Set-CIPPMailboxAccess -userid 'shared@contoso.com' -AccessUser 'user@contoso.com' ` + -PermissionLevel 'SendAs' -TenantFilter 'contoso.com' + + Should -Invoke Set-CIPPMailboxPermission -Times 1 -Exactly -ParameterFilter { + $PermissionLevel -eq 'SendAs' -and $Action -eq 'Add' + } + } + + It 'rejects a PermissionLevel outside FullAccess/SendAs/SendOnBehalf' { + { Set-CIPPMailboxAccess -userid 'shared@contoso.com' -AccessUser 'user@contoso.com' ` + -PermissionLevel 'ReadPermission' -TenantFilter 'contoso.com' } | Should -Throw + } + It 'continues to the next user when one user returns a failure string' { Mock -CommandName Set-CIPPMailboxPermission -MockWith { if ($AccessUser -eq 'bad@contoso.com') { diff --git a/frontend/src/components/CippWizard/CippWizardOffboarding.jsx b/frontend/src/components/CippWizard/CippWizardOffboarding.jsx index 54e2e4e332..4a4948feee 100644 --- a/frontend/src/components/CippWizard/CippWizardOffboarding.jsx +++ b/frontend/src/components/CippWizard/CippWizardOffboarding.jsx @@ -326,6 +326,61 @@ export const CippWizardOffboarding = (props) => { }, }} /> + `${option.displayName} (${option.userPrincipalName})`, + valueField: 'id', + url: '/api/ListGraphRequest', + dataKey: 'Results', + tenantFilter: currentTenant ? currentTenant.value : undefined, + queryKey: `Offboarding-Users-${currentTenant ? currentTenant.value : 'default'}`, + data: { + Endpoint: 'users', + manualPagination: true, + $select: 'id,userPrincipalName,displayName', + $count: true, + $orderby: 'displayName', + $top: 999, + }, + }} + /> + `${option.displayName} (${option.userPrincipalName})`, + valueField: 'id', + url: '/api/ListGraphRequest', + dataKey: 'Results', + tenantFilter: currentTenant ? currentTenant.value : undefined, + queryKey: `Offboarding-Users-${currentTenant ? currentTenant.value : 'default'}`, + data: { + Endpoint: 'users', + manualPagination: true, + $select: 'id,userPrincipalName,displayName', + $count: true, + $orderby: 'displayName', + $top: 999, + }, + }} + /> + + OneDrive Access + {deleteUser && ( When a user is deleted, their OneDrive is retained for 30 days by default unless @@ -335,7 +390,7 @@ export const CippWizardOffboarding = (props) => { { disabled={!!deleteUser} /> + + Out of Office + From 89645f91f0ad569660923ed1ed0c8ef9919973b7 Mon Sep 17 00:00:00 2001 From: Brian Simpson <50429915+bmsimp@users.noreply.github.com> Date: Tue, 18 Aug 2026 07:44:07 -0500 Subject: [PATCH 149/226] docs: cover GitHub token fallback and release notes defaults Documents the behaviour introduced in 4878da5f and c7ec759e. - GitHub integration: new "When the Token Stops Working" section covering the read fallback to the shared token, writes still failing, and the log entry each rejection produces. Test step now describes the failure result as well as success. - Release notes notification: the dialog opens on the newest feature release rather than the running hotfix tag, and a failed refresh serves the cached release list instead of erroring. - Release notes notification: new section for the phone layout from 9a0aac50b and 9a668dec2. Co-Authored-By: Claude Opus 5 (1M context) --- docs/user-documentation/cipp/integrations/github.md | 12 ++++++++++++ .../shared-features/release-notes-notification.md | 12 ++++++++++-- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/docs/user-documentation/cipp/integrations/github.md b/docs/user-documentation/cipp/integrations/github.md index ae717e4f2d..250c7d0a3e 100644 --- a/docs/user-documentation/cipp/integrations/github.md +++ b/docs/user-documentation/cipp/integrations/github.md @@ -53,9 +53,21 @@ Paste the token into **GitHub Personal Access Token**, then select **Submit** an ### Test Select **Test**. A successful result names the GitHub account CIPP authenticated as, and lists the scopes attached to the token, so you can confirm at a glance whether you have granted enough for what you plan to do. + +If GitHub rejects the token, the result says so and repeats the reason GitHub gave, so an expired or revoked token is obvious here rather than appearing to work. {% endstep %} {% endstepper %} +## When the Token Stops Working + +Personal Access Tokens expire, get revoked, and run into rate limits. When GitHub rejects the token you configured, CIPP falls back to the built-in shared token for anything that only reads, so browsing and importing from public community repositories carry on working. Anything that writes, such as publishing a template or creating a repository, keeps failing until the token is replaced. + +Every rejection is written to the [logs](../logs/ "mention") as a **GitHub** entry naming the status GitHub returned, so a token that has quietly expired shows up there before anyone reports a failure. + +{% hint style="info" %} +**Test** is the exception to the fallback. It always reports on the token you configured, never on the shared one, so it stays a reliable check on the token even while reads are quietly succeeding through the fallback. +{% endhint %} + ## What the Integration Enables | Capability | Token requirement | diff --git a/docs/user-documentation/shared-features/release-notes-notification.md b/docs/user-documentation/shared-features/release-notes-notification.md index 05534abf78..478a02416a 100644 --- a/docs/user-documentation/shared-features/release-notes-notification.md +++ b/docs/user-documentation/shared-features/release-notes-notification.md @@ -6,12 +6,14 @@ You can also open them at any time from **View release notes** in the account me ## Reading the Notes -The dialog opens on the notes for the version you are running. A **Release** dropdown at the top lets you select any earlier release and read its notes instead, and the heading updates to show which release you are viewing. +The dialog opens on the notes for the most recent feature release, the one whose version ends in `.0`. Hotfix and maintenance releases list only what changed since that feature release, so leading with the feature notes gives you the fuller picture of what is new. + +A **Release** dropdown at the top lets you select any other release and read its notes instead, including the hotfixes, and the heading updates to show which release you are viewing. **Expand** widens the dialog to fill more of the screen, which helps with longer release notes. **Shrink** returns it to its normal size. {% hint style="info" %} -The list of releases is fetched from GitHub. If it cannot be reached, the dialog says so and still shows the notes for your current version. +The list of releases is fetched from GitHub and cached. If GitHub cannot be reached, the last list that was fetched successfully is shown instead, so the notes stay readable even when the newest release is missing from the dropdown. {% endhint %} ## Dismissing the Notification @@ -31,4 +33,10 @@ These choices are stored in the browser you are using, so they apply to that bro Choosing **Remind me next time** or **Don't show until next release** also clears a previous **Don't show again**, so the notification is easy to reinstate without hunting through browser settings. {% endhint %} +## On a Phone + +The dialog fills the screen on a phone, so there is no **Expand** control. The heading doubles as the release picker: select it to open the list of releases and choose the one you want to read. + +**Don't show until next release** stays at the foot of the dialog, and **View release notes on GitHub** and **Don't show again** move behind the **More options** button beside it. Closing the dialog with the close icon, the back gesture, or by selecting outside it, does the same as **Remind me next time**. + {% include "../../../.gitbook/includes/feature-request.md" %} From f45e502c87855c795df8c4fc4e53a88c7664a647 Mon Sep 17 00:00:00 2001 From: Brian Simpson <50429915+bmsimp@users.noreply.github.com> Date: Tue, 18 Aug 2026 07:58:59 -0500 Subject: [PATCH 150/226] docs: cover custom role simple mode and role impersonation Documents the feature merged in 39767dac, and corrects the permission category guidance added for 888e38c5, which only holds for per-category roles now that pattern roles expand at evaluation time. - roles.md and cipp-roles/add.md: the Simple (patterns) and Advanced (per-category) modes, pattern syntax, the built-in role template, the live result panel, and the fact that saving in Simple mode replaces the category grid. - how-cipp-evaluates-roles.md: wildcard roles pick up newly added categories on their own, per-category roles do not. New section on testing a role with impersonation, including the single-role-in- isolation caveat. - cipp-roles/README.md: Impersonate Role table action, an impersonation section, and why More Info now reads "Effective Permissions (at last save)". - super-admin/README.md: pointer to impersonation, which is superadmin only but lives outside that menu. Co-Authored-By: Claude Opus 5 (1M context) --- .../resources/how-cipp-evaluates-roles.md | 26 +++++++++++++++++++ docs/setup/setting-up-cipp/roles.md | 17 +++++++++++- .../authentication/cipp-roles/README.md | 16 +++++++++++- .../advanced/authentication/cipp-roles/add.md | 8 +++++- .../cipp/advanced/super-admin/README.md | 4 +++ 5 files changed, 68 insertions(+), 3 deletions(-) diff --git a/docs/setup/resources/how-cipp-evaluates-roles.md b/docs/setup/resources/how-cipp-evaluates-roles.md index cbe78eb99d..e9b1a1a707 100644 --- a/docs/setup/resources/how-cipp-evaluates-roles.md +++ b/docs/setup/resources/how-cipp-evaluates-roles.md @@ -112,6 +112,32 @@ She is **not** in any group that maps to `editor`, `readonly`, `admin`, or `supe If Priya were also added to a second custom role granting **Endpoint: Read**, her access would be the **union** of the two: Reports read, Identity read, and Endpoint read. +## When CIPP adds new permission categories + +CIPP updates occasionally add a permission category, or split an existing one so that a capability can be granted on its own rather than bundled with everything else in its area. The categories listed under **API Permissions** on a custom role are therefore not fixed for the life of an instance. + +Existing custom roles are not rewritten when this happens. They keep every permission they already grant, and the capability that moved into the new category becomes a deny, because any category that has not been set is treated as `None`. Users notice this as one part of their access disappearing while the rest of the role carries on working, which tends to be reported as a fault rather than as a permission change. + +How much this affects you depends on how the role was built. A role defined in **Simple** mode is a set of patterns, expanded against the current permission list every time it is evaluated, so a wildcard such as `Tenant.*` covers a newly added category in that area without being touched. A role defined in **Advanced** mode grants only the categories it names, as does a pattern written without a wildcard. + +After an update, open each custom role on the [cipp-roles](../../user-documentation/cipp/advanced/authentication/cipp-roles/ "mention") page and look through the **API Permissions** list for categories you have not set. Setting a new category to `Read` or `Read/Write` returns the capability to the role, and moving the role to Simple mode with a wildcard pattern avoids meeting the same problem at the next update. + +{% hint style="info" %} +Users holding `admin` or `superadmin` are unaffected, as those roles bypass custom roles and receive new permissions automatically. +{% endhint %} + +## Testing a role with impersonation + +Reasoning about a role on paper is one thing, seeing it is another. Super admins can select **Impersonate Role** against any role on the [cipp-roles](../../user-documentation/cipp/advanced/authentication/cipp-roles/ "mention") page. CIPP reloads and behaves as though they hold that role and nothing else, including its tenant restrictions, and a banner across the top of the page names the role until **Exit impersonation** is selected. + +The swap is enforced by the API rather than only by the interface, so anything the role cannot reach fails exactly as it would for a real user holding it. It can only ever narrow access, because the request is honoured only for genuine super admins, and the `superadmin` role itself cannot be impersonated. If the impersonated role cannot load CIPP at all, the banner still appears on the access denied page, so there is always a way back. + +{% hint style="warning" %} +Impersonation shows a **single role in isolation**, which is not the same as showing a user. Someone holding a base role alongside one or more custom roles has their access shaped by the combinations described above, so their effective permissions can differ from what impersonation displays. IP restrictions are not simulated. +{% endhint %} + +Starting impersonation is written to the logs and attributed to the super admin's real account, not to the role being impersonated. + ## Quick reference | The user holds…​ | What they get | diff --git a/docs/setup/setting-up-cipp/roles.md b/docs/setup/setting-up-cipp/roles.md index e356b4b8bd..62a018acac 100644 --- a/docs/setup/setting-up-cipp/roles.md +++ b/docs/setup/setting-up-cipp/roles.md @@ -114,10 +114,25 @@ Optionally select the CIPP endpoints that you want to block for the role. For ex {% step %} ### API Permissions -Select the API permission from the listed categories and choose from None, Read or Read/Write. +Custom roles define their permissions in one of two ways, chosen with the **Simple (patterns)** and **Advanced (per-category)** toggle. A new role opens in Simple mode, and a role you open for editing opens in Advanced mode. + +**Simple (patterns)** works the way CIPP's built-in roles do. An **Include** list grants everything matching its patterns, an **Exclude** list then denies anything matching its own, and exclusions always win. + +* Patterns match permission names in the form `Category.Object.Level`, where the level is `Read` or `ReadWrite`, and `*` matches anything. `Identity.*.Read` grants read access to everything under Identity, and `*` grants everything. +* A pattern holds up to three dot-separated segments of letters, numbers and `*`. Anything else is reported and dropped rather than saved. +* **Start from a built-in role** replaces both lists with that role's own patterns, which you are then free to edit. +* The **Live result** panel counts what each pattern matches and flags any pattern matching nothing, so a typo does not pass unnoticed. +* Patterns are expanded every time permissions are evaluated, so a role built on wildcards picks up endpoints added in later CIPP releases on its own. + +**Advanced (per-category)** is the category list, where each category is set to None, Read or Read/Write. * To find out which API endpoints are affected by these selections, click on the Info button. * Not defining a category is the same as setting None. Be sure that you define all base role permissions you want to apply to the user. +* A role defined this way grants only the categories that existed when you saved it, so review it after a CIPP update. See [how-cipp-evaluates-roles.md](../resources/how-cipp-evaluates-roles.md "mention"). + +{% hint style="warning" %} +The two modes are not merged. Saving in Simple mode replaces the role's permissions with the patterns on screen, and CIPP warns you when the categories and the patterns have diverged. +{% endhint %} {% endstep %} {% step %} diff --git a/docs/user-documentation/cipp/advanced/authentication/cipp-roles/README.md b/docs/user-documentation/cipp/advanced/authentication/cipp-roles/README.md index f8f92e6266..4d51ae183d 100644 --- a/docs/user-documentation/cipp/advanced/authentication/cipp-roles/README.md +++ b/docs/user-documentation/cipp/advanced/authentication/cipp-roles/README.md @@ -14,6 +14,20 @@ A page for super admins to manage the custom roles deployed to their CIPP instan ## Table Actions -
    ActionDescriptionBulk Action Available
    EditAllows you to edit the custom role.false
    CloneAllows you to use an existing custom role to use as a starting point for a new roletrue
    DeleteDeletes the selected role(s)true
    More InfoOpens the Extended Info flyout with the full details for the selected row.false
    +
    ActionDescriptionBulk Action Available
    Impersonate RoleReloads CIPP as though you hold only this role, so you can see what it can reach. Shown to super admins only, and not offered on the superadmin role.false
    EditAllows you to edit the custom role.false
    CloneAllows you to use an existing custom role to use as a starting point for a new roletrue
    DeleteDeletes the selected role(s)true
    More InfoOpens the Extended Info flyout with the full details for the selected row.false
    + +## Reading a Role's Permissions + +**More Info** lists a role's **Permission Rules** where it has them, showing include patterns in green and exclude patterns in red. The permission list beneath is labelled **Effective Permissions (at last save)**, because a role built from patterns is expanded against the current permission list each time it is evaluated, and the stored list only records what those patterns matched when the role was saved. + +## Impersonating a Role + +Super admins can check a role by working in it for a moment rather than reading its permission list. **Impersonate Role** reloads CIPP with only that role's permissions and tenant scope in force, and a banner across the top of every page names the role until **Exit impersonation** is selected. + +Access is enforced by the API for the duration, so anything the role cannot do fails exactly as it would for a real user. Impersonation can only reduce what you reach: the `superadmin` role cannot be impersonated, and the action itself disappears while impersonating, so one role cannot be nested inside another. + +{% hint style="warning" %} +This shows a single role on its own. Real users often hold a base role alongside one or more custom roles, where the custom roles narrow the base role rather than adding to it, so their access can differ from what you see here. IP restrictions are not simulated. See [how-cipp-evaluates-roles.md](../../../../../setup/resources/how-cipp-evaluates-roles.md "mention") for how roles combine. +{% endhint %} {% include "../../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/cipp/advanced/authentication/cipp-roles/add.md b/docs/user-documentation/cipp/advanced/authentication/cipp-roles/add.md index cc9b55bfc6..2d713a2ea3 100644 --- a/docs/user-documentation/cipp/advanced/authentication/cipp-roles/add.md +++ b/docs/user-documentation/cipp/advanced/authentication/cipp-roles/add.md @@ -30,10 +30,16 @@ You can get more granular with your permissions to block specific CIPP API endpo {% step %} ### Set API Permissions -Using the categories listed, select whether the custom role will have `None`, `Read`, or `Read/Write` access to each category of permissions. Use the Information icon next to each category to display the CIPP API endpoints included in each category. +Permissions are defined in one of two modes, chosen with the **Simple (patterns)** and **Advanced (per-category)** toggle. A new role opens in Simple mode. + +**Simple (patterns)** takes an **Include** list of patterns that grant access and an **Exclude** list that denies anything matching, with exclusions always winning, the same arrangement the built-in roles use. Patterns match permission names in the form `Category.Object.Level` and `*` matches anything, so `Identity.*.Read` grants read access to everything under Identity. **Start from a built-in role** fills both lists with an existing role's patterns as a starting point, and the **Live result** panel shows how many permissions each pattern matches so an ineffective pattern is easy to spot. + +**Advanced (per-category)** lists the categories individually, where you select whether the custom role will have `None`, `Read`, or `Read/Write` access to each. Use the Information icon next to each category to display the CIPP API endpoints included in each category. {% hint style="warning" %} Note that when creating a custom role to layer with the base role, any permission that you do not define will be evaluated as if you had selected `None`. If you want to preserve the functionality of the base role, be sure to select and option for every category. + +Saving in Simple mode replaces the role's permissions with the patterns on screen, so the two modes are not combined. {% endhint %} {% endstep %} {% endstepper %} diff --git a/docs/user-documentation/cipp/advanced/super-admin/README.md b/docs/user-documentation/cipp/advanced/super-admin/README.md index 9c404b7014..66a16d4f26 100644 --- a/docs/user-documentation/cipp/advanced/super-admin/README.md +++ b/docs/user-documentation/cipp/advanced/super-admin/README.md @@ -10,4 +10,8 @@ As of version 8.0, users only need the \`superadmin\` role in order to access th Note that it may take some time for the role change to take effect. {% endhint %} +{% hint style="info" %} +Not every superadmin capability lives in this menu. Role impersonation, which reloads CIPP as though you hold only a chosen role so you can see what that role can reach, is offered against each role on the [cipp-roles](../authentication/cipp-roles/ "mention") page. +{% endhint %} + {% include "../../../../../.gitbook/includes/feature-request.md" %} From 2dc0c68a0768b752ecad1d2d2d3e2b357d913567 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Tue, 18 Aug 2026 21:04:11 +0800 Subject: [PATCH 151/226] fix(orchestration): pass parent run lineage when queueing Craft child runs Child orchestrations queued from inside a running activity (e.g. the per-tenant DomainAnalyser runs spawned by Push-DomainAnalyserTenant) arrived at Craft with no parent run: the bridge's ambient context read is always null on the reused pipeline thread, so the parent run finalized and dispatched its PostExecution while its children were still running. Read RunName from the stamped $global:CraftOperationContext - the same carrier the priority default already uses - and pass it explicitly as the new run's parent, so Craft holds the parent's finalize until the child completes. Probe the bridge method's arity first: an older Craft runtime only exposes the 6-parameter method, and passing 7 arguments to it would throw instead of degrading. --- backend/Config/openapi.json | 146 ++++++++++++++++++ .../Start-CIPPOrchestrator.ps1 | 56 +++++-- 2 files changed, 186 insertions(+), 16 deletions(-) diff --git a/backend/Config/openapi.json b/backend/Config/openapi.json index 0ca8fe2e00..43f9cacb2c 100644 --- a/backend/Config/openapi.json +++ b/backend/Config/openapi.json @@ -23064,6 +23064,77 @@ "x-cipp-role": "Security.Incident.Read" } }, + "/api/ExecIRMConfiguration": { + "post": { + "summary": "ExecIRMConfiguration", + "operationId": "ExecIRMConfiguration", + "tags": [ + "Email-Exchange > Tools" + ], + "description": "Enables or disables Microsoft Purview Message Encryption for a tenant by setting AzureRMSLicensingEnabled, or runs Test-IRMConfiguration to verify that encryption and decryption work end to end.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "Action": { + "type": "string", + "enum": [ + "Set", + "Test" + ] + }, + "AzureRMSLicensingEnabled": { + "type": "string" + }, + "Recipient": { + "type": "string" + }, + "Sender": { + "type": "string" + }, + "tenantFilter": { + "type": "string" + } + }, + "required": [ + "tenantFilter" + ] + } + } + } + }, + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StandardResults" + } + } + } + }, + "401": { + "description": "Unauthorized - invalid or missing bearer token" + }, + "403": { + "description": "Forbidden - caller lacks the required RBAC role" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "x-cipp-role": "Exchange.Mailbox.ReadWrite" + } + }, "/api/ExecJITAdmin": { "post": { "summary": "ExecJITAdmin", @@ -47052,6 +47123,81 @@ "x-cipp-any-tenant": true } }, + "/api/ListIRMConfiguration": { + "get": { + "summary": "ListIRMConfiguration", + "operationId": "ListIRMConfiguration", + "tags": [ + "Email-Exchange > Tools" + ], + "description": "Lists the Information Rights Management (IRM) configuration for a tenant. Used to check whether Microsoft Purview Message Encryption is active and whether an on-premises AD RMS deployment still has to be migrated to Azure RMS first.", + "parameters": [ + { + "$ref": "#/components/parameters/tenantFilter" + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "description": "Derived from the fields the endpoint selects onto each record. The response may carry more; these are the ones known to exist.", + "properties": { + "AdRmsDetected": { + "x-cipp-field-source": "backend" + }, + "AzureRMSLicensingEnabled": { + "x-cipp-field-source": "backend" + }, + "ExternalLicensingEnabled": { + "x-cipp-field-source": "backend" + }, + "InternalLicensingEnabled": { + "x-cipp-field-source": "backend" + }, + "JournalReportDecryptionEnabled": { + "x-cipp-field-source": "backend" + }, + "LicensingLocation": { + "x-cipp-field-source": "backend" + }, + "MessageEncryptionEnabled": { + "x-cipp-field-source": "backend" + }, + "SimplifiedClientAccessEnabled": { + "x-cipp-field-source": "backend" + }, + "TransportDecryptionSetting": { + "x-cipp-field-source": "backend" + } + } + } + } + } + } + }, + "401": { + "description": "Unauthorized - invalid or missing bearer token" + }, + "403": { + "description": "Forbidden - caller lacks the required RBAC role" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "x-cipp-role": "Exchange.Mailbox.Read" + } + }, "/api/ListJITAdmin": { "get": { "summary": "ListJITAdmin", diff --git a/backend/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-CIPPOrchestrator.ps1 b/backend/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-CIPPOrchestrator.ps1 index d362cb9cad..242ba6b7ea 100644 --- a/backend/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-CIPPOrchestrator.ps1 +++ b/backend/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-CIPPOrchestrator.ps1 @@ -100,13 +100,18 @@ function Start-CIPPOrchestrator { throw } + # $CraftOperationContext is stamped into the global scope per invocation by the Craft + # worker — the pipeline thread never sees OperationContext.Current directly, and on an + # older Craft runtime the variable simply does not exist, so this read degrades to $null. + # Both the priority default and the parent-run lineage below come from it. + $OpContext = Get-Variable -Name 'CraftOperationContext' -Scope Global -ValueOnly -ErrorAction SilentlyContinue + # The queue claims strictly by priority bucket (P00 first), so this decides who runs # when the limiter is saturated. Resolution order: # 1. Explicit Priority on the InputObject (range-guarded: the store clamps into 0-99 # buckets, so a stray negative would silently land in the critical P00 bucket). - # 2. The enclosing run's priority (ambient, set by Craft for orchestrator activities and - # post-exec jobs) — a child run belongs to its parent's band, so a baseline run's - # follow-up no longer drops back to the default. + # 2. The enclosing run's priority (from the stamped context) — a child run belongs to + # its parent's band, so a baseline run's follow-up no longer drops back to the default. # 3. P2 for HTTP-triggered orchestrations — user-initiated work must not queue behind # background fan-outs. # 4. The historical default 4 (timers and other background starters). @@ -116,25 +121,44 @@ function Start-CIPPOrchestrator { if ($Priority -lt 0 -or $Priority -gt 99) { $Priority = $null } } if ($null -eq $Priority) { - # $CraftOperationContext is stamped into the global scope per invocation by the Craft - # worker — the pipeline thread never sees OperationContext.Current directly, and on an - # older Craft runtime the variable simply does not exist, so this read degrades to $null. - $OpContext = Get-Variable -Name 'CraftOperationContext' -Scope Global -ValueOnly -ErrorAction SilentlyContinue $Priority = if ($null -ne $OpContext) { $OpContext.PSObject.Properties['Priority'].Value } if ($null -eq $Priority) { $Priority = if ($null -ne $OpContext -and $OpContext.Category -eq 'HTTP') { 2 } else { 4 } } $Priority = [int]$Priority } - Write-Information "Craft: Queuing orchestrator '$OrchestratorName' ($TaskCount tasks, P$Priority$(if ($PostExecFunctionName) { ", PostExec: $PostExecFunctionName" }))" - [Craft.Services.OrchestratorBridge]::QueueOrchestrationFromFile( - $OrchestratorName, - $BatchPath, - $Priority, - $PostExecFunctionName, - $PostExecParametersJson, - $InputObject.Reference - ) + + # Lineage: pass the enclosing run explicitly as the new run's parent, so Craft holds the + # parent's finalize (and PostExecution) until this child completes. The bridge cannot see + # the parent on its own — its ambient context read is null on the pipeline thread, which + # is exactly where this call runs. + $ParentRunName = if ($null -ne $OpContext) { $OpContext.PSObject.Properties['RunName'].Value } + + Write-Information "Craft: Queuing orchestrator '$OrchestratorName' ($TaskCount tasks, P$Priority$(if ($PostExecFunctionName) { ", PostExec: $PostExecFunctionName" })$(if ($ParentRunName) { ", Parent: $ParentRunName" }))" + # An older Craft runtime exposes the 6-parameter method only; probing the arity keeps this + # wrapper deployable against both. Passing 7 arguments to the old method would not degrade — + # it would throw a method-resolution error and fail the orchestration outright. + $QueueMethod = [Craft.Services.OrchestratorBridge].GetMethod('QueueOrchestrationFromFile') + if ($QueueMethod.GetParameters().Count -ge 7) { + [Craft.Services.OrchestratorBridge]::QueueOrchestrationFromFile( + $OrchestratorName, + $BatchPath, + $Priority, + $PostExecFunctionName, + $PostExecParametersJson, + $InputObject.Reference, + $ParentRunName + ) + } else { + [Craft.Services.OrchestratorBridge]::QueueOrchestrationFromFile( + $OrchestratorName, + $BatchPath, + $Priority, + $PostExecFunctionName, + $PostExecParametersJson, + $InputObject.Reference + ) + } return "Craft-$OrchestratorName" } From 0831a04d7a867ac8bcab61fa5eff04941b0ebe48 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Tue, 18 Aug 2026 21:12:59 +0800 Subject: [PATCH 152/226] feat(identity): serve guest lifecycle dashboard from the report cache The guest users page now reads cached data from the reporting database by default, with a toggle back to live Graph and a sync action. The Guests cache keeps the full beta property set and sponsors, and now merges in signInActivity where the tenant is licensed for it, stamping each row with signInLogsCapable so readers can tell a guest who never signed in apart from a tenant without sign-in data. ListGuestUsers gains a UseReportDB branch via Get-CIPPGuestUsersReport, AllTenants support through the cache, and a sponsors column. --- backend/Config/openapi.json | 14 ++- .../Public/Get-CIPPGuestUsersReport.ps1 | 60 ++++++++++++ .../Public/DBCache/Set-CIPPDBCacheGuests.ps1 | 18 ++++ .../Users/Invoke-ListGuestUsers.ps1 | 53 ++++++---- .../Endpoint/Invoke-ListGuestUsers.Tests.ps1 | 98 ++++++++++++++++++- .../administration/guest-users/index.js | 49 +++++++--- 6 files changed, 256 insertions(+), 36 deletions(-) create mode 100644 backend/Modules/CIPPCore/Public/Get-CIPPGuestUsersReport.ps1 diff --git a/backend/Config/openapi.json b/backend/Config/openapi.json index 43f9cacb2c..03b5cc6a5a 100644 --- a/backend/Config/openapi.json +++ b/backend/Config/openapi.json @@ -46111,7 +46111,7 @@ "tags": [ "Identity > Administration > Users" ], - "description": "Lists all guest accounts in a tenant with a computed lifecycle status (Active, Pending Acceptance, Stale, Never Signed In or Disabled) based on the invitation state and sign-in activity from the Graph beta API.", + "description": "Lists all guest accounts in a tenant with a computed lifecycle status (Active, Pending Acceptance, Stale, Never Signed In or Disabled) based on the invitation state and sign-in activity. Supports UseReportDB=true to serve cached data from the reporting database; AllTenants always uses the cache.", "parameters": [ { "name": "staleDays", @@ -46124,6 +46124,15 @@ }, { "$ref": "#/components/parameters/tenantFilter" + }, + { + "name": "UseReportDB", + "in": "query", + "description": "Serve from the reporting database cache instead of live Graph. AllTenants always uses the cache.", + "required": false, + "schema": { + "type": "boolean" + } } ], "responses": { @@ -46183,6 +46192,9 @@ "sourceDomain": { "x-cipp-field-source": "backend,frontend" }, + "sponsors": { + "x-cipp-field-source": "backend" + }, "status": { "x-cipp-field-source": "backend,frontend" }, diff --git a/backend/Modules/CIPPCore/Public/Get-CIPPGuestUsersReport.ps1 b/backend/Modules/CIPPCore/Public/Get-CIPPGuestUsersReport.ps1 new file mode 100644 index 0000000000..bab411a0b6 --- /dev/null +++ b/backend/Modules/CIPPCore/Public/Get-CIPPGuestUsersReport.ps1 @@ -0,0 +1,60 @@ +function Get-CIPPGuestUsersReport { + <# + .SYNOPSIS + Reads cached guest users from the CIPP Reporting database + + .DESCRIPTION + Returns the raw cached guest user objects for a tenant (or all tenants), with + CacheTimestamp added, ready for the guest lifecycle classification in + Invoke-ListGuestUsers. + + .PARAMETER TenantFilter + The tenant to read cached guest users for, or 'AllTenants' for all tenants + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter + ) + + if ($TenantFilter -eq 'AllTenants') { + $AnyItems = Get-CIPPDbItem -TenantFilter 'allTenants' -Type 'Guests' + $Tenants = @($AnyItems | Where-Object { $_.RowKey -notlike '*-Count' } | Select-Object -ExpandProperty PartitionKey -Unique) + $TenantList = Get-Tenants -IncludeErrors + $Tenants = $Tenants | Where-Object { $TenantList.defaultDomainName -contains $_ } + + $AllResults = [System.Collections.Generic.List[PSCustomObject]]::new() + foreach ($Tenant in $Tenants) { + try { + $TenantResults = Get-CIPPGuestUsersReport -TenantFilter $Tenant + foreach ($Result in $TenantResults) { + $Result | Add-Member -NotePropertyName 'Tenant' -NotePropertyValue $Tenant -Force + $AllResults.Add($Result) + } + } catch { + Write-LogMessage -API 'GuestUsersReport' -tenant $Tenant -message "Failed to get guest users report: $($_.Exception.Message)" -sev Warning + } + } + return $AllResults + } + + $Items = Get-CIPPDbItem -TenantFilter $TenantFilter -Type 'Guests' | Where-Object { $_.RowKey -notlike '*-Count' } + if (-not $Items) { + throw "No guest user data found in reporting database for $TenantFilter. Sync the report data first." + } + + $CacheTimestamp = ($Items | Where-Object { $_.Timestamp } | Sort-Object Timestamp -Descending | Select-Object -First 1).Timestamp + + $Results = [System.Collections.Generic.List[PSCustomObject]]::new() + foreach ($Item in $Items) { + try { + $Guest = $Item.Data | ConvertFrom-Json -Depth 10 -ErrorAction Stop + $Guest | Add-Member -NotePropertyName 'CacheTimestamp' -NotePropertyValue $CacheTimestamp -Force + $Results.Add($Guest) + } catch { + Write-LogMessage -API 'GuestUsersReport' -tenant $TenantFilter -message "Failed to parse guest user item: $($_.Exception.Message)" -sev Warning + } + } + + return ($Results | Sort-Object displayName) +} diff --git a/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheGuests.ps1 b/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheGuests.ps1 index 42fadf461a..a9345b5979 100644 --- a/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheGuests.ps1 +++ b/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheGuests.ps1 @@ -19,7 +19,25 @@ function Set-CIPPDBCacheGuests { try { Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching guest users' -sev Debug + # signInActivity is an expensive property Graph only returns when explicitly selected, + # and only on tenants with an Entra ID P1 license. Fetch it in a separate query and + # merge, so the main query keeps returning the full beta default property set. Each + # row is stamped with signInLogsCapable so cache readers can tell a guest who never + # signed in apart from a tenant whose sign-in data is unavailable. + $SignInLogsCapable = Test-CIPPStandardLicense -StandardName 'GuestLifecycle' -TenantFilter $TenantFilter -Preset Entra -SkipLog + $SignInActivityById = @{} + if ($SignInLogsCapable) { + # Graph caps the page size lower when signInActivity is selected + $SignInRows = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/users?`$filter=userType eq 'Guest'&`$select=id,signInActivity&`$count=true&`$top=500" -tenantid $TenantFilter -ComplexFilter + foreach ($Row in $SignInRows) { + if ($Row.id) { $SignInActivityById[$Row.id] = $Row.signInActivity } + } + } + New-GraphGetRequest -uri "https://graph.microsoft.com/beta/users?`$filter=userType eq 'Guest'&`$expand=sponsors&`$top=999" -tenantid $TenantFilter -Stream | + Select-Object -Property *, + @{ Name = 'signInActivity'; Expression = { $SignInActivityById[$_.id] } }, + @{ Name = 'signInLogsCapable'; Expression = { $SignInLogsCapable } } | Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'Guests' -AddCount Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached guest users successfully' -sev Debug diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListGuestUsers.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListGuestUsers.ps1 index d29a86ee0c..0255de67a6 100644 --- a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListGuestUsers.ps1 +++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListGuestUsers.ps1 @@ -7,7 +7,7 @@ function Invoke-ListGuestUsers { .SYNOPSIS List guest users with lifecycle status .DESCRIPTION - Lists all guest accounts in a tenant with a computed lifecycle status (Active, Pending Acceptance, Stale, Never Signed In or Disabled) based on the invitation state and sign-in activity from the Graph beta API. + Lists all guest accounts in a tenant with a computed lifecycle status (Active, Pending Acceptance, Stale, Never Signed In or Disabled) based on the invitation state and sign-in activity. Supports UseReportDB=true to serve cached data from the reporting database; AllTenants always uses the cache. #> [CmdletBinding()] param($Request, $TriggerMetadata) @@ -15,29 +15,40 @@ function Invoke-ListGuestUsers { $APIName = $Request.Params.CIPPEndpoint $Headers = $Request.Headers - # The tenant to list guest users for + # The tenant to list guest users for, or AllTenants for every tenant (served from the cache) $TenantFilter = $Request.Query.tenantFilter # Days without any sign-in before an enabled guest is considered stale. Defaults to 90. $StaleDays = $Request.Query.staleDays ? [int]$Request.Query.staleDays : 90 + # Serve from the reporting database cache instead of live Graph. AllTenants always uses the cache. + $UseReportDB = $Request.Query.UseReportDB -eq $true try { - # signInActivity can only be requested on tenants with an Entra ID P1 license - Graph - # rejects the whole query on unlicensed tenants, so fall back to listing without - # sign-in data there and compute status from the invitation state alone. - $SignInLogsCapable = Test-CIPPStandardLicense -StandardName 'GuestLifecycle' -TenantFilter $TenantFilter -Preset Entra -SkipLog + if ($TenantFilter -eq 'AllTenants' -or $UseReportDB) { + # Cached rows carry a per-row signInLogsCapable stamp written by the cache job, + # so sign-in availability is judged per row below. + $SignInLogsCapable = $null + $GuestUsers = Get-CIPPGuestUsersReport -TenantFilter $TenantFilter + } else { + # signInActivity can only be requested on tenants with an Entra ID P1 license - Graph + # rejects the whole query on unlicensed tenants, so fall back to listing without + # sign-in data there and compute status from the invitation state alone. + $SignInLogsCapable = Test-CIPPStandardLicense -StandardName 'GuestLifecycle' -TenantFilter $TenantFilter -Preset Entra -SkipLog - $SelectFields = @( - 'id', 'displayName', 'mail', 'userPrincipalName', 'createdDateTime', - 'accountEnabled', 'externalUserState', 'externalUserStateChangeDateTime' - ) - if ($SignInLogsCapable) { $SelectFields += 'signInActivity' } - # Graph caps the page size lower when signInActivity is selected - $Top = $SignInLogsCapable ? 500 : 999 - $Uri = "https://graph.microsoft.com/beta/users?`$filter=userType eq 'Guest'&`$select=$($SelectFields -join ',')&`$count=true&`$top=$Top" - $GuestUsers = New-GraphGetRequest -uri $Uri -tenantid $TenantFilter -ComplexFilter + $SelectFields = @( + 'id', 'displayName', 'mail', 'userPrincipalName', 'createdDateTime', + 'accountEnabled', 'externalUserState', 'externalUserStateChangeDateTime' + ) + if ($SignInLogsCapable) { $SelectFields += 'signInActivity' } + # Graph caps the page size lower when signInActivity is selected + $Top = $SignInLogsCapable ? 500 : 999 + $Uri = "https://graph.microsoft.com/beta/users?`$filter=userType eq 'Guest'&`$select=$($SelectFields -join ',')&`$count=true&`$top=$Top" + $GuestUsers = New-GraphGetRequest -uri $Uri -tenantid $TenantFilter -ComplexFilter + } $Now = Get-Date $GraphRequest = foreach ($Guest in $GuestUsers) { + $RowCapable = ($null -eq $SignInLogsCapable) ? ($Guest.signInLogsCapable -eq $true) : $SignInLogsCapable + # Last sign-in is the most recent of the three signInActivity fields. # lastSuccessfulSignInDateTime can run ahead of the other two, so leaving it # out would report recently-active guests as stale. @@ -58,7 +69,7 @@ function Invoke-ListGuestUsers { 'Disabled' } elseif ($Guest.externalUserState -eq 'PendingAcceptance') { 'Pending Acceptance' - } elseif (-not $SignInLogsCapable) { + } elseif (-not $RowCapable) { 'Unknown' } elseif (-not $LastSignIn) { 'Never Signed In' @@ -68,7 +79,7 @@ function Invoke-ListGuestUsers { 'Active' } - [PSCustomObject]@{ + $Row = [PSCustomObject]@{ id = $Guest.id displayName = $Guest.displayName mail = $Guest.mail @@ -84,7 +95,15 @@ function Invoke-ListGuestUsers { lastNonInteractiveSignInDateTime = $Guest.signInActivity.lastNonInteractiveSignInDateTime lastSuccessfulSignInDateTime = $Guest.signInActivity.lastSuccessfulSignInDateTime daysSinceSignIn = $DaysSinceSignIn + sponsors = $Guest.sponsors ? (@($Guest.sponsors | ForEach-Object { $_.displayName ?? $_.userPrincipalName }) -join ', ') : $null + } + if ($null -ne $Guest.CacheTimestamp) { + $Row | Add-Member -NotePropertyName 'CacheTimestamp' -NotePropertyValue $Guest.CacheTimestamp + } + if ($Guest.Tenant) { + $Row | Add-Member -NotePropertyName 'Tenant' -NotePropertyValue $Guest.Tenant } + $Row } $StatusCode = [System.Net.HttpStatusCode]::OK } catch { diff --git a/backend/Tests/Endpoint/Invoke-ListGuestUsers.Tests.ps1 b/backend/Tests/Endpoint/Invoke-ListGuestUsers.Tests.ps1 index 35daa544d6..7c2346f68d 100644 --- a/backend/Tests/Endpoint/Invoke-ListGuestUsers.Tests.ps1 +++ b/backend/Tests/Endpoint/Invoke-ListGuestUsers.Tests.ps1 @@ -1,6 +1,7 @@ # Pester tests for Invoke-ListGuestUsers # Validates lifecycle status classification, the sign-in date selection, the staleDays -# override, and the fallback for tenants without an Entra ID P1 license. +# override, the fallback for tenants without an Entra ID P1 license, and the +# reporting-database cache branch. BeforeAll { # Resolve by name under Modules/ so the test survives the function moving between modules. @@ -19,16 +20,19 @@ BeforeAll { function Get-CippException { param($Exception) @{ NormalizedError = $Exception } } function Test-CIPPStandardLicense { param($StandardName, $TenantFilter, $RequiredCapabilities, $Preset, [switch]$SkipLog) } function New-GraphGetRequest { param($uri, $tenantid, [switch]$ComplexFilter) } + function Get-CIPPGuestUsersReport { param($TenantFilter) } function Write-LogMessage { param($headers, $API, $tenant, $message, $Sev, $LogData) } . $FunctionPath function New-GuestRequest { param([hashtable]$Query = @{}) + $Merged = @{ tenantFilter = 'contoso.onmicrosoft.com' } + foreach ($Key in $Query.Keys) { $Merged[$Key] = $Query[$Key] } [pscustomobject]@{ Params = @{ CIPPEndpoint = 'ListGuestUsers' } Headers = @{ Authorization = 'token' } - Query = [pscustomobject](@{ tenantFilter = 'contoso.onmicrosoft.com' } + $Query) + Query = [pscustomobject]$Merged } } } @@ -183,4 +187,94 @@ Describe 'Invoke-ListGuestUsers' { $response.Body[0].Error | Should -Match 'Graph exploded' Should -Invoke Write-LogMessage -Times 1 -ParameterFilter { $Sev -eq 'Error' } } + + It 'serves classified rows from the report cache when UseReportDB is true' { + Mock -CommandName New-GraphGetRequest -MockWith { throw 'live Graph should not be called' } + Mock -CommandName Get-CIPPGuestUsersReport -MockWith { + @( + # Capable tenant, recent sign-in - Active, with sponsors joined for display + [pscustomobject]@{ + id = 'g-active'; displayName = 'Active Guest'; mail = 'active@partner.com' + userPrincipalName = 'active_partner.com#EXT#@contoso.onmicrosoft.com' + createdDateTime = (Get-Date).AddDays(-400).ToString('o'); accountEnabled = $true + externalUserState = 'Accepted'; externalUserStateChangeDateTime = $null + signInActivity = [pscustomobject]@{ + lastSignInDateTime = (Get-Date).AddDays(-4).ToString('o') + lastNonInteractiveSignInDateTime = $null + lastSuccessfulSignInDateTime = $null + } + signInLogsCapable = $true + sponsors = @( + [pscustomobject]@{ displayName = 'Sponsor One' } + [pscustomobject]@{ userPrincipalName = 'two@partner.com' } + ) + CacheTimestamp = '2026-08-18T10:00:00Z' + } + # Capable tenant, no sign-in data - genuinely never signed in + [pscustomobject]@{ + id = 'g-never'; displayName = 'Never Guest'; mail = 'never@partner.com' + userPrincipalName = 'never_partner.com#EXT#@contoso.onmicrosoft.com' + createdDateTime = (Get-Date).AddDays(-200).ToString('o'); accountEnabled = $true + externalUserState = 'Accepted'; externalUserStateChangeDateTime = $null + signInActivity = $null; signInLogsCapable = $true; sponsors = $null + CacheTimestamp = '2026-08-18T10:00:00Z' + } + # Not capable at cache time - sign-in state cannot be known + [pscustomobject]@{ + id = 'g-unknown'; displayName = 'Unknown Guest'; mail = 'u@partner.com' + userPrincipalName = 'u_partner.com#EXT#@contoso.onmicrosoft.com' + createdDateTime = (Get-Date).AddDays(-200).ToString('o'); accountEnabled = $true + externalUserState = 'Accepted'; externalUserStateChangeDateTime = $null + signInActivity = $null; signInLogsCapable = $false; sponsors = $null + CacheTimestamp = '2026-08-18T10:00:00Z' + } + # Legacy cache row without the capability stamp - never guess sign-in state + [pscustomobject]@{ + id = 'g-legacy'; displayName = 'Legacy Guest'; mail = 'l@partner.com' + userPrincipalName = 'l_partner.com#EXT#@contoso.onmicrosoft.com' + createdDateTime = (Get-Date).AddDays(-200).ToString('o'); accountEnabled = $true + externalUserState = 'Accepted'; externalUserStateChangeDateTime = $null + } + ) + } + + $response = Invoke-ListGuestUsers -Request (New-GuestRequest -Query @{ UseReportDB = 'true' }) -TriggerMetadata $null + + $response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::OK) + $ByStatus = @{} + foreach ($Row in $response.Body) { $ByStatus[$Row.id] = $Row } + $ByStatus['g-active'].status | Should -Be 'Active' + $ByStatus['g-active'].sponsors | Should -Be 'Sponsor One, two@partner.com' + $ByStatus['g-active'].CacheTimestamp | Should -Be '2026-08-18T10:00:00Z' + $ByStatus['g-never'].status | Should -Be 'Never Signed In' + $ByStatus['g-unknown'].status | Should -Be 'Unknown' + $ByStatus['g-legacy'].status | Should -Be 'Unknown' + + Should -Invoke Get-CIPPGuestUsersReport -Times 1 -ParameterFilter { $TenantFilter -eq 'contoso.onmicrosoft.com' } + Should -Invoke New-GraphGetRequest -Times 0 + } + + It 'always uses the report cache for AllTenants and passes Tenant through' { + Mock -CommandName New-GraphGetRequest -MockWith { throw 'live Graph should not be called' } + Mock -CommandName Get-CIPPGuestUsersReport -MockWith { + @( + [pscustomobject]@{ + id = 'g-1'; displayName = 'Guest'; mail = 'g@partner.com' + userPrincipalName = 'g_partner.com#EXT#@contoso.onmicrosoft.com' + createdDateTime = (Get-Date).AddDays(-10).ToString('o'); accountEnabled = $true + externalUserState = 'PendingAcceptance'; externalUserStateChangeDateTime = $null + signInLogsCapable = $true + CacheTimestamp = '2026-08-18T10:00:00Z'; Tenant = 'contoso.onmicrosoft.com' + } + ) + } + + $response = Invoke-ListGuestUsers -Request (New-GuestRequest -Query @{ tenantFilter = 'AllTenants' }) -TriggerMetadata $null + + $response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::OK) + $response.Body[0].status | Should -Be 'Pending Acceptance' + $response.Body[0].Tenant | Should -Be 'contoso.onmicrosoft.com' + Should -Invoke Get-CIPPGuestUsersReport -Times 1 -ParameterFilter { $TenantFilter -eq 'AllTenants' } + Should -Invoke New-GraphGetRequest -Times 0 + } } diff --git a/frontend/src/pages/identity/administration/guest-users/index.js b/frontend/src/pages/identity/administration/guest-users/index.js index 325c65a7c4..0056c50626 100644 --- a/frontend/src/pages/identity/administration/guest-users/index.js +++ b/frontend/src/pages/identity/administration/guest-users/index.js @@ -3,6 +3,7 @@ import { Layout as DashboardLayout } from '../../../../layouts/index.js' import { CippTablePage } from '../../../../components/CippComponents/CippTablePage.jsx' import { ApiGetCallWithPagination } from '../../../../api/ApiCall' import { useSettings } from '../../../../hooks/use-settings' +import { useCippReportDB } from '../../../../components/CippComponents/CippReportDBControls' import { Card, CardActionArea, @@ -75,14 +76,24 @@ const Page = () => { const pageTitle = 'Guest Users' const currentTenant = useSettings().currentTenant const [statusFilter, setStatusFilter] = useState(null) - const queryKey = `ListGuestUsers-${currentTenant}` - // Same queryKey as the table below, so react-query shares one request between - // the summary cards and the table. + const reportDB = useCippReportDB({ + apiUrl: '/api/ListGuestUsers', + queryKey: 'ListGuestUsers', + cacheName: 'Guests', + syncTitle: 'Sync Guest Users', + allowToggle: true, + defaultCached: true, + allowAllTenantSync: true, + cacheColumns: ['CacheTimestamp'], + }) + + // Same url/data/queryKey as the table below, so react-query shares one request + // between the summary cards and the table. const guestData = ApiGetCallWithPagination({ - url: '/api/ListGuestUsers', + url: reportDB.resolvedApiUrl, data: { tenantFilter: currentTenant }, - queryKey: queryKey, + queryKey: reportDB.resolvedQueryKey, waiting: true, }) @@ -188,11 +199,13 @@ const Page = () => { 'daysSinceSignIn', 'accountEnabled', 'sourceDomain', + 'sponsors', ], actions: actions, } const simpleColumns = [ + ...reportDB.cacheColumns, 'displayName', 'mail', 'sourceDomain', @@ -204,21 +217,25 @@ const Page = () => { ] return ( - + <> + + {reportDB.syncDialog} + ) } Page.getLayout = (page) => ( - {page} + {page} ) export default Page From 76489ea1af5ec8ac1f02e8d5fc967cfdd9f908d3 Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Tue, 18 Aug 2026 15:21:36 +0200 Subject: [PATCH 153/226] new MDE offboarding device action --- backend/Config/openapi.json | 146 ++++++++++++++++++ .../Public/Invoke-CIPPMDEOffboard.ps1 | 46 ++++++ .../CIPPCore/Public/New-CIPPDeviceAction.ps1 | 3 + .../CippIntuneDeviceActions.jsx | 14 ++ 4 files changed, 209 insertions(+) create mode 100644 backend/Modules/CIPPCore/Public/Invoke-CIPPMDEOffboard.ps1 diff --git a/backend/Config/openapi.json b/backend/Config/openapi.json index 0ca8fe2e00..43f9cacb2c 100644 --- a/backend/Config/openapi.json +++ b/backend/Config/openapi.json @@ -23064,6 +23064,77 @@ "x-cipp-role": "Security.Incident.Read" } }, + "/api/ExecIRMConfiguration": { + "post": { + "summary": "ExecIRMConfiguration", + "operationId": "ExecIRMConfiguration", + "tags": [ + "Email-Exchange > Tools" + ], + "description": "Enables or disables Microsoft Purview Message Encryption for a tenant by setting AzureRMSLicensingEnabled, or runs Test-IRMConfiguration to verify that encryption and decryption work end to end.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "Action": { + "type": "string", + "enum": [ + "Set", + "Test" + ] + }, + "AzureRMSLicensingEnabled": { + "type": "string" + }, + "Recipient": { + "type": "string" + }, + "Sender": { + "type": "string" + }, + "tenantFilter": { + "type": "string" + } + }, + "required": [ + "tenantFilter" + ] + } + } + } + }, + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StandardResults" + } + } + } + }, + "401": { + "description": "Unauthorized - invalid or missing bearer token" + }, + "403": { + "description": "Forbidden - caller lacks the required RBAC role" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "x-cipp-role": "Exchange.Mailbox.ReadWrite" + } + }, "/api/ExecJITAdmin": { "post": { "summary": "ExecJITAdmin", @@ -47052,6 +47123,81 @@ "x-cipp-any-tenant": true } }, + "/api/ListIRMConfiguration": { + "get": { + "summary": "ListIRMConfiguration", + "operationId": "ListIRMConfiguration", + "tags": [ + "Email-Exchange > Tools" + ], + "description": "Lists the Information Rights Management (IRM) configuration for a tenant. Used to check whether Microsoft Purview Message Encryption is active and whether an on-premises AD RMS deployment still has to be migrated to Azure RMS first.", + "parameters": [ + { + "$ref": "#/components/parameters/tenantFilter" + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "description": "Derived from the fields the endpoint selects onto each record. The response may carry more; these are the ones known to exist.", + "properties": { + "AdRmsDetected": { + "x-cipp-field-source": "backend" + }, + "AzureRMSLicensingEnabled": { + "x-cipp-field-source": "backend" + }, + "ExternalLicensingEnabled": { + "x-cipp-field-source": "backend" + }, + "InternalLicensingEnabled": { + "x-cipp-field-source": "backend" + }, + "JournalReportDecryptionEnabled": { + "x-cipp-field-source": "backend" + }, + "LicensingLocation": { + "x-cipp-field-source": "backend" + }, + "MessageEncryptionEnabled": { + "x-cipp-field-source": "backend" + }, + "SimplifiedClientAccessEnabled": { + "x-cipp-field-source": "backend" + }, + "TransportDecryptionSetting": { + "x-cipp-field-source": "backend" + } + } + } + } + } + } + }, + "401": { + "description": "Unauthorized - invalid or missing bearer token" + }, + "403": { + "description": "Forbidden - caller lacks the required RBAC role" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "x-cipp-role": "Exchange.Mailbox.Read" + } + }, "/api/ListJITAdmin": { "get": { "summary": "ListJITAdmin", diff --git a/backend/Modules/CIPPCore/Public/Invoke-CIPPMDEOffboard.ps1 b/backend/Modules/CIPPCore/Public/Invoke-CIPPMDEOffboard.ps1 new file mode 100644 index 0000000000..1e45971774 --- /dev/null +++ b/backend/Modules/CIPPCore/Public/Invoke-CIPPMDEOffboard.ps1 @@ -0,0 +1,46 @@ +function Invoke-CIPPMDEOffboard { + <# + .SYNOPSIS + Offboards a device from Microsoft Defender for Endpoint. + .DESCRIPTION + MDE has no portal option to offboard a device, only the API. Resolves the MDE + machine record(s) for the given Entra device id via the Defender for Endpoint + machines API, then queues an offboard action for every record that is still + onboarded. Only supported by the MDE API for Windows client and server devices. + .PARAMETER AzureADDeviceId + The Entra device id of the device to offboard. MDE stores this as aadDeviceId + on the machine record. + .PARAMETER TenantFilter + The tenant to run against. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)][string]$AzureADDeviceId, + [Parameter(Mandatory = $true)][string]$TenantFilter + ) + + if ($AzureADDeviceId -eq '00000000-0000-0000-0000-000000000000') { + throw 'Device has no Entra device id, so it cannot be matched to a Defender for Endpoint machine record.' + } + + $Scope = 'https://api.securitycenter.microsoft.com/.default' + $Machines = New-GraphGetRequest -tenantid $TenantFilter -uri "https://api.securitycenter.microsoft.com/api/machines?`$filter=aadDeviceId eq $AzureADDeviceId" -scope $Scope + + $Onboarded = @($Machines | Where-Object { $_.onboardingStatus -eq 'Onboarded' }) + if ($Onboarded.Count -eq 0) { + if (@($Machines).Count -gt 0) { + throw "Found $(@($Machines).Count) Defender for Endpoint machine record(s) for this device, but none are currently onboarded." + } + throw 'No Defender for Endpoint machine record found for this device.' + } + + $OffboardBody = @{ Comment = 'Offboarded via CIPP' } | ConvertTo-Json -Compress + foreach ($Machine in $Onboarded) { + $null = New-GraphPOSTRequest -uri "https://api.securitycenter.microsoft.com/api/machines/$($Machine.id)/offboard" -tenantid $TenantFilter -body $OffboardBody -scope $Scope + } + + $Names = @($Onboarded | ForEach-Object { $_.computerDnsName } | Select-Object -Unique) -join ', ' + return "Queued Defender for Endpoint offboarding for $Names ($($Onboarded.Count) machine record(s))" +} diff --git a/backend/Modules/CIPPCore/Public/New-CIPPDeviceAction.ps1 b/backend/Modules/CIPPCore/Public/New-CIPPDeviceAction.ps1 index 33b42b971a..e54f728212 100644 --- a/backend/Modules/CIPPCore/Public/New-CIPPDeviceAction.ps1 +++ b/backend/Modules/CIPPCore/Public/New-CIPPDeviceAction.ps1 @@ -11,6 +11,9 @@ function New-CIPPDeviceAction { try { if ($Action -eq 'delete') { $null = New-GraphPOSTRequest -uri "https://graph.microsoft.com/beta/deviceManagement/managedDevices/$DeviceFilter" -type DELETE -tenantid $TenantFilter + } elseif ($Action -eq 'offboardMDEDevice') { + # DeviceFilter carries the Entra device id here, not the Intune managedDevice id + $Result = Invoke-CIPPMDEOffboard -AzureADDeviceId $DeviceFilter -TenantFilter $TenantFilter } elseif ($Action -eq 'users') { $null = New-GraphPOSTRequest -uri "https://graph.microsoft.com/beta/deviceManagement/managedDevices('$DeviceFilter')/$($Action)/`$ref" -type POST -tenantid $TenantFilter -body $ActionBody $regex = "(?<=\(')([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})(?='|\))" diff --git a/frontend/src/components/CippComponents/CippIntuneDeviceActions.jsx b/frontend/src/components/CippComponents/CippIntuneDeviceActions.jsx index fca1cfe8ce..40b0fef8df 100644 --- a/frontend/src/components/CippComponents/CippIntuneDeviceActions.jsx +++ b/frontend/src/components/CippComponents/CippIntuneDeviceActions.jsx @@ -15,6 +15,7 @@ import { Recycling, ManageAccounts, GroupAdd, + RemoveModerator, } from '@mui/icons-material' // Shared between the MEM devices list page and the View Device detail page. @@ -304,6 +305,19 @@ export const getIntuneDeviceActions = ({ tenantFilter } = {}) => [ confirmText: 'Are you sure you want to update the Windows Defender signatures for [deviceName]?', }, + { + label: 'Offboard from Defender for Endpoint', + type: 'POST', + icon: , + url: '/api/ExecDeviceAction', + data: { + GUID: 'azureADDeviceId', + Action: 'offboardMDEDevice', + }, + condition: (row) => row.operatingSystem === 'Windows', + confirmText: + 'Are you sure you want to offboard [deviceName] from Microsoft Defender for Endpoint? This queues an offboarding action via the MDE API and cannot be undone without re-onboarding the device.', + }, // This endpoint currently does not work, Graph just returns an error. Leaving this here for now in case it is fixed in the future. -Zac // { // label: 'Generate logs and ship to MEM', From 7904815c3167ca2a3023da4cf290d9354cb1f40d Mon Sep 17 00:00:00 2001 From: Brian Simpson <50429915+bmsimp@users.noreply.github.com> Date: Tue, 18 Aug 2026 08:22:51 -0500 Subject: [PATCH 154/226] docs(autopilot): complete group assignment documentation Reviewed the enrollment profiles page against the sources changed in PR #296 and corrected the gaps: - document the missing Identity Group Read fallback, where the group picker is replaced by a permission warning - Assign to Custom Group(s) lists every Entra ID group type, not just security groups - Remove Assignment(s) can also remove the all devices assignment - correct the Display Name character set to match both validators, adding the pipe and backslash and noting hyphens are rejected - use for the UI label in the actions table, drop the italics in the field table, and restore the table's uniform row padding Co-Authored-By: Claude Opus 5 (1M context) --- .../endpoint/autopilot/enrollment-profiles/README.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/user-documentation/endpoint/autopilot/enrollment-profiles/README.md b/docs/user-documentation/endpoint/autopilot/enrollment-profiles/README.md index c6fc9a3c89..be3a3af04a 100644 --- a/docs/user-documentation/endpoint/autopilot/enrollment-profiles/README.md +++ b/docs/user-documentation/endpoint/autopilot/enrollment-profiles/README.md @@ -13,13 +13,13 @@ Opens the Autopilot Profile Wizard, which creates a deployment profile in one or | Field | Description | | -------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Select Tenants | The tenants the profile is created in. At least one is required, and the profile is created identically in each. | -| Display Name | The profile's name. Intune only accepts letters, numbers, spaces and the characters \`: " ? . @ $ & \_ \[ ] { } | +| Display Name | The profile's name. Intune only accepts letters, numbers, spaces and the characters : " ? . @ $ & \_ \[ ] { } \| \\ and rejects hyphens. | | Language | The language applied during out-of-box experience. Operating system default and User Select sit at the top of the list, followed by the individual languages. | | Description | Optional text describing the profile. | | Unique Name Template | The naming pattern applied to devices that receive the profile, for example `%SERIAL%` or `%RAND:x%`. Leave blank to leave device names alone. | | Convert all targeted devices to Autopilot | Registers any device the profile is assigned to into Autopilot automatically, rather than requiring it to be imported first. | -| Assign to all devices | On by default. Assigns the profile to every Autopilot device in the tenant on creation. Turn it off to choose groups instead. | -| Assign to Selected Groups | Shown only when _Assign to all devices_ is off and exactly one tenant is selected. Assigns the profile to the chosen groups. Leave empty to create the profile without an assignment. Groups are tenant-specific, so a single tenant must be selected; with multiple tenants selected the field is replaced by a warning instead. | +| Assign to all devices | On by default. Assigns the profile to every Autopilot device in the tenant on creation. Turn it off to choose groups instead. | +| Assign to Selected Groups | Shown when Assign to all devices is off. Assigns the profile to the groups you select, or leave it empty to create the profile with no assignment. | | Self-deploying mode | Enrols the device without a user present, for kiosks and shared devices. | | Hide Terms and conditions | Skips the terms and conditions page during out-of-box experience. On by default. | | Hide Privacy Settings | Skips the privacy settings page during out-of-box experience. On by default. | @@ -28,6 +28,8 @@ Opens the Autopilot Profile Wizard, which creates a deployment profile in one or | Allow White Glove OOBE | Permits pre-provisioning. On by default, and switched off and locked automatically when Self-deploying mode is enabled, since the two are incompatible. | | Automatically configure keyboard | Skips the keyboard selection page and applies the layout matching the chosen language. On by default. | +Group targets are tenant-specific, so the group picker only appears when a single tenant is selected. With several tenants selected, or without the Identity Group Read permission, a warning takes its place and the profile is created without a group assignment. + After a successful creation the drawer stays open so another profile can be created without reopening it. @@ -38,6 +40,6 @@ The properties returned are for the Graph resource type `windowsAutopilotDeploym ## Table Actions -
    ActionDescriptionBulk Action Available
    Assign to All DevicesAssigns the profile to every Autopilot device in the tenant. If the profile is already assigned to all devices the action reports that and makes no changes.true
    Assign to Custom Group(s)Assigns the profile to one or more Entra ID security groups. A group picker dialog lets you search and select groups. Groups the profile is already assigned to are skipped automatically.true
    Remove Assignment(s)Removes assignments from the profile. A "Remove all assignments" switch is on by default and removes every assignment in one action. Turn it off to pick specific groups to unassign instead.true
    Delete ProfileDeletes the profile from the tenant along with its assignments. Devices already deployed with it are unaffected, but devices reset afterwards will no longer receive it.true
    More InfoOpens the Extended Info flyout with the full details for the selected row.false
    +
    ActionDescriptionBulk Action Available
    Assign to All DevicesAssigns the profile to every Autopilot device in the tenant. If the profile is already assigned to all devices the action reports that and makes no changes.true
    Assign to Custom Group(s)Assigns the profile to one or more Entra ID groups. A dialog lets you search for and select the groups. Groups the profile is already assigned to are skipped automatically.true
    Remove Assignment(s)Removes assignments from the profile. A Remove all assignments switch is on by default and removes every assignment in one action. Turn it off to pick specific assignments to remove instead, including the all devices assignment.true
    Delete ProfileDeletes the profile from the tenant along with its assignments. Devices already deployed with it are unaffected, but devices reset afterwards will no longer receive it.true
    More InfoOpens the Extended Info flyout with the full details for the selected row.false
    {% include "../../../../../.gitbook/includes/feature-request.md" %} From 44cfa2f9d2850c651792a9e746878b5e93e93291 Mon Sep 17 00:00:00 2001 From: Brian Simpson <50429915+bmsimp@users.noreply.github.com> Date: Tue, 18 Aug 2026 08:51:23 -0500 Subject: [PATCH 155/226] docs(mobile): document mobile and narrow-screen behaviour Covers the mobile UX work in 19fe49d5, e5ee94bb, c2ba5a96 and d69cbd29 as a single shared-features page rather than repeating it per page, since every change is in the shared table, layout, theme and dialog components. New Shared Features > Mobile Layout page covering the two width thresholds, where each menu bar control moves, the card list and its controls, selection mode, the switch to the full table, full-screen detail flyouts and back-gesture dismissal, the page actions button, the tab picker, breadcrumb collapse, clamped notices, dialog and wizard layout, and the PDF handoff. Also updates the pages whose behaviour now differs on a phone, adds the new "Table view on small screens" preference, and corrects two stale references: the tenant selector no longer moves into the navigation menu, and the CIPP Users page is no longer titled "CIPP User Management". Co-Authored-By: Claude Opus 5 (1M context) --- docs/SUMMARY.md | 1 + .../advanced/authentication/cipp-users.md | 2 +- .../shared-features/breadcrumb-navigation.md | 6 + .../shared-features/menu-bar/tenant-select.md | 10 +- .../menu-bar/universal-search.md | 2 + .../shared-features/menu-bar/user-settings.md | 1 + .../shared-features/mobile-layout.md | 136 ++++++++++++++++++ .../shared-features/speed-dial.md | 4 + .../shared-features/table-features.md | 8 +- 9 files changed, 167 insertions(+), 3 deletions(-) create mode 100644 docs/user-documentation/shared-features/mobile-layout.md diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index 1e70e204e5..f2e6473ce2 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -88,6 +88,7 @@ * [Bookmarks](user-documentation/shared-features/menu-bar/bookmarks.md) * [User Preferences](user-documentation/shared-features/menu-bar/user-settings.md) * [Table Features](user-documentation/shared-features/table-features.md) + * [Mobile Layout](user-documentation/shared-features/mobile-layout.md) * [Speed Dial](user-documentation/shared-features/speed-dial.md) * [Keyboard Shortcuts](user-documentation/shared-features/keyboard-shortcuts.md) * [Get Help](user-documentation/shared-features/get-help.md) diff --git a/docs/user-documentation/cipp/advanced/authentication/cipp-users.md b/docs/user-documentation/cipp/advanced/authentication/cipp-users.md index fe1a71cc8d..09efca2584 100644 --- a/docs/user-documentation/cipp/advanced/authentication/cipp-users.md +++ b/docs/user-documentation/cipp/advanced/authentication/cipp-users.md @@ -1,6 +1,6 @@ # CIPP Users -The CIPP User Management page controls who can access CIPP and what they can do. Access is granted in two ways that work side by side. Users are automatically synced from your partner tenant every 15 minutes based on the Entra group memberships configured on the CIPP Roles page, and you can also add users or assign roles by hand. Manual assignments are held separately from the automatic sync, so they are never overwritten when the sync runs. +The CIPP Users page controls who can access CIPP and what they can do. Access is granted in two ways that work side by side. Users are automatically synced from your partner tenant every 15 minutes based on the Entra group memberships configured on the CIPP Roles page, and you can also add users or assign roles by hand. Manual assignments are held separately from the automatic sync, so they are never overwritten when the sync runs. ## Table Details diff --git a/docs/user-documentation/shared-features/breadcrumb-navigation.md b/docs/user-documentation/shared-features/breadcrumb-navigation.md index 88cff160a5..fca4a66481 100644 --- a/docs/user-documentation/shared-features/breadcrumb-navigation.md +++ b/docs/user-documentation/shared-features/breadcrumb-navigation.md @@ -22,6 +22,12 @@ CIPP keeps the last twenty pages and displays the five most recent. The history Both modes ignore the tenant selection when building the trail, so switching tenants does not add duplicate entries or leave the tenant name embedded in a breadcrumb label. {% endhint %} +## On Narrow Screens + +On a phone the trail is kept to a single line. The most recent entries are shown, with everything before them collapsed behind an ellipsis that expands the full trail when selected, and the bookmark button moves to the right-hand edge of the row. + +Where the trail would say nothing the page does not already say, it is hidden altogether. That covers pages with a single entry, and the Home page and dashboard, whose trail only repeats the views their own picker offers. See [mobile-layout.md](mobile-layout.md "mention"). + ## Bookmark Button The bookmark button sits at the end of the breadcrumb trail and adds or removes the current page from your bookmarks. An outlined bookmark means the page is not yet saved, and a solid, coloured bookmark means it is. diff --git a/docs/user-documentation/shared-features/menu-bar/tenant-select.md b/docs/user-documentation/shared-features/menu-bar/tenant-select.md index a2a1d6530b..7b01ac8c53 100644 --- a/docs/user-documentation/shared-features/menu-bar/tenant-select.md +++ b/docs/user-documentation/shared-features/menu-bar/tenant-select.md @@ -36,9 +36,17 @@ Recent tenants are tracked for you: choosing a tenant from the dropdown adds it Favourites and recent tenants are stored in your browser rather than in your CIPP user settings. They are specific to the browser and device you set them on, they do not follow you to another machine, and clearing your browser's site data removes them. Both lists update immediately in any other CIPP tab you have open. {% endhint %} +## On Narrow Screens + +Where the window is too narrow for the selector, the menu bar shows the current tenant as a chip in its place. Selecting the chip opens a full-screen picker with its own search box and the same **All Tenants** entry, favourites, recent tenants and stars as the dropdown, so the same choices are available with more room to make them. + +Both forms read the same favourites and recent tenants, so resizing the window, or opening CIPP on a phone in a browser you have already used, keeps the groups you are used to. + +The tenant information flyout described below is not available from the picker. See [mobile-layout.md](../mobile-layout.md "mention"). + ## Tenant Information -The building icon to the left of the selector opens a flyout with details of the currently selected tenant, available from any page. It is unavailable while All Tenants is selected, and is not shown on narrow screens, where the selector moves into the mobile navigation menu. +The building icon to the left of the selector opens a flyout with details of the currently selected tenant, available from any page. It is unavailable while All Tenants is selected, and is not shown on narrow screens, where the selector is replaced by the tenant chip described above. | Field | Description | | ---------------------------------------- | ------------------------------------------------------------------------------- | diff --git a/docs/user-documentation/shared-features/menu-bar/universal-search.md b/docs/user-documentation/shared-features/menu-bar/universal-search.md index d335d93823..6c86166676 100644 --- a/docs/user-documentation/shared-features/menu-bar/universal-search.md +++ b/docs/user-documentation/shared-features/menu-bar/universal-search.md @@ -12,6 +12,8 @@ Two icons in the menu bar open the search dialog, each starting on a different s | Ctrl/Cmd + Shift + F | Opens search on **Users**. | | Ctrl/Cmd + Alt + K | Moves the cursor to the tenant selector. | +On a phone the two icons are not shown, and search is opened from the **Universal Search** entry in your account menu. It fills the screen, the search types are offered as chips beneath the box so any of them is one tap away, and the results appear in the page rather than in a dropdown. Before you have typed anything, your bookmarks are listed instead. See [mobile-layout.md](../mobile-layout.md "mention"). + ## Search Types | Type | What is searched | Selecting a result | diff --git a/docs/user-documentation/shared-features/menu-bar/user-settings.md b/docs/user-documentation/shared-features/menu-bar/user-settings.md index cc41fa3ad2..49d248f597 100644 --- a/docs/user-documentation/shared-features/menu-bar/user-settings.md +++ b/docs/user-documentation/shared-features/menu-bar/user-settings.md @@ -10,6 +10,7 @@ The page opens on whichever scope currently applies to you: your own settings if | ----------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Default usage location for users | The country pre-selected as the usage location when creating a new user. Required. | | Default Page Size | How many rows tables show per page by default, chosen from 25, 50, 100, or 250. Required. | +| Table view on small screens | How tables present themselves when the window is narrow: **Automatic (cards on mobile)** shows a card list below roughly 900px and the classic table above it, **Always card list** shows cards at every width, and **Always classic table** keeps the table at every width. See [mobile-layout.md](../mobile-layout.md "mention"). | | Default test suite on the Home page | The test suite whose results are shown on the Home page by default, chosen from your saved test reports. | | Added Attributes when creating a new user | Additional user attributes to make available on the new user form. Anything selected here appears as an extra field when creating a user. The available attributes are `consentProvidedForMinor`, `employeeId`, `employeeHireDate`, `employeeLeaveDateTime`, `employeeType`, `faxNumber`, `legalAgeGroupClassification`, `officeLocation`, `otherMails`, `showInAddressList`, and `sponsor`. | | Save last used table filter | When enabled, the filter you last applied to a table is remembered and re-applied the next time you open it. | diff --git a/docs/user-documentation/shared-features/mobile-layout.md b/docs/user-documentation/shared-features/mobile-layout.md new file mode 100644 index 0000000000..edd16c5166 --- /dev/null +++ b/docs/user-documentation/shared-features/mobile-layout.md @@ -0,0 +1,136 @@ +# Mobile Layout + +CIPP adapts to the width of the window it is running in, so the same pages work on a phone or a tablet as they do on a desktop. Nothing is removed on a smaller screen: the same tables, actions, filters and wizards are all there, presented in a form that fits the space and can be driven with a thumb. + +The layout is chosen from the width of the browser window rather than from the device you are using, so a narrow window on a desktop gets the same treatment as a phone. + +| Window width | What changes | +| -------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | +| Below roughly 1200px | The left-hand navigation collapses behind a menu button, and the tenant selector becomes a chip in the menu bar. | +| Below roughly 900px | Tables become card lists, dialogs and flyouts take the full screen, and page actions move to a button in the bottom right corner. | + +Pages are laid out to fit the width of the screen, so scrolling is vertical. Where content genuinely cannot be made narrower, such as a marketing email built around a fixed-width layout, it scrolls sideways within its own card rather than moving the page beneath it. + +## Menu Bar + +On a narrow window the menu bar carries the menu button, the current tenant, notifications and your account. The controls that no longer fit move rather than disappear. + +| Control | Where it goes | +| ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Navigation | Behind the menu button on the far left. The menu opens as a drawer that can also be swiped closed. | +| Tenant selector | A chip in the menu bar showing the current tenant, which opens a full-screen picker. See [tenant-select.md](menu-bar/tenant-select.md "mention"). | +| Universal search | The **Universal Search** entry in your account menu. See [universal-search.md](menu-bar/universal-search.md "mention"). | +| Light/dark mode | The **Light Mode** or **Dark Mode** entry in your account menu. | +| Help and support | The help links and **Clear Cache and Reload** move into your account menu, because the speed dial's corner is given to page actions. See [speed-dial.md](speed-dial.md "mention"). | + +The navigation drawer has a search box at the top. Typing in it narrows the menu to matching entries and opens the sections they sit in, so a page several levels down can be reached without expanding each level by hand. + +## Tables + +Below roughly 900px, tables are presented as a list of cards, one card per row. Each card is built from the columns the table is already showing. + +| Part of the card | What it holds | +| ------------------ | -------------------------------------------------------------------------------------- | +| Title | The row's name, such as a display name, device name or subject. | +| Subtitle | The row's identifier, such as a user principal name, mail address or serial number. | +| Chips | Up to three status values, such as an account state, severity or result. | +| Details | Up to three further fields, shown as label and value pairs. | +| **+N more fields** | Everything else on the row. Selecting it opens the full detail view. | + +Where a row has more information than the card shows, tapping the card opens the same detail flyout that **More Info** opens on a desktop. Tapping the button in the card's top right corner opens the row's actions, exactly the actions the ellipsis offers on a desktop. + +The first batch of cards is drawn using your **Default Page Size** preference, up to a maximum of 50, so a large page size does not turn into hundreds of cards on a phone. A count beneath the list reads **Showing 50 of 340**, with a **Load 50 more** button while there is more to show. + +### Controls Above the List + +A row of controls sits above the cards and stays in place as you scroll. + +| Control | Description | +| ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Search | Filters the list as you type, matching the same values the desktop search box matches. | +| **Select** | Enters selection mode, described below. Shown only where the table supports selecting rows. | +| Sort | Opens a list of the columns you can sort on. Tapping one cycles it through ascending, descending, and off. One column sorts at a time. | +| Table options | Opens the sheet described below. It carries a badge counting the filters currently applied. | +| Table view | Switches this table to the full desktop table, described under [#switching-to-the-full-table](mobile-layout.md#switching-to-the-full-table "mention"). | + +The **Table options** sheet gathers everything the desktop toolbar's menus hold. + +| Section | Description | +| ------------------ | ------------------------------------------------------------------------------------------------------------------- | +| Data source | The Live and Cached switch, and the **Sync** button, on pages that offer them. | +| Presets | The page's table filter presets, as chips. The active preset is ticked. | +| Graph filters | The page's Graph filter presets, where the page is backed by Graph Explorer. | +| Reset all filters | Clears the search box and every applied filter. | +| Edit graph filters | Opens the filter builder, on pages backed by Graph Explorer. | +| Export to CSV/PDF | The same exports the desktop **Export** menu offers. | +| View API response | Opens the raw JSON returned by the call behind the table. | +| Refresh data | Reloads the table. | +| Fields shown | Ticks and unticks the columns the cards are built from, which is the same column selection the desktop table uses. | + +Filters, presets, saved column selections and the **Save last used table filter** preference all behave exactly as they do on a desktop, because the card list and the table are driven by the same controls underneath. + +### Selecting Rows + +Selecting **Select** puts a checkbox on every card. Tapping a card then ticks it rather than opening its details. + +A bar appears at the foot of the screen with the number of rows selected, a **Select all** button covering every row that matches the current filters, an **Actions** button opening the bulk actions available, and **Done** to leave selection mode. + +### Switching to the Full Table + +The table icon above the list switches to the full desktop table for the page you are on, complete with column headers, column filters and horizontal scrolling. A card icon in that table's toolbar switches back. + +The switch applies to that table only and lasts as long as you stay on the page. To change what tables do by default, use the **Table view on small screens** preference described in [user-settings.md](menu-bar/user-settings.md "mention"), which offers **Automatic (cards on mobile)**, **Always card list**, and **Always classic table**. + +## Detail Flyouts + +Detail flyouts fill the screen and read as a page of their own, with a back arrow in the top left. Where the flyout supports moving between rows, the previous and next controls sit in a bar at the foot of the screen rather than in the header. + +Your device's back gesture closes the flyout and returns you to the list, with the list still loaded and still scrolled where you left it, rather than leaving the page entirely. The same applies to the navigation drawer, bottom sheets and other overlays: back closes the topmost one first. + +## Page Actions + +Where a page has its own actions, such as adding a record or running a report, they are collected behind a round button in the bottom right corner. Selecting it opens the full list. + +{% hint style="info" %} +On a desktop this corner holds the speed dial. On a phone the speed dial stands down so that page actions can use the corner, and the help and support options it offers move into your account menu. +{% endhint %} + +## Pages with Tabs + +On a page with tabs, the tab bar is replaced by a single control showing the view you are on. Selecting it opens the list of views, and choosing one navigates to it. On pages with a heading, such as an individual user or device, the control sits beside the heading as a chip. + +Pages with only one view show no control at all, since there is nowhere else to go. + +Where the view you are on carries the same name as the page, the page's own heading is not printed above the control, so the name appears once rather than twice. + +## Breadcrumbs + +The breadcrumb trail stays on one line. Leading entries collapse behind an ellipsis that expands them when selected, and the bookmark button moves to the right-hand edge of the row. + +On the Home page and the dashboard the trail is hidden altogether, since it repeats what the page and its view picker already say. See [breadcrumb-navigation.md](breadcrumb-navigation.md "mention"). + +## Notices + +Pages that open with a long explanatory notice show the first three lines of it, followed by a **Show more** link that expands the rest and a **Show less** link to collapse it again, so the page's actual content is not pushed below the fold. Notices short enough to fit are shown in full with no link. + +## Forms, Dialogs and Wizards + +Dialogs use the full width of the screen, and their buttons stack with the main action at the top so it falls under your thumb. + +Where a form places controls side by side, they stack into a single column instead. This applies both to rows of fields, such as the name, type and value of a row in Table Maintenance, and to whole panes, such as the summary that sits beside the permission list when you edit a CIPP role. + +Wizards replace the horizontal step indicator with a progress bar reading **Step 2 of 5**, the name of the step you are on, and how far through you are. A step that is loading or has failed is reflected in the bar, as it is in the step icons on a desktop. **Back**, **Next** and **Submit** stack, with the action that moves you forward on top. + +Text fields are rendered slightly larger on touch devices, which stops mobile browsers zooming in when you tap into a field and leaving the page zoomed afterwards. + +{% hint style="info" %} +Tooltips do not open on touch devices. A tooltip is a hover affordance, and on a touch screen a long press would leave one stuck to the screen while you scroll. Anything a tooltip explains is also available elsewhere on the page. +{% endhint %} + +## Reports + +Report previews are laid out for the screen rather than shown as an embedded document, and choosing a test suite or one of its actions is done from a list you pick from. + +PDF reports are handed to your device rather than displayed in place, because mobile browsers cannot scroll an embedded PDF. You are shown the report's name and size with a button to open it in your device's own PDF viewer, which can scroll, zoom, print and share it, and a download button where the report offers one. + +{% include "../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/shared-features/speed-dial.md b/docs/user-documentation/shared-features/speed-dial.md index ca353d5789..8541692ec3 100644 --- a/docs/user-documentation/shared-features/speed-dial.md +++ b/docs/user-documentation/shared-features/speed-dial.md @@ -2,6 +2,10 @@ The CIPP speed dial gives you quick access to help, feedback, and troubleshooting from anywhere in the application. It sits as a round button in the lower right corner of your browser window, and opens when you hover over it or click it. Clicking anywhere outside closes it again. +{% hint style="info" %} +On a phone the lower right corner holds the actions for the page you are on, so the speed dial is not shown. **Report Bug**, **Request Feature**, **Join the Discord!**, **Check the Documentation** and **Clear Cache and Reload** move into your account menu instead. **Tutorials** and **License** are available on a larger screen. See [mobile-layout.md](mobile-layout.md "mention"). +{% endhint %} + ## Options | Option | Description | diff --git a/docs/user-documentation/shared-features/table-features.md b/docs/user-documentation/shared-features/table-features.md index 35240b9d2c..2e9266d609 100644 --- a/docs/user-documentation/shared-features/table-features.md +++ b/docs/user-documentation/shared-features/table-features.md @@ -86,7 +86,13 @@ When a page has queued a long-running background task, a queue status button app ### Narrow Screens -On smaller viewports, and whenever the toolbar runs out of room, the **Filters**, **Columns** and **Export** buttons collapse into a single menu behind the vertical ellipsis. That menu also offers **Fullscreen**, which expands the table to fill the window, and **Exit Fullscreen** to return. +Whenever the toolbar runs out of room, the **Filters**, **Columns** and **Export** buttons collapse into a single menu behind the vertical ellipsis. That menu also offers **Fullscreen**, which expands the table to fill the window, and **Exit Fullscreen** to return. + +On a phone the ellipsis opens a sheet instead, holding the same filters, presets, column selection, exports and refresh, along with a **Rows per page** section in place of the footer's page size control. + +{% hint style="info" %} +Below roughly 900px, tables are presented as a list of cards rather than as a table, with their own search, sort and filter controls. Everything on this page still applies, and the full table remains available. See [mobile-layout.md](mobile-layout.md "mention"). +{% endhint %} ## Row Selection and Actions From cd046714a4312d60bb389b919dd0f16d0d69f0ee Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Tue, 18 Aug 2026 16:34:25 +0200 Subject: [PATCH 156/226] add AppleUserInitiatedEnrollmentProfiles --- .../AppleEnrollmentTypeProfile.json | 104 +++++++ backend/Config/standards.json | 62 +++++ ...aselineAppleEnrollmentTypeProfileState.ps1 | 76 ++++++ ...CIPPBaselineAppleEnrollmentTypeProfile.ps1 | 135 +++++++++ .../Public/Get-CIPPIntuneAssignmentTarget.ps1 | 3 + ...neAppleUserInitiatedEnrollmentProfiles.ps1 | 70 +++++ .../DBCache/Set-CIPPDBCacheIntunePolicies.ps1 | 1 + ...CIPPStandardAppleEnrollmentTypeProfile.ps1 | 256 ++++++++++++++++++ frontend/src/data/standards.json | 62 +++++ 9 files changed, 769 insertions(+) create mode 100644 backend/Config/BaselineStandards/Intune Standards/AppleEnrollmentTypeProfile.json create mode 100644 backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineAppleEnrollmentTypeProfileState.ps1 create mode 100644 backend/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineAppleEnrollmentTypeProfile.ps1 create mode 100644 backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheIntuneAppleUserInitiatedEnrollmentProfiles.ps1 create mode 100644 backend/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardAppleEnrollmentTypeProfile.ps1 diff --git a/backend/Config/BaselineStandards/Intune Standards/AppleEnrollmentTypeProfile.json b/backend/Config/BaselineStandards/Intune Standards/AppleEnrollmentTypeProfile.json new file mode 100644 index 0000000000..41f77af5b0 --- /dev/null +++ b/backend/Config/BaselineStandards/Intune Standards/AppleEnrollmentTypeProfile.json @@ -0,0 +1,104 @@ +{ + "name": "AppleEnrollmentTypeProfile", + "label": "Deploy Apple Enrollment Type Profile", + "cat": "Intune Standards", + "tag": [], + "impact": "Medium Impact", + "helpText": "Creates and manages an Apple user-initiated enrollment type profile (such as iOS/iPadOS web based device enrollment) and keeps it assigned to the configured groups. The tenant needs an Apple MDM push certificate for the enrollment itself to function.", + "executiveText": "Ensures every tenant offers the same enrollment experience for Apple devices, such as web based enrollment for personal iPhones and iPads, without engineers configuring each tenant by hand.", + "docsDescription": "One instance per profile display name. Grades the enrollment type, description and available enrollment type options from the cached Apple user-initiated enrollment profiles plus - separately - the group assignment through Compare-CIPPIntuneAssignments. Remediation repairs a wrong assignment in place, patches drifted settings, and only sets the priority when the profile is first created.", + "impactColour": "warning", + "addedDate": "2026-08-18", + "powershellEquivalent": "Graph API - deviceManagement/appleUserInitiatedEnrollmentProfiles", + "recommendedBy": [], + "requiredCapabilities": [ + "INTUNE_A", + "MDM_Services", + "EMS", + "SCCM", + "MICROSOFTINTUNEPLAN1" + ], + "disabledFeatures": { + "report": false, + "warn": false, + "remediate": false + }, + "secureScoreImpact": 0, + "compare": "subset", + "variables": { + "DisplayName": { + "type": "textField", + "label": "Profile Display Name", + "required": true + }, + "Description": { + "type": "textField", + "label": "Profile Description", + "omitWhenBlank": true + }, + "EnrollmentType": { + "type": "autoComplete", + "creatable": false, + "multiple": false, + "label": "Enrollment Type", + "required": true, + "options": [ + { + "value": "webDeviceEnrollment", + "label": "Web based device enrollment" + }, + { + "value": "accountDrivenUserEnrollment", + "label": "Account driven user enrollment" + }, + { + "value": "device", + "label": "Device enrollment with Company Portal" + } + ], + "default": "webDeviceEnrollment" + }, + "Priority": { + "type": "number", + "label": "Priority (applied when the profile is created)", + "omitWhenBlank": true + }, + "AssignTo": { + "type": "autoComplete", + "creatable": false, + "multiple": false, + "label": "Profile Assignment", + "omitWhenBlank": true, + "options": [ + { + "value": "none", + "label": "Do not assign" + }, + { + "value": "customGroup", + "label": "Assign to Custom Group" + } + ] + }, + "customGroup": { + "type": "textField", + "label": "Custom group name(s). Comma separated, wildcards allowed.", + "omitWhenBlank": true + } + }, + "read": { + "cacheType": "IntuneAppleUserInitiatedEnrollmentProfiles" + }, + "prepare": "Get-CIPPBaselineAppleEnrollmentTypeProfileState", + "remediate": { + "executor": "AppleEnrollmentTypeProfile", + "displayName": "%DisplayName%", + "description": "%Description%", + "enrollmentType": "%EnrollmentType%", + "priority": "%Priority%", + "assignTo": "%AssignTo%", + "customGroup": "%customGroup%" + }, + "multiple": true, + "instanceIdentity": "DisplayName" +} diff --git a/backend/Config/standards.json b/backend/Config/standards.json index 6e2febd341..b173b7dd0d 100644 --- a/backend/Config/standards.json +++ b/backend/Config/standards.json @@ -6509,6 +6509,68 @@ "recommendedBy": [], "requiredCapabilities": ["INTUNE_A", "MDM_Services", "EMS", "SCCM", "MICROSOFTINTUNEPLAN1"] }, + { + "name": "standards.AppleEnrollmentTypeProfile", + "cat": "Intune Standards", + "tag": ["enrollment", "apple", "ios"], + "disabledFeatures": { "report": false, "warn": false, "remediate": false }, + "helpText": "Creates and manages an Apple user-initiated enrollment type profile (such as iOS/iPadOS web based device enrollment) and keeps it assigned to the configured groups. The tenant needs an Apple MDM push certificate for the enrollment itself to function.", + "executiveText": "Ensures every tenant offers the same enrollment experience for Apple devices, such as web based enrollment for personal iPhones and iPads, without engineers configuring each tenant by hand. This keeps device onboarding consistent and makes it possible to report on which tenants are correctly configured.", + "docsDescription": "Deploys an Apple user-initiated enrollment type profile through deviceManagement/appleUserInitiatedEnrollmentProfiles. The profile is matched by display name; the enrollment type (web based device enrollment, account driven user enrollment, or device enrollment with Company Portal), description and group assignments are kept in sync, with a wrong assignment repaired in place. Priority is only applied when the profile is first created, because reordering is relative to the other profiles in each tenant.", + "addedComponent": [ + { + "type": "textField", + "name": "standards.AppleEnrollmentTypeProfile.DisplayName", + "label": "Profile Display Name", + "required": true + }, + { + "type": "textField", + "name": "standards.AppleEnrollmentTypeProfile.Description", + "label": "Profile Description", + "required": false + }, + { + "type": "autoComplete", + "multiple": false, + "creatable": false, + "name": "standards.AppleEnrollmentTypeProfile.EnrollmentType", + "label": "Enrollment Type", + "options": [ + { "label": "Web based device enrollment", "value": "webDeviceEnrollment" }, + { "label": "Account driven user enrollment", "value": "accountDrivenUserEnrollment" }, + { "label": "Device enrollment with Company Portal", "value": "device" } + ] + }, + { + "type": "number", + "name": "standards.AppleEnrollmentTypeProfile.Priority", + "label": "Priority (applied when the profile is created)", + "defaultValue": 1 + }, + { + "type": "radio", + "name": "standards.AppleEnrollmentTypeProfile.AssignTo", + "label": "Profile Assignment", + "options": [ + { "label": "Do not assign", "value": "none" }, + { "label": "Assign to Custom Group", "value": "customGroup" } + ] + }, + { + "type": "textField", + "name": "standards.AppleEnrollmentTypeProfile.customGroup", + "label": "Custom group name(s). Comma separated, wildcards allowed.", + "required": false + } + ], + "label": "Deploy Apple Enrollment Type Profile", + "impact": "Medium Impact", + "impactColour": "warning", + "addedDate": "2026-08-18", + "recommendedBy": [], + "requiredCapabilities": ["INTUNE_A", "MDM_Services", "EMS", "SCCM", "MICROSOFTINTUNEPLAN1"] + }, { "name": "standards.IntuneTemplate", "cat": "Templates", diff --git a/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineAppleEnrollmentTypeProfileState.ps1 b/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineAppleEnrollmentTypeProfileState.ps1 new file mode 100644 index 0000000000..ba6e26a1c0 --- /dev/null +++ b/backend/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineAppleEnrollmentTypeProfileState.ps1 @@ -0,0 +1,76 @@ +function Get-CIPPBaselineAppleEnrollmentTypeProfileState { + <# + .SYNOPSIS + Prepare hook for AppleEnrollmentTypeProfile: one named Apple user-initiated enrollment profile. + .DESCRIPTION + Finds the configured profile by display name in the Apple enrollment profiles cache and + grades the classic's exact field set: description, default enrollment type and the + available enrollment type options as a normalized ownerType:enrollmentType set, so + option order coming back from Graph can never register as drift. Priority is not + graded - it is relative to the other profiles in each tenant and only applied when the + profile is first created. + + The assignment grades separately through Compare-CIPPIntuneAssignments off the cached + assignments; a failed or unknown lookup - including a cache row whose assignments + fetch failed, recognizable by the absent assignments property - leaves the dimension + out entirely, because a deviation no run can clear is worse than a blind spot. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Item, + $TenantFilter + ) + + $Profiles = @(Get-CIPPBaselineCacheRows -TenantFilter $TenantFilter -Type 'IntuneAppleUserInitiatedEnrollmentProfiles') + if ($Profiles.Count -eq 0 -and -not (Test-CIPPBaselineCacheCollected -TenantFilter $TenantFilter -Type 'IntuneAppleUserInitiatedEnrollmentProfiles')) { + return @{ Current = $null } + } + + $V = $Item.Variables + # The identity may arrive as an option object ({label, value}) from some save paths. + $DisplayName = "$($V.DisplayName.value ?? $V.DisplayName)" + if ([string]::IsNullOrWhiteSpace($DisplayName)) { return @{ Current = $null } } + $EnrollmentProfile = @($Profiles | Where-Object { "$($_.displayName)" -eq $DisplayName }) | Select-Object -First 1 + + $EnrollmentType = [string]($V.EnrollmentType.value ?? $V.EnrollmentType) + if ([string]::IsNullOrWhiteSpace($EnrollmentType)) { $EnrollmentType = 'webDeviceEnrollment' } + $AssignTo = [string]($V.AssignTo.value ?? $V.AssignTo) + if ([string]::IsNullOrWhiteSpace($AssignTo)) { $AssignTo = 'none' } + + $CurrentOptions = (@($EnrollmentProfile.availableEnrollmentTypeOptions) | Where-Object { $_ } | ForEach-Object { "$($_.ownerType):$($_.enrollmentType)" } | Sort-Object) -join ', ' + + $Expected = [PSCustomObject]@{ + profileExists = $true + displayName = $DisplayName + description = "$($V.Description)" + defaultEnrollmentType = $EnrollmentType + enrollmentTypeOptions = "personal:$EnrollmentType" + } + $Current = [PSCustomObject]@{ + profileExists = ($null -ne $EnrollmentProfile) + displayName = "$($EnrollmentProfile.displayName)" + description = "$($EnrollmentProfile.description)" + defaultEnrollmentType = "$($EnrollmentProfile.defaultEnrollmentType)" + enrollmentTypeOptions = $CurrentOptions + } + + # Assignment dimension: graded only when requested AND readable. The collector only adds + # the assignments property when the per-profile fetch succeeded, so a row without it is a + # failed read - indistinguishable from "unassigned" by value, which is exactly why the + # property's absence has to leave the dimension out instead of grading an empty set. + if ($null -ne $EnrollmentProfile -and $AssignTo -ne 'none' -and $EnrollmentProfile.PSObject.Properties.Name -contains 'assignments') { + try { + $AssignmentDetail = Compare-CIPPIntuneAssignments -ExistingAssignments @($EnrollmentProfile.assignments) -ExpectedAssignTo $AssignTo -ExpectedCustomGroup "$($V.customGroup)" -PolicyType 'AppleEnrollmentTypeProfile' -TenantFilter $TenantFilter + if (-not $AssignmentDetail.Unknown) { + $Expected | Add-Member -NotePropertyName 'isAssigned' -NotePropertyValue $true + $Current | Add-Member -NotePropertyName 'isAssigned' -NotePropertyValue ([bool]$AssignmentDetail.Matched) + } + } catch { + Write-Information "Baselines: AppleEnrollmentTypeProfile assignment compare failed: $($_.Exception.Message)" + } + } + + @{ Expected = $Expected; Current = $Current } +} diff --git a/backend/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineAppleEnrollmentTypeProfile.ps1 b/backend/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineAppleEnrollmentTypeProfile.ps1 new file mode 100644 index 0000000000..e60733b5d5 --- /dev/null +++ b/backend/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineAppleEnrollmentTypeProfile.ps1 @@ -0,0 +1,135 @@ +function Invoke-CIPPBaselineAppleEnrollmentTypeProfile { + <# + .SYNOPSIS + AppleEnrollmentTypeProfile executor: deploys the named Apple enrollment type profile. + .DESCRIPTION + The classic's write, verbatim: create the profile when it is missing (the only moment + priority is applied), PATCH the enrollment type, description and available options when + the settings drifted, and reconcile the group assignments to exactly the configured + set. The profile type has no /assign action, so missing groups are added and everything + else is removed one assignment at a time. The profile and its assignments are read live + rather than from the prepare's cache-derived state, because remediation must not act on + a snapshot another remediation may already have changed. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Remediate, + $TenantFilter, + $Current + ) + + $DisplayName = "$($Remediate.displayName.value ?? $Remediate.displayName)" + if ([string]::IsNullOrWhiteSpace($DisplayName)) { return } + + $EnrollmentType = [string]($Remediate.enrollmentType.value ?? $Remediate.enrollmentType) + if ([string]::IsNullOrWhiteSpace($EnrollmentType)) { $EnrollmentType = 'webDeviceEnrollment' } + $Description = "$($Remediate.description)" + $Priority = if ([string]::IsNullOrWhiteSpace("$($Remediate.priority)")) { 1 } else { [int]"$($Remediate.priority)" } + $AssignTo = [string]($Remediate.assignTo.value ?? $Remediate.assignTo) + if ([string]::IsNullOrWhiteSpace($AssignTo)) { $AssignTo = 'none' } + + $EnrollmentTypeOptions = @( + @{ + '@odata.type' = '#microsoft.graph.appleOwnerTypeEnrollmentType' + ownerType = 'personal' + enrollmentType = $EnrollmentType + } + ) + + $ProfilesUri = 'https://graph.microsoft.com/beta/deviceManagement/appleUserInitiatedEnrollmentProfiles' + $Profiles = @(New-GraphGetRequest -uri "$ProfilesUri`?`$top=999" -tenantid $TenantFilter) + $ExistingProfile = $Profiles | Where-Object { "$($_.displayName)" -eq $DisplayName } | Select-Object -First 1 + + $ExistingAssignments = @() + if (-not $ExistingProfile) { + $CreateBody = @{ + '@odata.type' = '#microsoft.graph.appleUserInitiatedEnrollmentProfile' + displayName = $DisplayName + description = $Description + platform = 'iOS' + priority = $Priority + defaultEnrollmentType = $EnrollmentType + availableEnrollmentTypeOptions = $EnrollmentTypeOptions + } | ConvertTo-Json -Compress -Depth 10 + $NewProfile = New-GraphPostRequest -uri $ProfilesUri -tenantid $TenantFilter -body $CreateBody -type POST + $ProfileId = "$($NewProfile.id)" + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "Created the Apple enrollment type profile '$DisplayName'." -Sev 'Info' + } else { + $ProfileId = "$($ExistingProfile.id)" + # The settings verdict is recomputed from the live profile rather than carried over + # from the prepare's cache-derived state: settings that drifted after the cache was + # collected would otherwise survive a run that reports itself as remediated. + $LiveOptions = (@($ExistingProfile.availableEnrollmentTypeOptions) | Where-Object { $_ } | ForEach-Object { "$($_.ownerType):$($_.enrollmentType)" } | Sort-Object) -join ', ' + $SettingsCorrect = ("$($ExistingProfile.description)" -eq $Description) -and + ("$($ExistingProfile.defaultEnrollmentType)" -eq $EnrollmentType) -and + ($LiveOptions -eq "personal:$EnrollmentType") + if (-not $SettingsCorrect) { + $PatchBody = @{ + '@odata.type' = '#microsoft.graph.appleUserInitiatedEnrollmentProfile' + description = $Description + defaultEnrollmentType = $EnrollmentType + availableEnrollmentTypeOptions = $EnrollmentTypeOptions + } | ConvertTo-Json -Compress -Depth 10 + $null = New-GraphPostRequest -uri "$ProfilesUri/$ProfileId" -tenantid $TenantFilter -body $PatchBody -type PATCH + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "Updated the Apple enrollment type profile '$DisplayName'." -Sev 'Info' + } + $ExistingAssignments = @(New-GraphGetRequest -uri "$ProfilesUri/$ProfileId/assignments" -tenantid $TenantFilter) + } + + if ($AssignTo -ne 'customGroup' -or [string]::IsNullOrWhiteSpace($ProfileId)) { return } + + # Reconcile the assignments to exactly the configured groups. + $ExpectedGroupIds = [System.Collections.Generic.List[string]]::new() + $AllGroups = New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/groups?$select=id,displayName&$top=999' -tenantid $TenantFilter + foreach ($Name in @("$($Remediate.customGroup)".Split(',').Trim() | Where-Object { $_ })) { + # Square brackets are wildcard character classes to -like; group names containing them + # are literal. Matches the escaping Compare-CIPPIntuneAssignments applies. + $Pattern = $Name -replace '\[', '`[' -replace '\]', '`]' + $Matched = @($AllGroups | Where-Object { $_.displayName -like $Pattern } | Select-Object -ExpandProperty id) + if ($Matched.Count -eq 0) { + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "AppleEnrollmentTypeProfile: group name '$Name' matches no group in this tenant." -Sev 'Warning' + } else { + $ExpectedGroupIds.AddRange([string[]]$Matched) + } + } + + # A name set that resolves to nothing must not strip a working profile bare: the compare + # keeps reporting the unresolved names, so deleting the existing assignments would add + # damage to a deviation remediation cannot clear anyway. + if ($ExpectedGroupIds.Count -eq 0) { + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "AppleEnrollmentTypeProfile: no configured group resolved for profile '$DisplayName'; leaving the existing assignments untouched." -Sev 'Warning' + return + } + + $ChangeCount = 0 + $KeptGroupIds = [System.Collections.Generic.List[string]]::new() + foreach ($Assignment in $ExistingAssignments) { + # An assignment filter set through Graph would otherwise survive as a kept assignment + # while the comparison keeps flagging it - keep only clean group targets so the write + # converges with what the compare asserts. + $FilterId = "$($Assignment.target.deviceAndAppManagementAssignmentFilterId)" + $HasFilter = -not [string]::IsNullOrWhiteSpace($FilterId) -and $FilterId -ne '00000000-0000-0000-0000-000000000000' + $IsExpected = $Assignment.target.'@odata.type' -eq '#microsoft.graph.groupAssignmentTarget' -and $ExpectedGroupIds -contains "$($Assignment.target.groupId)" -and -not $HasFilter + if ($IsExpected) { + $KeptGroupIds.Add("$($Assignment.target.groupId)") + } else { + $null = New-GraphPostRequest -uri "$ProfilesUri/$ProfileId/assignments/$($Assignment.id)" -tenantid $TenantFilter -type DELETE + $ChangeCount++ + } + } + foreach ($GroupId in @($ExpectedGroupIds | Select-Object -Unique | Where-Object { $_ -notin $KeptGroupIds })) { + $AssignmentBody = @{ + target = @{ + '@odata.type' = '#microsoft.graph.groupAssignmentTarget' + groupId = $GroupId + } + } | ConvertTo-Json -Compress -Depth 10 + $null = New-GraphPostRequest -uri "$ProfilesUri/$ProfileId/assignments" -tenantid $TenantFilter -body $AssignmentBody -type POST + $ChangeCount++ + } + if ($ChangeCount -gt 0) { + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "Reconciled the assignments for the Apple enrollment type profile '$DisplayName' ($ChangeCount change(s))." -Sev 'Info' + } +} diff --git a/backend/Modules/CIPPCore/Public/Get-CIPPIntuneAssignmentTarget.ps1 b/backend/Modules/CIPPCore/Public/Get-CIPPIntuneAssignmentTarget.ps1 index 155867ffa8..0d92f92caf 100644 --- a/backend/Modules/CIPPCore/Public/Get-CIPPIntuneAssignmentTarget.ps1 +++ b/backend/Modules/CIPPCore/Public/Get-CIPPIntuneAssignmentTarget.ps1 @@ -72,8 +72,11 @@ function Get-CIPPIntuneAssignmentTarget { # Policy types whose assignment surface is user groups only. Device Preparation deployments # trigger on the enrolling user, so a device audience cannot be expressed for them at all. + # Apple enrollment type profiles apply to the enrolling user the same way - the portal's + # picker offers user groups and nothing else. $UserGroupOnlyTypes = @( 'DevicePrepProfile' + 'AppleEnrollmentTypeProfile' ) $IsUserGroupOnly = $IsMam -or ($UserGroupOnlyTypes -contains $PolicyType) diff --git a/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheIntuneAppleUserInitiatedEnrollmentProfiles.ps1 b/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheIntuneAppleUserInitiatedEnrollmentProfiles.ps1 new file mode 100644 index 0000000000..5b7f149e15 --- /dev/null +++ b/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheIntuneAppleUserInitiatedEnrollmentProfiles.ps1 @@ -0,0 +1,70 @@ +function Set-CIPPDBCacheIntuneAppleUserInitiatedEnrollmentProfiles { + <# + .SYNOPSIS + Caches Apple user-initiated enrollment type profiles (with assignments) for a tenant. + + .DESCRIPTION + Thin single-family collector for the IntuneAppleUserInitiatedEnrollmentProfiles cache + type, which the Set-CIPPDBCacheIntunePolicies umbrella also writes on its schedule. It + exists so the convention lookup (Set-CIPPDBCache) resolves for collect-on-miss + and for the post-remediation refresh. Assignments are fanned out per profile because + the list endpoint does not support $expand=assignments. + + .PARAMETER TenantFilter + The tenant to cache Apple enrollment type profiles for + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter, + [string]$QueueId + ) + + try { + $TestResult = Test-CIPPStandardLicense -StandardName 'IntuneAppleEnrollmentProfilesCache' -TenantFilter $TenantFilter -Preset Intune -SkipLog + if ($TestResult -eq $false) { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Tenant does not have Intune license, skipping Apple enrollment type profiles cache' -sev Debug + return + } + + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching Apple enrollment type profiles' -sev Debug + $Profiles = @(New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/deviceManagement/appleUserInitiatedEnrollmentProfiles?$top=999' -tenantid $TenantFilter) + + if ($Profiles.Count -gt 0) { + $AssignmentRequests = @($Profiles | ForEach-Object { + [PSCustomObject]@{ + id = $_.id + method = 'GET' + url = "/deviceManagement/appleUserInitiatedEnrollmentProfiles/$($_.id)/assignments" + } + }) + + try { + $AssignmentResults = @(New-GraphBulkRequest -Requests $AssignmentRequests -tenantid $TenantFilter) + foreach ($AssignResult in $AssignmentResults) { + if ($null -eq $AssignResult.status) { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "No HTTP status was returned while fetching assignments for Apple enrollment profile $($AssignResult.id)" -sev Warning + continue + } elseif ([int]$AssignResult.status -lt 200 -or [int]$AssignResult.status -ge 300) { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Failed to fetch assignments for Apple enrollment profile $($AssignResult.id): HTTP $($AssignResult.status)" -sev Warning + continue + } + + $EnrollmentProfile = $Profiles | Where-Object { $_.id -eq $AssignResult.id } | Select-Object -First 1 + if ($EnrollmentProfile) { + $Assignments = @($AssignResult.body.value) + $EnrollmentProfile | Add-Member -NotePropertyName assignments -NotePropertyValue $Assignments -Force + } + } + } catch { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Failed to fetch assignments for Apple enrollment type profiles: $($_.Exception.Message)" -sev Warning + } + } + + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'IntuneAppleUserInitiatedEnrollmentProfiles' -Data @($Profiles) -AddCount -ClearOnEmpty + + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Cached $($Profiles.Count) Apple enrollment type profiles" -sev Debug + } catch { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Failed to cache Apple enrollment type profiles: $($_.Exception.Message)" -sev Error -LogData (Get-CippException -Exception $_) + } +} diff --git a/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheIntunePolicies.ps1 b/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheIntunePolicies.ps1 index b413692907..3327feda26 100644 --- a/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheIntunePolicies.ps1 +++ b/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheIntunePolicies.ps1 @@ -52,6 +52,7 @@ function Set-CIPPDBCacheIntunePolicies { foreach ($LegacyType in @( [PSCustomObject]@{ Type = 'WindowsAutopilotDeploymentProfiles'; CacheType = 'IntuneWindowsAutopilotDeploymentProfiles'; Uri = '/deviceManagement/windowsAutopilotDeploymentProfiles?$top=999&$expand=assignments' } [PSCustomObject]@{ Type = 'DeviceEnrollmentConfigurations'; CacheType = 'IntuneDeviceEnrollmentConfigurations'; Uri = '/deviceManagement/deviceEnrollmentConfigurations?$top=999'; FetchAssignments = $true } + [PSCustomObject]@{ Type = 'AppleUserInitiatedEnrollmentProfiles'; CacheType = 'IntuneAppleUserInitiatedEnrollmentProfiles'; Uri = '/deviceManagement/appleUserInitiatedEnrollmentProfiles?$top=999'; FetchAssignments = $true } [PSCustomObject]@{ Type = 'DeviceManagementScripts'; CacheType = 'IntuneDeviceManagementScripts'; Uri = '/deviceManagement/deviceManagementScripts?$top=999&$expand=assignments' } [PSCustomObject]@{ Type = 'MobileApps'; CacheType = 'IntuneMobileApps'; Uri = '/deviceAppManagement/mobileApps?$top=999&$select=id,displayName,description,publisher,isAssigned,createdDateTime,lastModifiedDateTime'; FetchAssignments = $true } )) { diff --git a/backend/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardAppleEnrollmentTypeProfile.ps1 b/backend/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardAppleEnrollmentTypeProfile.ps1 new file mode 100644 index 0000000000..f9652d773d --- /dev/null +++ b/backend/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardAppleEnrollmentTypeProfile.ps1 @@ -0,0 +1,256 @@ +function Invoke-CIPPStandardAppleEnrollmentTypeProfile { + <# + .FUNCTIONALITY + Internal + .COMPONENT + (APIName) AppleEnrollmentTypeProfile + .SYNOPSIS + (Label) Deploy Apple Enrollment Type Profile + .DESCRIPTION + (Helptext) Creates and manages an Apple user-initiated enrollment type profile (such as iOS/iPadOS web based device enrollment) and keeps it assigned to the configured groups. The tenant needs an Apple MDM push certificate for the enrollment itself to function. + (DocsDescription) Deploys an Apple user-initiated enrollment type profile through deviceManagement/appleUserInitiatedEnrollmentProfiles. The profile is matched by display name; the enrollment type (web based device enrollment, account driven user enrollment, or device enrollment with Company Portal), description and group assignments are kept in sync, with a wrong assignment repaired in place. Priority is only applied when the profile is first created, because reordering is relative to the other profiles in each tenant. + .NOTES + CAT + Intune Standards + TAG + "enrollment" + "apple" + "ios" + EXECUTIVETEXT + Ensures every tenant offers the same enrollment experience for Apple devices, such as web based enrollment for personal iPhones and iPads, without engineers configuring each tenant by hand. This keeps device onboarding consistent and makes it possible to report on which tenants are correctly configured. + ADDEDCOMPONENT + {"type":"textField","name":"standards.AppleEnrollmentTypeProfile.DisplayName","label":"Profile Display Name","required":true} + {"type":"textField","name":"standards.AppleEnrollmentTypeProfile.Description","label":"Profile Description","required":false} + {"type":"autoComplete","multiple":false,"creatable":false,"name":"standards.AppleEnrollmentTypeProfile.EnrollmentType","label":"Enrollment Type","options":[{"label":"Web based device enrollment","value":"webDeviceEnrollment"},{"label":"Account driven user enrollment","value":"accountDrivenUserEnrollment"},{"label":"Device enrollment with Company Portal","value":"device"}]} + {"type":"number","name":"standards.AppleEnrollmentTypeProfile.Priority","label":"Priority (applied when the profile is created)","defaultValue":1} + {"type":"radio","name":"standards.AppleEnrollmentTypeProfile.AssignTo","label":"Profile Assignment","options":[{"label":"Do not assign","value":"none"},{"label":"Assign to Custom Group","value":"customGroup"}]} + {"type":"textField","name":"standards.AppleEnrollmentTypeProfile.customGroup","label":"Custom group name(s). Comma separated, wildcards allowed.","required":false} + IMPACT + Medium Impact + ADDEDDATE + 2026-08-18 + POWERSHELLEQUIVALENT + Graph API - deviceManagement/appleUserInitiatedEnrollmentProfiles + RECOMMENDEDBY + DISABLEDFEATURES + {"report":false,"warn":false,"remediate":false} + REQUIREDCAPABILITIES + "INTUNE_A" + "MDM_Services" + "EMS" + "SCCM" + "MICROSOFTINTUNEPLAN1" + UPDATECOMMENTBLOCK + Run the Tools\Update-StandardsComments.ps1 script to update this comment block + .LINK + https://docs.cipp.app/user-documentation/tenant/standards/alignment/templates/available-standards + #> + + param($Tenant, $Settings) + + $TestResult = Test-CIPPStandardLicense -StandardName 'AppleEnrollmentTypeProfile' -TenantFilter $Tenant -Preset Intune + if ($TestResult -eq $false) { return $true } + + $DisplayName = "$($Settings.DisplayName)" + if ([string]::IsNullOrWhiteSpace($DisplayName)) { + Write-LogMessage -API 'Standards' -tenant $Tenant -message 'AppleEnrollmentTypeProfile: DisplayName is empty, skipping.' -sev Error + return + } + + $EnrollmentType = [string]($Settings.EnrollmentType.value ?? $Settings.EnrollmentType) + if ([string]::IsNullOrWhiteSpace($EnrollmentType)) { $EnrollmentType = 'webDeviceEnrollment' } + $Description = "$($Settings.Description)" + $Priority = if ([string]::IsNullOrWhiteSpace("$($Settings.Priority)")) { 1 } else { [int]"$($Settings.Priority)" } + $AssignTo = [string]($Settings.AssignTo.value ?? $Settings.AssignTo ?? 'none') + if ([string]::IsNullOrWhiteSpace($AssignTo)) { $AssignTo = 'none' } + + $ProfilesUri = 'https://graph.microsoft.com/beta/deviceManagement/appleUserInitiatedEnrollmentProfiles' + try { + $Profiles = @(New-GraphGetRequest -uri "$ProfilesUri`?`$top=999" -tenantid $Tenant) + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'Standards' -tenant $Tenant -message "AppleEnrollmentTypeProfile: Failed to retrieve enrollment type profiles: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage + return + } + $ExistingProfile = $Profiles | Where-Object { $_.displayName -eq $DisplayName } | Select-Object -First 1 + $ProfileExists = $null -ne $ExistingProfile + + # The list endpoint does not expand assignments, so they are read per profile. + $ExistingAssignments = @() + $AssignmentsReadable = $false + if ($ProfileExists) { + try { + $ExistingAssignments = @(New-GraphGetRequest -uri "$ProfilesUri/$($ExistingProfile.id)/assignments" -tenantid $Tenant) + $AssignmentsReadable = $true + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'Standards' -tenant $Tenant -message "AppleEnrollmentTypeProfile: Failed to read profile assignments: $($ErrorMessage.NormalizedError)" -sev Warning -LogData $ErrorMessage + } + } + + # The available enrollment type options compare as a normalized ownerType:enrollmentType + # set, so option order coming back from Graph can never register as drift. + $CurrentOptions = (@($ExistingProfile.availableEnrollmentTypeOptions) | Where-Object { $_ } | ForEach-Object { "$($_.ownerType):$($_.enrollmentType)" } | Sort-Object) -join ', ' + $ExpectedOptions = "personal:$EnrollmentType" + + $AssignmentsMatch = $null + $AssignmentDetail = $null + if ($ProfileExists -and $AssignTo -ne 'none' -and $AssignmentsReadable) { + try { + $AssignmentDetail = Compare-CIPPIntuneAssignments -ExistingAssignments $ExistingAssignments -ExpectedAssignTo $AssignTo -ExpectedCustomGroup "$($Settings.customGroup)" -PolicyType 'AppleEnrollmentTypeProfile' -TenantFilter $Tenant + # Unknown stays $null: a failed lookup is not a deviation. + $AssignmentsMatch = if ($AssignmentDetail.Unknown) { $null } else { $AssignmentDetail.Matched } + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'Standards' -tenant $Tenant -message "AppleEnrollmentTypeProfile: Failed to compare profile assignments: $($ErrorMessage.NormalizedError)" -sev Warning -LogData $ErrorMessage + } + } + + $CurrentValue = [PSCustomObject]@{ + profileExists = $ProfileExists + displayName = "$($ExistingProfile.displayName)" + description = "$($ExistingProfile.description)" + defaultEnrollmentType = "$($ExistingProfile.defaultEnrollmentType)" + enrollmentTypeOptions = $CurrentOptions + } + $ExpectedValue = [PSCustomObject]@{ + profileExists = $true + displayName = $DisplayName + description = $Description + defaultEnrollmentType = $EnrollmentType + enrollmentTypeOptions = $ExpectedOptions + } + + # A failed assignment lookup is unknown, not a deviation: leave the dimension out of the + # comparison entirely until it can be read, or drift records a deviation no run can clear. + if ($AssignTo -ne 'none' -and $null -ne $AssignmentsMatch) { + $CurrentValue | Add-Member -NotePropertyName 'isAssigned' -NotePropertyValue $AssignmentsMatch + $ExpectedValue | Add-Member -NotePropertyName 'isAssigned' -NotePropertyValue $true + if (-not $AssignmentsMatch) { + $AssignmentReason = @($AssignmentDetail.Reasons) -join '; ' + if ($AssignmentReason) { + $CurrentValue | Add-Member -NotePropertyName 'assignmentDifferences' -NotePropertyValue $AssignmentReason + } + } + } + + # The settings verdict stays separate from the assignment verdict so remediation can repair + # a wrong assignment in place instead of touching the profile itself. + $SettingsAreCorrect = $ProfileExists -and + ($CurrentValue.description -eq $ExpectedValue.description) -and + ($CurrentValue.defaultEnrollmentType -eq $ExpectedValue.defaultEnrollmentType) -and + ($CurrentValue.enrollmentTypeOptions -eq $ExpectedValue.enrollmentTypeOptions) + $StateIsCorrect = $SettingsAreCorrect -and $AssignmentsMatch -ne $false + + if ($Settings.remediate -eq $true) { + if ($StateIsCorrect) { + Write-LogMessage -API 'Standards' -tenant $Tenant -message "AppleEnrollmentTypeProfile: Profile '$DisplayName' already correctly configured" -sev Info + } else { + try { + $ProfileId = "$($ExistingProfile.id)" + if (-not $ProfileExists) { + $CreateBody = @{ + '@odata.type' = '#microsoft.graph.appleUserInitiatedEnrollmentProfile' + displayName = $DisplayName + description = $Description + platform = 'iOS' + priority = $Priority + defaultEnrollmentType = $EnrollmentType + availableEnrollmentTypeOptions = @( + @{ + '@odata.type' = '#microsoft.graph.appleOwnerTypeEnrollmentType' + ownerType = 'personal' + enrollmentType = $EnrollmentType + } + ) + } | ConvertTo-Json -Compress -Depth 10 + $NewProfile = New-GraphPostRequest -uri $ProfilesUri -tenantid $Tenant -body $CreateBody -type POST + $ProfileId = "$($NewProfile.id)" + Write-LogMessage -API 'Standards' -tenant $Tenant -message "AppleEnrollmentTypeProfile: Created profile '$DisplayName'" -sev Info + } elseif (-not $SettingsAreCorrect) { + $PatchBody = @{ + '@odata.type' = '#microsoft.graph.appleUserInitiatedEnrollmentProfile' + description = $Description + defaultEnrollmentType = $EnrollmentType + availableEnrollmentTypeOptions = @( + @{ + '@odata.type' = '#microsoft.graph.appleOwnerTypeEnrollmentType' + ownerType = 'personal' + enrollmentType = $EnrollmentType + } + ) + } | ConvertTo-Json -Compress -Depth 10 + $null = New-GraphPostRequest -uri "$ProfilesUri/$ProfileId" -tenantid $Tenant -body $PatchBody -type PATCH + Write-LogMessage -API 'Standards' -tenant $Tenant -message "AppleEnrollmentTypeProfile: Updated profile '$DisplayName'" -sev Info + } + + # Reconcile the assignments to exactly the configured groups. The profile type has + # no /assign action, so missing groups are added and everything else is removed + # one assignment at a time. + if ($AssignTo -eq 'customGroup' -and -not [string]::IsNullOrWhiteSpace($ProfileId) -and (-not $ProfileExists -or ($AssignmentsReadable -and $AssignmentsMatch -ne $true))) { + $ExpectedGroupIds = [System.Collections.Generic.List[string]]::new() + $AllGroups = New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/groups?$select=id,displayName&$top=999' -tenantid $Tenant + foreach ($Name in @("$($Settings.customGroup)".Split(',').Trim() | Where-Object { $_ })) { + # Square brackets are wildcard character classes to -like; group names + # containing them are literal. Matches Compare-CIPPIntuneAssignments. + $Pattern = $Name -replace '\[', '`[' -replace '\]', '`]' + $Matched = @($AllGroups | Where-Object { $_.displayName -like $Pattern } | Select-Object -ExpandProperty id) + if ($Matched.Count -eq 0) { + Write-LogMessage -API 'Standards' -tenant $Tenant -message "AppleEnrollmentTypeProfile: Group name '$Name' matches no group in this tenant" -sev Warning + } else { + $ExpectedGroupIds.AddRange([string[]]$Matched) + } + } + + # A name set that resolves to nothing must not strip a working profile bare: + # the compare keeps reporting the unresolved names, so deleting the existing + # assignments would add damage to a deviation remediation cannot clear anyway. + if ($ExpectedGroupIds.Count -eq 0) { + Write-LogMessage -API 'Standards' -tenant $Tenant -message "AppleEnrollmentTypeProfile: No configured group resolved for profile '$DisplayName'; leaving the existing assignments untouched" -sev Warning + } else { + $KeptGroupIds = [System.Collections.Generic.List[string]]::new() + foreach ($Assignment in $ExistingAssignments) { + # An assignment filter set through Graph would otherwise survive as a + # kept assignment while the comparison keeps flagging it - keep only + # clean group targets so the write converges with the compare. + $FilterId = "$($Assignment.target.deviceAndAppManagementAssignmentFilterId)" + $HasFilter = -not [string]::IsNullOrWhiteSpace($FilterId) -and $FilterId -ne '00000000-0000-0000-0000-000000000000' + $IsExpected = $Assignment.target.'@odata.type' -eq '#microsoft.graph.groupAssignmentTarget' -and $ExpectedGroupIds -contains "$($Assignment.target.groupId)" -and -not $HasFilter + if ($IsExpected) { + $KeptGroupIds.Add("$($Assignment.target.groupId)") + } else { + $null = New-GraphPostRequest -uri "$ProfilesUri/$ProfileId/assignments/$($Assignment.id)" -tenantid $Tenant -type DELETE + } + } + foreach ($GroupId in @($ExpectedGroupIds | Select-Object -Unique | Where-Object { $_ -notin $KeptGroupIds })) { + $AssignmentBody = @{ + target = @{ + '@odata.type' = '#microsoft.graph.groupAssignmentTarget' + groupId = $GroupId + } + } | ConvertTo-Json -Compress -Depth 10 + $null = New-GraphPostRequest -uri "$ProfilesUri/$ProfileId/assignments" -tenantid $Tenant -body $AssignmentBody -type POST + } + Write-LogMessage -API 'Standards' -tenant $Tenant -message "AppleEnrollmentTypeProfile: Reconciled assignments for profile '$DisplayName'" -sev Info + } + } + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'Standards' -tenant $Tenant -message "AppleEnrollmentTypeProfile: Failed to deploy profile '$DisplayName': $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage + } + } + } + + if ($Settings.alert -eq $true) { + if ($StateIsCorrect) { + Write-LogMessage -API 'Standards' -tenant $Tenant -message "AppleEnrollmentTypeProfile: Profile '$DisplayName' is correctly configured" -sev Info + } else { + Write-StandardsAlert -message "Apple enrollment type profile '$DisplayName' is not correctly configured" -object $CurrentValue -tenant $Tenant -standardName 'AppleEnrollmentTypeProfile' -standardId $Settings.standardId + Write-LogMessage -API 'Standards' -tenant $Tenant -message "AppleEnrollmentTypeProfile: Profile '$DisplayName' is not correctly configured" -sev Info + } + } + + if ($Settings.report -eq $true) { + Set-CIPPStandardsCompareField -FieldName 'standards.AppleEnrollmentTypeProfile' -CurrentValue $CurrentValue -ExpectedValue $ExpectedValue -TenantFilter $Tenant + } +} diff --git a/frontend/src/data/standards.json b/frontend/src/data/standards.json index 6e2febd341..b173b7dd0d 100644 --- a/frontend/src/data/standards.json +++ b/frontend/src/data/standards.json @@ -6509,6 +6509,68 @@ "recommendedBy": [], "requiredCapabilities": ["INTUNE_A", "MDM_Services", "EMS", "SCCM", "MICROSOFTINTUNEPLAN1"] }, + { + "name": "standards.AppleEnrollmentTypeProfile", + "cat": "Intune Standards", + "tag": ["enrollment", "apple", "ios"], + "disabledFeatures": { "report": false, "warn": false, "remediate": false }, + "helpText": "Creates and manages an Apple user-initiated enrollment type profile (such as iOS/iPadOS web based device enrollment) and keeps it assigned to the configured groups. The tenant needs an Apple MDM push certificate for the enrollment itself to function.", + "executiveText": "Ensures every tenant offers the same enrollment experience for Apple devices, such as web based enrollment for personal iPhones and iPads, without engineers configuring each tenant by hand. This keeps device onboarding consistent and makes it possible to report on which tenants are correctly configured.", + "docsDescription": "Deploys an Apple user-initiated enrollment type profile through deviceManagement/appleUserInitiatedEnrollmentProfiles. The profile is matched by display name; the enrollment type (web based device enrollment, account driven user enrollment, or device enrollment with Company Portal), description and group assignments are kept in sync, with a wrong assignment repaired in place. Priority is only applied when the profile is first created, because reordering is relative to the other profiles in each tenant.", + "addedComponent": [ + { + "type": "textField", + "name": "standards.AppleEnrollmentTypeProfile.DisplayName", + "label": "Profile Display Name", + "required": true + }, + { + "type": "textField", + "name": "standards.AppleEnrollmentTypeProfile.Description", + "label": "Profile Description", + "required": false + }, + { + "type": "autoComplete", + "multiple": false, + "creatable": false, + "name": "standards.AppleEnrollmentTypeProfile.EnrollmentType", + "label": "Enrollment Type", + "options": [ + { "label": "Web based device enrollment", "value": "webDeviceEnrollment" }, + { "label": "Account driven user enrollment", "value": "accountDrivenUserEnrollment" }, + { "label": "Device enrollment with Company Portal", "value": "device" } + ] + }, + { + "type": "number", + "name": "standards.AppleEnrollmentTypeProfile.Priority", + "label": "Priority (applied when the profile is created)", + "defaultValue": 1 + }, + { + "type": "radio", + "name": "standards.AppleEnrollmentTypeProfile.AssignTo", + "label": "Profile Assignment", + "options": [ + { "label": "Do not assign", "value": "none" }, + { "label": "Assign to Custom Group", "value": "customGroup" } + ] + }, + { + "type": "textField", + "name": "standards.AppleEnrollmentTypeProfile.customGroup", + "label": "Custom group name(s). Comma separated, wildcards allowed.", + "required": false + } + ], + "label": "Deploy Apple Enrollment Type Profile", + "impact": "Medium Impact", + "impactColour": "warning", + "addedDate": "2026-08-18", + "recommendedBy": [], + "requiredCapabilities": ["INTUNE_A", "MDM_Services", "EMS", "SCCM", "MICROSOFTINTUNEPLAN1"] + }, { "name": "standards.IntuneTemplate", "cat": "Templates", From 68e0c011f080ac7e598a1e6cd0ad205dc5dfb0b5 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Tue, 18 Aug 2026 23:25:57 +0800 Subject: [PATCH 157/226] refactor(orchestration): resolve priority fallback directly Fold the out-of-range check into the fallback condition instead of forcing the explicit value back to $null to re-enter the fallback branch. Same resolution order and results; one less indirection to read. --- .../Start-CIPPOrchestrator.ps1 | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/backend/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-CIPPOrchestrator.ps1 b/backend/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-CIPPOrchestrator.ps1 index 242ba6b7ea..b03862179a 100644 --- a/backend/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-CIPPOrchestrator.ps1 +++ b/backend/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-CIPPOrchestrator.ps1 @@ -108,19 +108,16 @@ function Start-CIPPOrchestrator { # The queue claims strictly by priority bucket (P00 first), so this decides who runs # when the limiter is saturated. Resolution order: - # 1. Explicit Priority on the InputObject (range-guarded: the store clamps into 0-99 - # buckets, so a stray negative would silently land in the critical P00 bucket). + # 1. Explicit Priority on the InputObject, when it is a valid bucket (out-of-range values + # take the fallback: the store clamps into 0-99 buckets, so a stray negative would + # otherwise silently land in the critical P00 bucket). # 2. The enclosing run's priority (from the stamped context) — a child run belongs to # its parent's band, so a baseline run's follow-up no longer drops back to the default. # 3. P2 for HTTP-triggered orchestrations — user-initiated work must not queue behind # background fan-outs. # 4. The historical default 4 (timers and other background starters). - $Priority = $InputObject.Priority - if ($null -ne $Priority) { - $Priority = [int]$Priority - if ($Priority -lt 0 -or $Priority -gt 99) { $Priority = $null } - } - if ($null -eq $Priority) { + $Priority = if ($null -ne $InputObject.Priority) { [int]$InputObject.Priority } + if ($null -eq $Priority -or $Priority -lt 0 -or $Priority -gt 99) { $Priority = if ($null -ne $OpContext) { $OpContext.PSObject.Properties['Priority'].Value } if ($null -eq $Priority) { $Priority = if ($null -ne $OpContext -and $OpContext.Category -eq 'HTTP') { 2 } else { 4 } From a4cea9723184cdc11f0f184d423175f230f41677 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Tue, 18 Aug 2026 23:26:03 +0800 Subject: [PATCH 158/226] feat(support): add manual recording mode to support bundle Adds a 'Record Actions' mode to the support bundle dialog that lets users close the dialog, reproduce an issue, and return to stop recording. A persistent chip indicator is shown while recording is active. Also captures request bodies in the network recording and renames internal serialization fields for clarity. --- .../CippSupportBundleDialog.jsx | 311 ++++++++++++------ frontend/src/pages/_app.js | 24 +- frontend/src/utils/support-bundle.js | 45 ++- 3 files changed, 263 insertions(+), 117 deletions(-) diff --git a/frontend/src/components/CippComponents/CippSupportBundleDialog.jsx b/frontend/src/components/CippComponents/CippSupportBundleDialog.jsx index 5872d1b057..e90efc2179 100644 --- a/frontend/src/components/CippComponents/CippSupportBundleDialog.jsx +++ b/frontend/src/components/CippComponents/CippSupportBundleDialog.jsx @@ -13,7 +13,12 @@ import { Switch, Typography, } from '@mui/material' -import { Download, PlayArrow } from '@mui/icons-material' +import { + Download, + FiberManualRecord, + PlayArrow, + Stop, +} from '@mui/icons-material' import { useQueryClient } from '@tanstack/react-query' import { useSettings } from '../../hooks/use-settings' import { @@ -28,7 +33,7 @@ import { // The fixed sections go through fetch() rather than axios on purpose: the armed recorder // captures all axios traffic, and the network section should contain only what the page -// itself requested. +// (or the user's recorded actions) actually requested. const fetchJson = async (url) => { try { const response = await fetch(url, { credentials: 'include' }) @@ -39,7 +44,7 @@ const fetchJson = async (url) => { } } -const CippSupportBundleDialog = ({ open, onClose }) => { +const CippSupportBundleDialog = ({ open, onClose, onRecordingChange }) => { const queryClient = useQueryClient() const settings = useSettings() const [phase, setPhase] = useState('options') @@ -48,110 +53,160 @@ const CippSupportBundleDialog = ({ open, onClose }) => { const [redactionSummary, setRedactionSummary] = useState(null) const [progress, setProgress] = useState(0) const [errorMessage, setErrorMessage] = useState(null) - // Invalidates a run when the dialog closes mid-collection, so a stale run cannot - // finish later and overwrite the state of a newer one. + // True while a manual recording is running. It deliberately survives the dialog being + // closed - the user closes it, reproduces the issue, and comes back to stop. The + // dialog stays mounted in _app, so this state outlives the close. + const [recording, setRecording] = useState(false) + // Invalidates a run when it is cancelled, so a stale run cannot finish later and + // overwrite the state of a newer one. const runToken = useRef(0) - const pollRef = useRef(null) - - const stopCollecting = () => { - disarmSupportRecorder() - if (pollRef.current) { - clearInterval(pollRef.current) - pollRef.current = null - } - } + const modeRef = useRef('page') - // Each open starts back at the options screen. State is adjusted during render on the - // open transition (the React-sanctioned alternative to setState-in-effect); the close - // effect below only cancels the run and disarms the recorder — external side effects, - // no state updates. + // Reopening lands on the options screen - unless a manual recording is running, in + // which case it lands back on the recording screen. Adjusted during render (the + // React-sanctioned alternative to setState-in-effect). const [prevOpen, setPrevOpen] = useState(open) if (open !== prevOpen) { setPrevOpen(open) if (open) { - setPhase('options') - setBundle(null) - setRedactionSummary(null) - setErrorMessage(null) - setProgress(0) + if (recording) { + setPhase('recording') + setProgress(getSupportRecordingCount()) + } else { + setPhase('options') + setBundle(null) + setRedactionSummary(null) + setErrorMessage(null) + setProgress(0) + } } } + // Closing cancels a page capture in flight; a manual recording keeps running. useEffect(() => { - if (!open) { + if (!open && !recording) { runToken.current++ - stopCollecting() + disarmSupportRecorder() + } + }, [open, recording]) + + // Live request counter while the dialog is showing an armed recorder. + useEffect(() => { + if (!open || (phase !== 'collecting' && phase !== 'recording')) return + const interval = setInterval( + () => setProgress(getSupportRecordingCount()), + 300 + ) + return () => clearInterval(interval) + }, [open, phase]) + + const assemble = async (token) => { + const localVersion = await fetchJson('/version.json') + const [instance, me, authMe] = await Promise.all([ + fetchJson( + `/api/GetVersion?LocalVersion=${encodeURIComponent(localVersion?.version ?? '')}` + ), + fetchJson('/api/me'), + fetchJson('/.auth/me'), + ]) + if (token !== runToken.current) return + disarmSupportRecorder() + const network = getSupportRecording() + let assembled = { + schemaVersion: 1, + generatedAt: new Date().toISOString(), + instanceHostname: window.location.hostname, + redaction: { enabled: redact }, + client: { + captureMode: modeRef.current, + path: window.location.pathname, + tenant: settings.currentTenant ?? null, + userAgent: navigator.userAgent, + frontendVersion: localVersion?.version ?? null, + }, + instance, + user: { me, authMe }, + network, } - }, [open]) + // Tokens are live credentials and are stripped from every bundle, before and + // independent of the optional identifier redaction. + const stripped = stripTokens(assembled) + assembled = stripped.bundle + assembled.tokensRemoved = stripped.removed + if (redact) { + // The instance's own hostname identifies the installation, not a customer + // tenant - support needs it, so it survives redaction. + const redacted = redactBundle(assembled, { + keepHostnames: [window.location.hostname], + }) + assembled = redacted.bundle + assembled.redaction = { enabled: true, ...redacted.summary } + setRedactionSummary(redacted.summary) + } + setBundle(assembled) + setProgress(network.length) + setPhase('ready') + } - const handleStart = async () => { + const failRun = (token, error) => { + if (token !== runToken.current) return + disarmSupportRecorder() + setErrorMessage(String(error?.message ?? error)) + setPhase('error') + } + + const handleCapturePage = async () => { const token = ++runToken.current + modeRef.current = 'page' setPhase('collecting') setProgress(0) armSupportRecorder() - pollRef.current = setInterval( - () => setProgress(getSupportRecordingCount()), - 300 - ) try { - // Force every query mounted on the current page to hit the API again — the + // Force every query mounted on the current page to hit the API again - the // recorder only sees axios traffic, so cache reads must become real requests. - const refetchPromise = queryClient.refetchQueries({ type: 'active' }) - const localVersion = await fetchJson('/version.json') - const [instance, me, authMe] = await Promise.all([ - fetchJson( - `/api/GetVersion?LocalVersion=${encodeURIComponent(localVersion?.version ?? '')}` - ), - fetchJson('/api/me'), - fetchJson('/.auth/me'), - ]) - await refetchPromise - if (token !== runToken.current) return - stopCollecting() - const network = getSupportRecording() - let assembled = { - schemaVersion: 1, - generatedAt: new Date().toISOString(), - instanceHostname: window.location.hostname, - redaction: { enabled: redact }, - client: { - path: window.location.pathname, - tenant: settings.currentTenant ?? null, - userAgent: navigator.userAgent, - frontendVersion: localVersion?.version ?? null, - }, - instance, - user: { me, authMe }, - network, - } - // Tokens are live credentials and are stripped from every bundle, before and - // independent of the optional identifier redaction. - const stripped = stripTokens(assembled) - assembled = stripped.bundle - assembled.tokensRemoved = stripped.removed - if (redact) { - // The instance's own hostname identifies the installation, not a customer - // tenant — support needs it, so it survives redaction. - const redacted = redactBundle(assembled, { - keepHostnames: [window.location.hostname], - }) - assembled = redacted.bundle - assembled.redaction = { enabled: true, ...redacted.summary } - setRedactionSummary(redacted.summary) - } - setBundle(assembled) - setProgress(network.length) - setPhase('ready') + await queryClient.refetchQueries({ type: 'active' }) + await assemble(token) } catch (error) { - if (token !== runToken.current) return - stopCollecting() - setErrorMessage(String(error?.message ?? error)) - setPhase('error') + failRun(token, error) } } + const handleStartRecording = () => { + ++runToken.current + modeRef.current = 'recording' + setRecording(true) + onRecordingChange?.(true) + armSupportRecorder() + onClose() + } + + const handleStopRecording = async () => { + const token = ++runToken.current + setRecording(false) + onRecordingChange?.(false) + setPhase('collecting') + try { + await assemble(token) + } catch (error) { + failRun(token, error) + } + } + + const handleDiscardRecording = () => { + ++runToken.current + setRecording(false) + onRecordingChange?.(false) + disarmSupportRecorder() + setPhase('options') + setProgress(0) + } + const failedCount = bundle?.network?.filter((call) => !call.success).length ?? 0 + const capturedFrom = + bundle?.client?.captureMode === 'recording' + ? 'during the recording' + : 'from this page' return ( @@ -160,9 +215,10 @@ const CippSupportBundleDialog = ({ open, onClose }) => { {phase === 'options' && ( - This refreshes the current page's data and captures the API - requests behind it, together with the instance version, hosting - and update details, and your signed-in identity and roles + Capture this page's API requests now, or record while you + reproduce an issue. Either way the file also includes the instance + version, hosting and update details, and your signed-in identity + and roles. { /> )} + {phase === 'recording' && ( + + + + + Recording — {progress} request{progress === 1 ? '' : 's'}{' '} + captured so far. + + + + Close this dialog, reproduce the issue, then click the recording + indicator to come back and stop. Reloading the browser discards + the recording. + + + )} {phase === 'collecting' && ( - Refreshing the current page's data — {progress} request - {progress === 1 ? '' : 's'} captured... + Collecting — {progress} request{progress === 1 ? '' : 's'}{' '} + captured... )} @@ -188,7 +260,7 @@ const CippSupportBundleDialog = ({ open, onClose }) => { Captured {bundle.network.length} request - {bundle.network.length === 1 ? '' : 's'} from this page + {bundle.network.length === 1 ? '' : 's'} {capturedFrom} {failedCount > 0 ? `, of which ${failedCount} failed` : ''}, along with the instance version, hosting and update details, and your signed-in identity and roles. @@ -218,27 +290,52 @@ const CippSupportBundleDialog = ({ open, onClose }) => { )} - {phase === 'options' && ( - + <> + + + + + )} + {phase === 'recording' && ( + <> + + + + )} - {phase !== 'options' && ( - + {(phase === 'collecting' || phase === 'ready' || phase === 'error') && ( + <> + + + )} diff --git a/frontend/src/pages/_app.js b/frontend/src/pages/_app.js index eebdd0115d..a3f69ebe27 100644 --- a/frontend/src/pages/_app.js +++ b/frontend/src/pages/_app.js @@ -54,10 +54,11 @@ import { Gavel, ClearAll as ClearAllIcon, SupportAgent, + FiberManualRecord, } from '@mui/icons-material' import { School as TutorialIcon } from '@mui/icons-material' import { getHelpLinks, clearCippCache } from '../utils/help-links' -import { SvgIcon } from '@mui/material' +import { Chip, SvgIcon } from '@mui/material' import React, { useEffect, useState, useRef } from 'react' import { usePathname } from 'next/navigation' import { useRouter } from 'next/router' @@ -95,6 +96,7 @@ const App = (props) => { const [dateLocale, setDateLocale] = useState(enUS) const [tutorialDialogOpen, setTutorialDialogOpen] = useState(false) const [supportBundleOpen, setSupportBundleOpen] = useState(false) + const [supportRecording, setSupportRecording] = useState(false) useEffect(() => { if (typeof window === 'undefined') return @@ -282,11 +284,31 @@ const App = (props) => { setSupportBundleOpen(false)} + onRecordingChange={setSupportRecording} /> + {supportRecording && !supportBundleOpen && ( + } + label="Recording — click to stop" + color="error" + onClick={() => setSupportBundleOpen(true)} + sx={{ + position: 'fixed', + bottom: 20, + // Pinned left of the speed dial FAB (46px wide + 12px gap), + // which itself shifts left when devtools is enabled. + right: + (settings.isInitialized && settings?.showDevtools === true + ? 60 + : 12) + 58, + zIndex: (muiTheme) => muiTheme.zIndex.speedDial, + }} + /> + )} } diff --git a/frontend/src/utils/support-bundle.js b/frontend/src/utils/support-bundle.js index deb3114f2a..421d7b4d0c 100644 --- a/frontend/src/utils/support-bundle.js +++ b/frontend/src/utils/support-bundle.js @@ -14,16 +14,19 @@ let armed = false let seq = 0 let calls = [] -const serializeBody = (data, responseType) => { - if (data === null || data === undefined) return { body: null } +const serializeValue = (data, responseType) => { + if (data === null || data === undefined) return { value: null } if ( responseType === 'blob' || (typeof Blob !== 'undefined' && data instanceof Blob) ) { return { - body: ``, + value: ``, } } + if (typeof FormData !== 'undefined' && data instanceof FormData) { + return { value: '' } + } let text try { text = typeof data === 'string' ? data : JSON.stringify(data) @@ -31,10 +34,24 @@ const serializeBody = (data, responseType) => { text = String(data) } if (typeof text === 'string' && text.length > MAX_BODY_CHARS) { - return { body: text.slice(0, MAX_BODY_CHARS), bodyTruncated: true } + return { value: text.slice(0, MAX_BODY_CHARS), truncated: true } } // Small bodies keep their shape so the bundle stays readable as plain JSON. - return { body: typeof data === 'string' ? data : data } + return { value: typeof data === 'string' ? data : data } +} + +// By response time axios has already transformed the request payload into its wire form, +// which for CIPP means a JSON string. Parse it back so the recorded requestBody is a +// readable object rather than an escaped string inside the bundle. +const parseMaybeJson = (data) => { + if (typeof data !== 'string') return data + const trimmed = data.trim() + if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) return data + try { + return JSON.parse(trimmed) + } catch { + return data + } } const record = (config, response, error) => { @@ -43,7 +60,7 @@ const record = (config, response, error) => { // keeps a call from being recorded twice. config.cippSupportRecorded = true const { start, seq: n } = config.cippSupportMeta - calls.push({ + const entry = { seq: n, startedAt: new Date(start).toISOString(), durationMs: Date.now() - start, @@ -52,9 +69,19 @@ const record = (config, response, error) => { params: config.params ?? null, status: response?.status ?? null, success: !error, - ...(error ? { errorMessage: String(error.message ?? error) } : {}), - ...serializeBody(response?.data, config.responseType), - }) + } + if (error) entry.errorMessage = String(error.message ?? error) + // The payload the client SENT matters as much as what came back - a failing write + // usually fails because of what was in it. + if (config.data !== undefined) { + const requestBody = serializeValue(parseMaybeJson(config.data)) + entry.requestBody = requestBody.value + if (requestBody.truncated) entry.requestBodyTruncated = true + } + const responseBody = serializeValue(response?.data, config.responseType) + entry.responseBody = responseBody.value + if (responseBody.truncated) entry.responseBodyTruncated = true + calls.push(entry) } axios.interceptors.request.use((config) => { From dec62136bb2301d60dc11111b92c04cf26d22481 Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Tue, 18 Aug 2026 17:29:13 +0200 Subject: [PATCH 159/226] add users directly from groups menu --- .../identity/administration/groups/index.js | 76 +++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/frontend/src/pages/identity/administration/groups/index.js b/frontend/src/pages/identity/administration/groups/index.js index 17d781c13c..6da25fb47d 100644 --- a/frontend/src/pages/identity/administration/groups/index.js +++ b/frontend/src/pages/identity/administration/groups/index.js @@ -12,6 +12,7 @@ import { GroupSharp, CloudSync, RocketLaunch, + PersonAdd, } from '@mui/icons-material' import { Stack } from '@mui/system' import { useState } from 'react' @@ -66,6 +67,81 @@ const Page = () => { icon: , color: 'success', }, + { + label: 'Add Member', + type: 'POST', + url: '/api/EditGroup', + icon: , + customDataformatter: (row, action, formData) => { + // Members picked in the dialog already carry {label, value: id, addedFields} + const addMember = [...(formData.AddMember ?? [])] + // CSV rows only carry a userPrincipalName; without a value the backend + // resolves the directory object id itself + ;(formData.bulkMember ?? []).forEach((csvRow) => { + const upnKey = Object.keys(csvRow).find( + (key) => key.trim().toLowerCase() === 'userprincipalname' + ) + const userPrincipalName = upnKey ? csvRow[upnKey]?.trim() : undefined + if (userPrincipalName) { + addMember.push({ + label: userPrincipalName, + addedFields: { userPrincipalName: userPrincipalName }, + }) + } + }) + + // Handle multiple groups - return an array of requests (one per group) + const selectedGroups = Array.isArray(row) ? row : [row] + return selectedGroups.map((group) => ({ + AddMember: addMember, + tenantFilter: group.Tenant ?? currentTenant, + groupId: group.id, + groupName: group.displayName, + groupType: group.groupType, + })) + }, + fields: [ + { + type: 'autoComplete', + name: 'AddMember', + label: 'Select users to add as members', + multiple: true, + creatable: false, + api: { + url: '/api/ListGraphRequest', + data: { + Endpoint: 'users', + $select: 'id,displayName,userPrincipalName', + $top: 999, + $count: true, + }, + dataKey: 'Results', + labelField: (user) => `${user.displayName} (${user.userPrincipalName})`, + valueField: 'id', + addedField: { + userPrincipalName: 'userPrincipalName', + displayName: 'displayName', + }, + queryKey: 'ListUsersAutoComplete', + showRefresh: true, + }, + validators: { + validate: (value, formValues) => + (Array.isArray(value) && value.length > 0) || + (Array.isArray(formValues.bulkMember) && formValues.bulkMember.length > 0) || + 'Select at least one user or upload a CSV', + }, + }, + { + type: 'CSVReader', + name: 'bulkMember', + }, + ], + confirmText: + 'Select the users to add as members to [displayName], or drop a CSV file with a userPrincipalName column to bulk add members.', + multiPost: false, + allowResubmit: true, + }, { label: 'Set Global Address List Visibility', type: 'POST', From 067ed77464987a5c2b585713a5dea72fb867671a Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Tue, 18 Aug 2026 17:38:06 +0200 Subject: [PATCH 160/226] translate license shapes and report update. --- .../Push-ExecGenerateReportBuilderReport.ps1 | 24 +++++++++++++++++-- .../Invoke-CippTestGenericTest002.ps1 | 7 +++--- .../tools/report-builder/builder/index.js | 12 +++++++++- 3 files changed, 37 insertions(+), 6 deletions(-) diff --git a/backend/Modules/CIPPCore/Public/Tools/Push-ExecGenerateReportBuilderReport.ps1 b/backend/Modules/CIPPCore/Public/Tools/Push-ExecGenerateReportBuilderReport.ps1 index c63f2fe877..e85e910bba 100644 --- a/backend/Modules/CIPPCore/Public/Tools/Push-ExecGenerateReportBuilderReport.ps1 +++ b/backend/Modules/CIPPCore/Public/Tools/Push-ExecGenerateReportBuilderReport.ps1 @@ -59,6 +59,26 @@ function Push-ExecGenerateReportBuilderReport { throw 'No blocks provided and no template found' } + # Licence assignments come out of the users cache as objects carrying skuId GUIDs; a + # report reader wants product names. Any cell shaped like a licence assignment (an object, + # or array of objects, with a skuId property) is rendered as the display names instead. + $SkuConversionTable = $null + if ($ParsedBlocks | Where-Object { $_.type -eq 'database' -and $_.dbType }) { + $SkuConversionTable = [System.IO.File]::ReadAllText((Join-Path $env:CIPPRootPath 'Config\ConversionTable.csv')) | ConvertFrom-Csv + } + $ResolveCellValue = { + param($Value) + $Items = @($Value) + if ($Items.Count -eq 0 -or $null -eq $Items[0] -or -not $Items[0].PSObject.Properties['skuId']) { + return $Value + } + $Names = foreach ($Assignment in $Items) { + $Resolved = Convert-SKUname -SkuID $Assignment.skuId -ConvertTable $SkuConversionTable + if ($Resolved -is [string] -and $Resolved) { $Resolved } else { $Assignment.skuId } + } + return ($Names -join ', ') + } + # For test blocks that are NOT static, fetch fresh test results $TestResults = $null $HasLiveTests = $ParsedBlocks | Where-Object { $_.type -eq 'test' -and $_.static -ne $true } @@ -102,7 +122,7 @@ function Push-ExecGenerateReportBuilderReport { $Obj = [ordered]@{} foreach ($Header in $SelectedHeaders) { $Val = $Row.$Header - $Obj[$Header] = if ($null -ne $Val) { $Val } else { '' } + $Obj[$Header] = if ($null -ne $Val) { & $ResolveCellValue $Val } else { '' } } [PSCustomObject]$Obj }) @@ -202,7 +222,7 @@ function Push-ExecGenerateReportBuilderReport { $Obj = [ordered]@{} foreach ($Header in $SelectedHeaders) { $Val = $Row.$Header - $Obj[$Header] = if ($null -ne $Val) { $Val } else { '' } + $Obj[$Header] = if ($null -ne $Val) { & $ResolveCellValue $Val } else { '' } } [PSCustomObject]$Obj }) diff --git a/backend/Modules/CIPPTests/Public/Tests/GenericTests/Identity/Invoke-CippTestGenericTest002.ps1 b/backend/Modules/CIPPTests/Public/Tests/GenericTests/Identity/Invoke-CippTestGenericTest002.ps1 index 75d99ab942..68164d7333 100644 --- a/backend/Modules/CIPPTests/Public/Tests/GenericTests/Identity/Invoke-CippTestGenericTest002.ps1 +++ b/backend/Modules/CIPPTests/Public/Tests/GenericTests/Identity/Invoke-CippTestGenericTest002.ps1 @@ -44,15 +44,16 @@ function Invoke-CippTestGenericTest002 { $Result = [System.Text.StringBuilder]::new("**Total Licensed Users:** $($UserLicenseMap.Count)`n`n") - $null = $Result.Append("| User | Licenses |`n") - $null = $Result.Append("|------|----------|`n") + $null = $Result.Append("| User | Email | Licenses |`n") + $null = $Result.Append("|------|-------|----------|`n") $SortedUsers = $UserLicenseMap.GetEnumerator() | Sort-Object { $_.Value.DisplayName } $DisplayCount = 0 foreach ($Entry in $SortedUsers) { $DisplayName = ConvertTo-CippMarkdownCell -Value $Entry.Value.DisplayName + $Email = ConvertTo-CippMarkdownCell -Value $Entry.Key $LicList = ($Entry.Value.Licenses | Sort-Object) -join ', ' - $null = $Result.Append("| $DisplayName | $LicList |`n") + $null = $Result.Append("| $DisplayName | $Email | $LicList |`n") $DisplayCount++ if ($DisplayCount -ge 500) { break } } diff --git a/frontend/src/pages/tools/report-builder/builder/index.js b/frontend/src/pages/tools/report-builder/builder/index.js index 39c38649d6..a28ee52cf4 100644 --- a/frontend/src/pages/tools/report-builder/builder/index.js +++ b/frontend/src/pages/tools/report-builder/builder/index.js @@ -34,6 +34,7 @@ import CippButtonCard from '../../../../components/CippCards/CippButtonCard' import ReactMarkdown from 'react-markdown' import remarkGfm from 'remark-gfm' import { renderCustomScriptMarkdownTemplate } from '../../../../utils/customScriptTemplate' +import { getCippLicenseTranslation } from '../../../../utils/get-cipp-license-translation' import { escapeTableCell, isTableSeparatorRow, @@ -740,6 +741,14 @@ const DatabaseBlock = ({ } /* ── Format database content helper ─────────────────────── */ + +// License assignments come out of the cache as objects carrying skuId GUIDs; render the product +// names instead. Matches the shape check the backend applies when the report is generated. +const isLicenseAssignmentValue = (val) => { + const items = Array.isArray(val) ? val : [val] + return items.length > 0 && items.every((v) => v && typeof v === 'object' && 'skuId' in v) +} + const formatDatabaseContent = (data, selectedHeaders, format) => { if (!data || !selectedHeaders || selectedHeaders.length === 0) return '' @@ -750,7 +759,8 @@ const formatDatabaseContent = (data, selectedHeaders, format) => { const filtered = rows.map((row) => { const obj = {} selectedHeaders.forEach((h) => { - obj[h] = row[h] !== undefined && row[h] !== null ? row[h] : '' + const val = row[h] !== undefined && row[h] !== null ? row[h] : '' + obj[h] = isLicenseAssignmentValue(val) ? getCippLicenseTranslation(val).join(', ') : val }) return obj }) From 30cb11bfd4e4f6dfae1e3cead2ca988102a27e2c Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Tue, 18 Aug 2026 17:44:46 +0200 Subject: [PATCH 161/226] use actual report. --- .../Push-ExecGenerateReportBuilderReport.ps1 | 25 +++++++++++++------ 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/backend/Modules/CIPPCore/Public/Tools/Push-ExecGenerateReportBuilderReport.ps1 b/backend/Modules/CIPPCore/Public/Tools/Push-ExecGenerateReportBuilderReport.ps1 index e85e910bba..0c2dce55bb 100644 --- a/backend/Modules/CIPPCore/Public/Tools/Push-ExecGenerateReportBuilderReport.ps1 +++ b/backend/Modules/CIPPCore/Public/Tools/Push-ExecGenerateReportBuilderReport.ps1 @@ -60,23 +60,32 @@ function Push-ExecGenerateReportBuilderReport { } # Licence assignments come out of the users cache as objects carrying skuId GUIDs; a - # report reader wants product names. Any cell shaped like a licence assignment (an object, - # or array of objects, with a skuId property) is rendered as the display names instead. - $SkuConversionTable = $null + # report reader wants product names. The tenant's LicenseOverview cache already carries + # the display name per SKU with the ExcludedLicenses table applied, so cells shaped like + # licence assignments render through it: known SKUs become their product name and + # excluded SKUs drop out, matching every other licence view in CIPP. Without overview + # data the cell is left untouched rather than guessed at. + $LicenseNamesBySkuId = @{} if ($ParsedBlocks | Where-Object { $_.type -eq 'database' -and $_.dbType }) { - $SkuConversionTable = [System.IO.File]::ReadAllText((Join-Path $env:CIPPRootPath 'Config\ConversionTable.csv')) | ConvertFrom-Csv + try { + foreach ($License in @(New-CIPPDbRequest -TenantFilter $TenantFilter -Type 'LicenseOverview' -Fields 'License', 'skuId')) { + if ($License.skuId) { $LicenseNamesBySkuId[([string]$License.skuId).ToLowerInvariant()] = [string]$License.License } + } + } catch { + Write-LogMessage -API 'ReportBuilder' -tenant $TenantFilter -message "Could not load the licence overview cache; licence columns will show raw SKU ids: $($_.Exception.Message)" -Sev 'Warning' + } } $ResolveCellValue = { param($Value) $Items = @($Value) - if ($Items.Count -eq 0 -or $null -eq $Items[0] -or -not $Items[0].PSObject.Properties['skuId']) { + if ($LicenseNamesBySkuId.Count -eq 0 -or $Items.Count -eq 0 -or $null -eq $Items[0] -or -not $Items[0].PSObject.Properties['skuId']) { return $Value } $Names = foreach ($Assignment in $Items) { - $Resolved = Convert-SKUname -SkuID $Assignment.skuId -ConvertTable $SkuConversionTable - if ($Resolved -is [string] -and $Resolved) { $Resolved } else { $Assignment.skuId } + $Name = $LicenseNamesBySkuId[([string]$Assignment.skuId).ToLowerInvariant()] + if ($Name) { $Name } } - return ($Names -join ', ') + return (@($Names) -join ', ') } # For test blocks that are NOT static, fetch fresh test results From 32a4e5e828eafc2905ad6e840a07f7ce7611389d Mon Sep 17 00:00:00 2001 From: Jacob Newman Date: Tue, 18 Aug 2026 17:20:22 +0100 Subject: [PATCH 162/226] feat(halo): add configurable ticket request source Halo records tickets created over the API as "Manual" unless the payload carries a source, so CIPP's tickets are indistinguishable from ones an engineer logged by hand when reporting on ticket origin, SLAs or service reviews. Adds an optional Request Source setting to the HaloPSA integration. Create a source in Halo, pick it here, and it is stamped on every ticket CIPP raises. Left blank nothing is sent and Halo applies its own default, so existing installs behave exactly as before. Request sources are lookup type 22 and are instance-wide rather than scoped to a ticket type, so they get their own List key instead of joining HaloPSAFields, which is re-fetched per dropdown and on every ticket type change. Source ids include 0 (Email) and negatives (Halo's built-in integration sources), and both $null -as [int] and '' -as [int] evaluate to 0, so the payload guard checks presence before parsing rather than reusing the truthiness/-gt 0 pattern the priority field uses. Tests cover that, the unconfigured case and the consolidation path. --- backend/Config/openapi.json | 1 + .../Invoke-ExecExtensionMapping.ps1 | 9 ++ .../Invoke-ExecHaloPSATestTicket.ps1 | 2 +- .../Public/Halo/Get-HaloRequestSource.ps1 | 48 +++++++ .../Public/Halo/New-HaloPSATicket.ps1 | 16 +++ .../Extensions/New-HaloPSATicket.Tests.ps1 | 132 ++++++++++++++++++ .../cipp/integrations/halopsa.md | 1 + frontend/src/data/Extensions.json | 26 ++++ 8 files changed, 234 insertions(+), 1 deletion(-) create mode 100644 backend/Modules/CippExtensions/Public/Halo/Get-HaloRequestSource.ps1 create mode 100644 backend/Tests/Extensions/New-HaloPSATicket.Tests.ps1 diff --git a/backend/Config/openapi.json b/backend/Config/openapi.json index 03b5cc6a5a..51b8bda672 100644 --- a/backend/Config/openapi.json +++ b/backend/Config/openapi.json @@ -20571,6 +20571,7 @@ "enum": [ "HaloPSA", "HaloPSAFields", + "HaloPSARequestSources", "Hudu", "HuduFields", "NinjaOne", diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Extensions/Invoke-ExecExtensionMapping.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Extensions/Invoke-ExecExtensionMapping.ps1 index 3acb04722b..5179bfdc3d 100644 --- a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Extensions/Invoke-ExecExtensionMapping.ps1 +++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Extensions/Invoke-ExecExtensionMapping.ps1 @@ -48,6 +48,15 @@ Function Invoke-ExecExtensionMapping { 'Priorities' = $Priorities } } + 'HaloPSARequestSources' { + # Request sources are instance-wide rather than scoped to a ticket type, so they get their + # own List key instead of joining HaloPSAFields. That key is fetched once per dropdown and + # again on every ticket type change, and folding an unscoped lookup into it would add a + # Halo API call to each of those for a list that never changes. + $Result = @{ + 'RequestSources' = @(Get-HaloRequestSource) + } + } 'PWPushFields' { $Accounts = Get-PwPushAccount $Result = @{ diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Extensions/Invoke-ExecHaloPSATestTicket.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Extensions/Invoke-ExecHaloPSATestTicket.ps1 index f01ef36490..c20bedf478 100644 --- a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Extensions/Invoke-ExecHaloPSATestTicket.ps1 +++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Extensions/Invoke-ExecHaloPSATestTicket.ps1 @@ -38,7 +38,7 @@ Function Invoke-ExecHaloPSATestTicket { $Description = @"

    This is a test ticket created by CIPP at $Timestamp to verify end-to-end HaloPSA delivery.

    Target client: $ClientName (id $ClientId).

    -

    It is raised the same way CIPP raises alert tickets, so the configured Ticket Type and Default Priority should both apply.

    +

    It is raised the same way CIPP raises alert tickets, so the configured Ticket Type, Request Source and Default Priority should all apply.

    It is safe to close this ticket.

    "@ diff --git a/backend/Modules/CippExtensions/Public/Halo/Get-HaloRequestSource.ps1 b/backend/Modules/CippExtensions/Public/Halo/Get-HaloRequestSource.ps1 new file mode 100644 index 0000000000..1aa8179773 --- /dev/null +++ b/backend/Modules/CippExtensions/Public/Halo/Get-HaloRequestSource.ps1 @@ -0,0 +1,48 @@ +function Get-HaloRequestSource { + <# + .SYNOPSIS + Get the HaloPSA request sources available to stamp on CIPP-generated tickets. + .DESCRIPTION + Halo records tickets created over the API as "Manual" unless the payload carries a source, + so CIPP's tickets are indistinguishable from ones an agent raised by hand. Request sources + have no dedicated endpoint - they are lookup type 22 - and the list is instance-wide rather + than scoped to a ticket type, so unlike the priority and outcome lookups this takes no + TicketType parameter. + + Source ids legitimately include 0 (Email) and negative values (Halo's built-in integration + sources, e.g. -9 Ninja RMM), so callers must not treat an id as absent because it is falsy. + .EXAMPLE + Get-HaloRequestSource + + #> + [CmdletBinding()] + param () + $Table = Get-CIPPTable -TableName Extensionsconfig + try { + $Configuration = ((Get-CIPPAzDataTableEntity @Table).config | ConvertFrom-Json -ea stop).HaloPSA + $Token = Get-HaloToken -configuration $Configuration + + $Response = Invoke-RestMethod -Uri "$($Configuration.ResourceURL)/lookup?lookupid=22&showall=true" -ContentType 'application/json' -Method GET -Headers @{Authorization = "Bearer $($Token.access_token)" } + + # Halo returns a bare array here, but some of its lookup responses wrap the rows. Handle + # both so a version difference reads as "no sources" rather than throwing. + $Sources = if ($Response -is [array]) { $Response } elseif ($Response.lookups) { $Response.lookups } else { @($Response) } + + # Project to what the dropdown needs. The integration form persists the whole selected + # option - label, value and the raw API row - into the extension config blob, so returning + # the raw lookup rows would store that noise alongside it. + @($Sources | Where-Object { $null -ne $_.id -and $_.name } | ForEach-Object { + [PSCustomObject]@{ + name = "$($_.name)" + id = [int]$_.id + } + } | Sort-Object -Property name) + } catch { + $Message = if ($_.ErrorDetails.Message) { + Get-NormalizedError -Message $_.ErrorDetails.Message + } else { + $_.Exception.Message + } + @(@{name = "Could not get HaloPSA Request Sources, error: $Message"; id = '' }) + } +} diff --git a/backend/Modules/CippExtensions/Public/Halo/New-HaloPSATicket.ps1 b/backend/Modules/CippExtensions/Public/Halo/New-HaloPSATicket.ps1 index deea67be9c..5cddec481e 100644 --- a/backend/Modules/CippExtensions/Public/Halo/New-HaloPSATicket.ps1 +++ b/backend/Modules/CippExtensions/Public/Halo/New-HaloPSATicket.ps1 @@ -148,6 +148,22 @@ function New-HaloPSATicket { Write-LogMessage -message "HaloPSA.DefaultPriority value '$Priority' is not a valid integer - omitting priority_id from ticket payload" -API 'HaloPSATicket' -sev Warning } } + # Halo records tickets created over the API as 'Manual' unless the payload carries a source, so + # MSPs who want CIPP's tickets identifiable create their own source in Halo and select it here. + # Blank keeps the previous behaviour exactly - no source is sent and Halo applies its default. + $RequestSource = $Configuration.RequestSource.value ?? $Configuration.RequestSource + if ($null -ne $RequestSource -and "$RequestSource".Trim() -ne '') { + # Halo source ids include 0 (Email) and negatives (built-in integrations, e.g. -9 Ninja RMM), + # so presence has to be tested before parsing. The '-gt 0' guard the priority block uses would + # drop both, and '-as [int]' can't be the guard either - it turns $null and '' into 0, which + # would silently stamp Email on every install that left this blank. + $SourceInt = 0 + if ([int]::TryParse("$RequestSource", [ref]$SourceInt)) { + $object | Add-Member -MemberType NoteProperty -Name 'source' -Value $SourceInt -Force + } else { + Write-LogMessage -message "HaloPSA.RequestSource value '$RequestSource' is not a valid integer - omitting source from ticket payload" -API 'HaloPSATicket' -sev Warning + } + } #use the token to create a new ticket in HaloPSA $body = ConvertTo-Json -Compress -Depth 10 -InputObject @($Object) diff --git a/backend/Tests/Extensions/New-HaloPSATicket.Tests.ps1 b/backend/Tests/Extensions/New-HaloPSATicket.Tests.ps1 new file mode 100644 index 0000000000..089917cd50 --- /dev/null +++ b/backend/Tests/Extensions/New-HaloPSATicket.Tests.ps1 @@ -0,0 +1,132 @@ +# Pester tests for the HaloPSA ticket payload built by New-HaloPSATicket. +# Halo records tickets created over the API as 'Manual' unless the payload carries a source, so the +# integration gained an optional HaloPSA.RequestSource setting (#321). The guard around it is easy +# to get wrong: Halo source ids include 0 (Email) and negatives (built-in integration sources), and +# in PowerShell both $null -as [int] and '' -as [int] evaluate to 0 - so a naive cast would stamp +# Email on every install that left the setting blank. + +BeforeAll { + $BackendRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $FunctionPath = Join-Path $BackendRoot 'Modules/CippExtensions/Public/Halo/New-HaloPSATicket.ps1' + + # Stubs for the dependencies we mock. + function Get-CIPPTable { param([string]$TableName) } + function Get-CIPPAzDataTableEntity { param($TableName, $Filter, $Property, $First) } + function Add-CIPPAzDataTableEntity { param($TableName, $Entity, [switch]$Force) } + function Get-HaloToken { param($configuration) } + function Get-HaloUser { param($AzureOID, $Email, $ClientId, $Configuration, $Token) } + function Get-StringHash { param($String) } + function Get-NormalizedError { param($Message) } + function Get-CippException { param($Exception) } + function Write-LogMessage { param($API, $tenant, $message, $sev, $LogData, $headers) } + + . $FunctionPath + + # Builds the single Extensionsconfig row the function reads. Pass -NoRequestSource to leave the + # property off entirely, which is what an existing install looks like. + function New-HaloConfigRow { + param( + $RequestSource, + [switch]$NoRequestSource, + [bool]$ConsolidateTickets = $false + ) + $Halo = @{ + Enabled = $true + ResourceURL = 'https://halo.example.com/api' + TicketType = 21 + ConsolidateTickets = $ConsolidateTickets + } + if (-not $NoRequestSource) { $Halo.RequestSource = $RequestSource } + [pscustomobject]@{ config = (@{ HaloPSA = $Halo } | ConvertTo-Json -Depth 5) } + } + + # Runs the function against a given config and hands back the deserialised POST body. + function Get-TicketPayload { + param($ConfigRow) + $script:CapturedUri = $null + $script:CapturedBody = $null + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { $ConfigRow } + New-HaloPSATicket -title 'Test alert' -description '

    body

    ' -client 19 | Out-Null + if ($null -eq $script:CapturedBody) { return $null } + # The function posts a single-element array. + @($script:CapturedBody | ConvertFrom-Json)[0] + } +} + +Describe 'New-HaloPSATicket - request source' { + BeforeEach { + Mock -CommandName Get-CIPPTable -MockWith { param([string]$TableName) @{ TableName = $TableName } } + Mock -CommandName Get-HaloToken -MockWith { @{ access_token = 'token' } } + Mock -CommandName Get-StringHash -MockWith { 'hash' } + Mock -CommandName Add-CIPPAzDataTableEntity -MockWith { } + Mock -CommandName Write-LogMessage -MockWith { } + Mock -CommandName Invoke-RestMethod -MockWith { + param($Uri, $ContentType, $Method, $Body, $Headers, [switch]$SkipHttpErrorCheck) + $script:CapturedUri = $Uri + $script:CapturedBody = $Body + @{ id = 123 } + } + } + + It 'omits source entirely when the setting has never been configured' { + # The backwards-compatibility guarantee: existing installs must post exactly what they + # posted before, so Halo keeps applying its own default. + $Payload = Get-TicketPayload -ConfigRow (New-HaloConfigRow -NoRequestSource) + $Payload.PSObject.Properties.Name | Should -Not -Contain 'source' + } + + It 'sends source 0 rather than treating it as unset' { + # Email is source id 0. Any falsy/-as [int] guard would drop or invent this. + $Payload = Get-TicketPayload -ConfigRow (New-HaloConfigRow -RequestSource @{ label = 'Email'; value = 0 }) + $Payload.PSObject.Properties.Name | Should -Contain 'source' + $Payload.source | Should -Be 0 + } + + It 'sends negative source ids' { + # Halo's built-in integration sources are negative, e.g. -9 Ninja RMM. + $Payload = Get-TicketPayload -ConfigRow (New-HaloConfigRow -RequestSource @{ label = 'Ninja RMM'; value = -9 }) + $Payload.source | Should -Be -9 + } + + It 'accepts a raw scalar as well as the autocomplete object' { + # Config can hold either shape, hence the .value ?? $x idiom. + $Payload = Get-TicketPayload -ConfigRow (New-HaloConfigRow -RequestSource 42) + $Payload.source | Should -Be 42 + } + + It 'omits source when the setting was cleared to an empty string' { + # '' -as [int] is 0, so without an explicit blank check this would become Email. + $Payload = Get-TicketPayload -ConfigRow (New-HaloConfigRow -RequestSource '') + $Payload.PSObject.Properties.Name | Should -Not -Contain 'source' + } + + It 'omits source and warns when the stored value is not an integer' { + $Payload = Get-TicketPayload -ConfigRow (New-HaloConfigRow -RequestSource 'not-a-number') + $Payload.PSObject.Properties.Name | Should -Not -Contain 'source' + Should -Invoke Write-LogMessage -Times 1 -Exactly -ParameterFilter { $sev -eq 'Warning' -and $message -like '*RequestSource*' } + } + + It 'does not put source on the note action when consolidating onto an existing ticket' { + # The consolidation path posts to /actions, which has no source field. + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { + param($TableName, $Filter) + if ($Filter) { return [pscustomobject]@{ TicketID = 999 } } + New-HaloConfigRow -RequestSource @{ label = 'CIPP'; value = 42 } -ConsolidateTickets $true + } + $script:CapturedUri = $null + $script:CapturedBody = $null + Mock -CommandName Invoke-RestMethod -MockWith { + param($Uri, $ContentType, $Method, $Body, $Headers, [switch]$SkipHttpErrorCheck) + if ($Method -eq 'Get') { return @{ id = 999; hasbeenclosed = $false } } + $script:CapturedUri = $Uri + $script:CapturedBody = $Body + @{ id = 999 } + } + + New-HaloPSATicket -title 'Test alert' -description '

    body

    ' -client 19 | Out-Null + + $script:CapturedUri | Should -BeLike '*/actions' + $Action = @($script:CapturedBody | ConvertFrom-Json)[0] + $Action.PSObject.Properties.Name | Should -Not -Contain 'source' + } +} diff --git a/docs/user-documentation/cipp/integrations/halopsa.md b/docs/user-documentation/cipp/integrations/halopsa.md index f4e1045a68..d4cd960859 100644 --- a/docs/user-documentation/cipp/integrations/halopsa.md +++ b/docs/user-documentation/cipp/integrations/halopsa.md @@ -103,6 +103,7 @@ Move to the **Tenant Mapping** tab and map each CIPP tenant to its Halo client, | HaloPSA Client ID | The Client ID of the API application created in Halo. | | HaloPSA Client Secret | The Client Secret of the API application. Stored securely and masked once saved; leave blank on subsequent saves to keep the existing value. | | HaloPSA Ticket Type | The ticket type used for CIPP alert tickets. Sets the workflow, and determines which priorities and outcomes are offered below. Leave blank to use Halo's default. | +| HaloPSA Request Source | Optional. Sets the request source recorded on every CIPP-generated ticket, so they can be told apart from manually logged tickets when reporting on ticket origin. Halo records tickets raised over the API as Manual unless one is set. Create the source in Halo first. Leave blank to use Halo default. | | HaloPSA Default Priority | Optional. Sets the priority on every CIPP-generated ticket. Only priorities on the ticket type's SLA are listed. Leave blank to use the SLA default. Appears once a ticket type is selected. | | Consolidate Tickets | Adds repeat alerts with the same title to the existing open ticket as a private note rather than raising a new ticket. Appears once a ticket type is selected. | | HaloPSA Outcome | The action applied when a duplicate alert is added to an existing ticket. Only outcomes from the selected ticket type's workflow are listed, and the action must be one the Halo API user can run. Leave blank to use Halo's built-in Internal Note action. Appears once **Consolidate Tickets** is enabled. | diff --git a/frontend/src/data/Extensions.json b/frontend/src/data/Extensions.json index 8643c7b44b..660661bf91 100644 --- a/frontend/src/data/Extensions.json +++ b/frontend/src/data/Extensions.json @@ -374,6 +374,32 @@ "action": "disable" } }, + { + "type": "autoComplete", + "name": "HaloPSA.RequestSource", + "label": "HaloPSA Request Source", + "placeholder": "Select a request source, leave blank for default", + "helperText": "Optional. Stamps every CIPP-generated ticket with this request source. Halo records tickets created over the API as Manual unless one is set, so create a CIPP request source in Halo to tell them apart.", + "fullRow": true, + "multiple": false, + "api": { + "url": "/api/ExecExtensionMapping", + "data": { + "List": "HaloPSARequestSources" + }, + "queryKey": "HaloRequestSources", + "dataKey": "RequestSources", + "labelField": "name", + "valueField": "id", + "showRefresh": true + }, + "condition": { + "field": "HaloPSA.Enabled", + "compareType": "is", + "compareValue": true, + "action": "disable" + } + }, { "type": "autoComplete", "name": "HaloPSA.DefaultPriority", From 3f3247f23d396697b42af465e0624600ca96a742 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Wed, 19 Aug 2026 00:42:05 +0800 Subject: [PATCH 163/226] feat(tests): expose Domain Analyser results to custom tests via the reporting DB Snapshot the Domain Analyser results already computed into the Domains table into CippReportingDB as type DomainAnalyser during the Graph cache collection, so custom tests and reports can read DNS hygiene, email authentication state and domain health scores through Get-CIPPTestData without needing network access from the test sandbox. Rows are keyed by domain so nightly reruns upsert in place, and a tenant the analyser has not run for is skipped rather than recorded as an authoritative empty set. Move the Domain Analyser timer from 05:30 to 01:30 so it completes ahead of the 03:00 DB cache run and the 04:00 test run, matching the pattern the Intune report-export timer already uses. Closes #235 --- backend/Config/CIPPTimers.json | 4 +- .../Public/Invoke-CIPPDBCacheCollection.ps1 | 1 + .../DBCache/Set-CIPPDBCacheDomainAnalyser.ps1 | 58 +++++++++++++++ .../Set-CIPPDBCacheDomainAnalyser.Tests.ps1 | 71 +++++++++++++++++++ frontend/src/data/CIPPDBCacheTypes.json | 5 ++ 5 files changed, 137 insertions(+), 2 deletions(-) create mode 100644 backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheDomainAnalyser.ps1 create mode 100644 backend/Tests/DBCache/Set-CIPPDBCacheDomainAnalyser.Tests.ps1 diff --git a/backend/Config/CIPPTimers.json b/backend/Config/CIPPTimers.json index 72e9f468d0..56d42c3e97 100644 --- a/backend/Config/CIPPTimers.json +++ b/backend/Config/CIPPTimers.json @@ -109,8 +109,8 @@ { "Id": "c2ebde3f-fa35-45aa-8a6b-91c835050b79", "Command": "Start-DomainOrchestrator", - "Description": "Orchestrator to process domains", - "Cron": "0 30 5 * * *", + "Description": "Orchestrator to process domains ahead of the nightly DB cache run", + "Cron": "0 30 1 * * *", "Priority": 22, "TZOffset": true, "RunOnProcessor": true diff --git a/backend/Modules/CIPPCore/Public/Invoke-CIPPDBCacheCollection.ps1 b/backend/Modules/CIPPCore/Public/Invoke-CIPPDBCacheCollection.ps1 index f21c9c8a19..3c761f84c7 100644 --- a/backend/Modules/CIPPCore/Public/Invoke-CIPPDBCacheCollection.ps1 +++ b/backend/Modules/CIPPCore/Public/Invoke-CIPPDBCacheCollection.ps1 @@ -83,6 +83,7 @@ function Invoke-CIPPDBCacheCollection { 'CopilotPolicySettings' 'SelfServicePurchaseProducts' 'MoeraDmarc' + 'DomainAnalyser' ) ExchangeConfig = @( 'ExoAntiPhishPolicies' diff --git a/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheDomainAnalyser.ps1 b/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheDomainAnalyser.ps1 new file mode 100644 index 0000000000..5cdb1a9be9 --- /dev/null +++ b/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheDomainAnalyser.ps1 @@ -0,0 +1,58 @@ +function Set-CIPPDBCacheDomainAnalyser { + <# + .SYNOPSIS + Caches Domain Analyser results for a tenant + + .DESCRIPTION + Snapshots the Domain Analyser results already computed into the Domains table (SPF, MX, + DMARC, DKIM, DNSSEC, enrollment CNAMEs and the health score per domain) into + CippReportingDB, so custom tests and reports can read them via + Get-CIPPTestData -Type 'DomainAnalyser'. No DNS work happens here - the nightly + Start-DomainOrchestrator run produces the data before this cache pass; this function + only copies it. + + A tenant with no analyser results is skipped without writing anything: an empty set + usually means the Domain Analyser has not run for the tenant yet, which is not an + authoritative "no domains" answer. + + .PARAMETER TenantFilter + The tenant to cache Domain Analyser results for + + .PARAMETER QueueId + The queue ID to update with total tasks (optional) + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter, + [string]$QueueId + ) + + try { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching Domain Analyser results' -sev Debug + + $Results = @(Get-CIPPDomainAnalyser -TenantFilter $TenantFilter) + + if ($Results.Count -eq 0) { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'No Domain Analyser results to cache - the Domain Analyser has not run for this tenant yet' -sev Debug + return + } + + # A stable id gives deterministic row keys (DomainAnalyser-) so reruns upsert in + # place instead of inserting GUID-keyed rows and orphan-deleting the previous run's. The + # records are copied first because Get-CIPPDomainAnalyser serves them from a shared + # in-worker cache that other callers read. + $Rows = @(foreach ($Result in $Results) { + $Row = $Result.PSObject.Copy() + $Row | Add-Member -NotePropertyName 'id' -NotePropertyValue $Result.Domain -Force + $Row + }) + + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'DomainAnalyser' -Data $Rows -AddCount + + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached Domain Analyser results successfully' -sev Debug + } catch { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Failed to cache Domain Analyser results: $($_.Exception.Message)" -sev Error + throw + } +} diff --git a/backend/Tests/DBCache/Set-CIPPDBCacheDomainAnalyser.Tests.ps1 b/backend/Tests/DBCache/Set-CIPPDBCacheDomainAnalyser.Tests.ps1 new file mode 100644 index 0000000000..b05a6c8b3a --- /dev/null +++ b/backend/Tests/DBCache/Set-CIPPDBCacheDomainAnalyser.Tests.ps1 @@ -0,0 +1,71 @@ +# The Domain Analyser cache collector copies already-computed analyser results from the Domains +# table into CippReportingDB. These tests hold two semantics in place: +# +# - An empty analyser result set is NOT written. Empty usually means the Domain Analyser has not +# run for the tenant yet, which is not an authoritative "no domains" answer - writing it would +# record a Count of 0 (and with cleanup semantics could erase valid earlier rows). +# - Failures rethrow, so Invoke-CIPPDBCacheCollection counts the type as failed instead of the +# queue reporting success while the cache silently kept stale data. + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + + function Get-CIPPDomainAnalyser { param($TenantFilter) } + function Add-CIPPDbItem { param($TenantFilter, $Type, $Data, [switch]$AddCount) } + function Write-LogMessage { param($API, $tenant, $message, $sev, $LogData) } + + . (Join-Path $RepoRoot 'Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheDomainAnalyser.ps1') + + $script:Tenant = 'contoso.onmicrosoft.com' +} + +Describe 'Set-CIPPDBCacheDomainAnalyser' { + BeforeEach { + Mock Write-LogMessage {} + Mock Add-CIPPDbItem {} + } + + It 'skips the write entirely when the analyser has no results for the tenant' { + Mock Get-CIPPDomainAnalyser { @() } + + { Set-CIPPDBCacheDomainAnalyser -TenantFilter $script:Tenant } | Should -Not -Throw + Should -Invoke Add-CIPPDbItem -Times 0 + Should -Invoke Write-LogMessage -Times 0 -ParameterFilter { $sev -eq 'Error' } + } + + It 'writes analyser results as type DomainAnalyser' { + Mock Get-CIPPDomainAnalyser { + [PSCustomObject]@{ Domain = 'contoso.com'; Score = 130; ScorePercentage = 81 } + } + + { Set-CIPPDBCacheDomainAnalyser -TenantFilter $script:Tenant } | Should -Not -Throw + Should -Invoke Add-CIPPDbItem -Times 1 -ParameterFilter { + $Type -eq 'DomainAnalyser' -and $TenantFilter -eq 'contoso.onmicrosoft.com' -and $AddCount + } + Should -Invoke Write-LogMessage -Times 0 -ParameterFilter { $sev -eq 'Error' } + } + + It 'stamps each record with id = Domain without mutating the analyser-owned objects' { + # Get-CIPPDomainAnalyser serves results from a shared in-worker cache, so the collector + # must copy records before decorating them. + $script:Source = [PSCustomObject]@{ Domain = 'contoso.com'; Score = 130 } + Mock Get-CIPPDomainAnalyser { $script:Source } + + Set-CIPPDBCacheDomainAnalyser -TenantFilter $script:Tenant + + Should -Invoke Add-CIPPDbItem -Times 1 -ParameterFilter { + @($Data).Count -eq 1 -and $Data[0].id -eq 'contoso.com' -and $Data[0].Score -eq 130 + } + $script:Source.PSObject.Properties.Name | Should -Not -Contain 'id' + } + + It 'rethrows failures so the collection counts the type as failed' { + Mock Get-CIPPDomainAnalyser { throw 'Storage request failed' } + + { Set-CIPPDBCacheDomainAnalyser -TenantFilter $script:Tenant } | Should -Throw '*Storage request failed*' + Should -Invoke Add-CIPPDbItem -Times 0 + Should -Invoke Write-LogMessage -Times 1 -ParameterFilter { + $sev -eq 'Error' -and $message -like '*Failed to cache Domain Analyser results*' + } + } +} diff --git a/frontend/src/data/CIPPDBCacheTypes.json b/frontend/src/data/CIPPDBCacheTypes.json index 9eac150e02..d07e8369e3 100644 --- a/frontend/src/data/CIPPDBCacheTypes.json +++ b/frontend/src/data/CIPPDBCacheTypes.json @@ -353,5 +353,10 @@ "type": "IntuneAppInstallStatus", "friendlyName": "Intune App Install Status", "description": "Per-application install status rollup (failed/installed/pending device counts) from the AppInstallStatusAggregate report" + }, + { + "type": "DomainAnalyser", + "friendlyName": "Domain Analyser", + "description": "Domain Analyser results per domain: SPF, MX, DMARC, DKIM, DNSSEC, enrollment CNAMEs and the domain health score" } ] From 8ac85709cd907bbc7194f4570bf76d80ca73cac1 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Wed, 19 Aug 2026 00:47:55 +0800 Subject: [PATCH 164/226] feat(auth): add self-service access refresh for PIM-activated roles Roles resolved from Entra group membership are cached for 15 minutes (cacheAccessUserRoles) and mirrored into allowedUsers by a 15-minute sync timer, so a role granted through a PIM-activated group could take 15+ minutes to reach CIPP. ExecRefreshMyAccess lets a signed-in user clear their own cached resolution, re-check group membership via Graph, and refresh the allowedUsers projection on demand, behind a 30-second per-user cooldown. The endpoint is Public by necessity - a user whose elevation has not landed yet holds no CIPP role at all - and gates itself on the platform principal header, refusing app-only API clients. Frontend: a Refresh my access item in the account popover that runs through the standard confirm dialog with inline results, and a refresh affordance with result feedback on the Access Denied page, both invalidating the cached /api/me so the UI updates in place. Closes #315 --- .../Settings/Invoke-ExecRefreshMyAccess.ps1 | 110 +++++++++++ .../Invoke-ExecRefreshMyAccess.Tests.ps1 | 184 ++++++++++++++++++ frontend/src/layouts/account-popover.js | 38 ++++ frontend/src/pages/unauthenticated.js | 58 +++++- 4 files changed, 386 insertions(+), 4 deletions(-) create mode 100644 backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecRefreshMyAccess.ps1 create mode 100644 backend/Tests/Endpoint/Invoke-ExecRefreshMyAccess.Tests.ps1 diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecRefreshMyAccess.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecRefreshMyAccess.ps1 new file mode 100644 index 0000000000..e9105d3f72 --- /dev/null +++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecRefreshMyAccess.ps1 @@ -0,0 +1,110 @@ +function Invoke-ExecRefreshMyAccess { + <# + .SYNOPSIS + Re-check the caller's Entra group membership and refresh their CIPP roles + .DESCRIPTION + Clears the caller's cached role resolution and re-checks Entra group membership, so a + just-activated PIM group grants its mapped CIPP role without waiting out the role cache. + Only ever refreshes the calling user's own access. + .FUNCTIONALITY + Entrypoint,AnyTenant + .ROLE + Public + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + # A user whose PIM elevation has not landed yet holds no CIPP role at all, so any role gate + # would lock them out of the one endpoint meant to fix exactly that. The role check is + # skipped (Public) and identity comes exclusively from the platform-injected principal + # header, never from the request body, so the caller can only refresh themselves. + $User = $null + try { + $PrincipalHeader = $Request.Headers.'x-ms-client-principal' + if (-not [string]::IsNullOrEmpty($PrincipalHeader)) { + $User = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($PrincipalHeader)) | ConvertFrom-Json + } + } catch { + $User = $null + } + + if ($User -and $User.claims -and [string]::IsNullOrWhiteSpace($User.userDetails)) { + $Claims = @($User.claims) + $Upn = ($Claims | Where-Object { $_.typ -in @('preferred_username', 'upn', 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/upn', 'email', 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress') } | Select-Object -First 1).val + if ([string]::IsNullOrWhiteSpace($Upn)) { $Upn = $Request.Headers.'x-ms-client-principal-name' } + } else { + $Upn = $User.userDetails + } + + # App-only API clients authenticate as an app registration (a GUID principal name) and have + # no group membership to refresh. + $IsApiClient = $Request.Headers.'x-ms-client-principal-idp' -eq 'aad' -and $Request.Headers.'x-ms-client-principal-name' -match '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$' + + if ($IsApiClient -or [string]::IsNullOrWhiteSpace($Upn)) { + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::Unauthorized + Body = @{ Results = 'Access refresh is only available to a signed-in user.' } + }) + } + + try { + $Table = Get-CippTable -TableName 'cacheAccessUserRoles' + $SafeUpn = $Upn -replace "'", "''" + + # A refresh costs a Graph membership lookup plus a full group sync, so cap how often a + # single user can trigger one. + $CooldownSeconds = 30 + $CooldownMarker = Get-CIPPAzDataTableEntity @Table -Filter "PartitionKey eq 'AccessRefresh' and RowKey eq '$SafeUpn'" + if ($CooldownMarker.Timestamp) { + $SecondsSince = ((Get-Date).ToUniversalTime() - $CooldownMarker.Timestamp.UtcDateTime).TotalSeconds + if ($SecondsSince -lt $CooldownSeconds) { + $WaitSeconds = [math]::Ceiling($CooldownSeconds - $SecondsSince) + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::TooManyRequests + Body = @{ Results = "Your access was refreshed less than $CooldownSeconds seconds ago. Try again in $WaitSeconds seconds." } + }) + } + } + Add-CIPPAzDataTableEntity @Table -Entity @{ PartitionKey = 'AccessRefresh'; RowKey = [string]$Upn } -Force | Out-Null + + # Drop the caller's cached role resolution so the re-check below goes to Graph instead + # of reading the row back. + $CachedRole = Get-CIPPAzDataTableEntity @Table -Filter "PartitionKey eq 'AccessUser' and RowKey eq '$SafeUpn'" + if ($CachedRole) { + Remove-CIPPAzDataTableEntity -Force @Table -Entity $CachedRole + } + + # Seeds the same placeholder roles Test-CIPPAccess does, so the rewritten cache row + # matches what normal resolution would produce. + $Resolved = Test-CIPPAccessUserRole -User ([PSCustomObject]@{ + userDetails = [string]$Upn + userRoles = @('authenticated', 'anonymous') + }) + $GroupRoles = @($Resolved.userRoles | Where-Object { $_ -notin @('authenticated', 'anonymous') }) + + # Refresh the allowedUsers projection CRAFT authenticates against and drop its + # in-memory user cache, so the next request carries the new roles. + try { Start-UserSyncTimer } catch {} + try { [Craft.Services.AuthBridge]::InvalidateUsers() } catch {} + + if (($GroupRoles | Measure-Object).Count -gt 0) { + $Result = "Access refreshed. Roles from your Entra group memberships: $($GroupRoles -join ', ')." + } else { + $Result = 'Access refreshed, but none of your Entra group memberships map to a CIPP role. If you activated a group with PIM just now, the change may not have reached Microsoft Graph yet - wait a moment and refresh again.' + } + $RolesText = if (($GroupRoles | Measure-Object).Count -gt 0) { $GroupRoles -join ', ' } else { 'none' } + Write-LogMessage -API 'RefreshMyAccess' -headers $Request.Headers -message "$Upn refreshed their access. Group-mapped roles: $RolesText" -sev Info + $StatusCode = [HttpStatusCode]::OK + $Body = @{ Results = $Result; Roles = $GroupRoles } + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'RefreshMyAccess' -headers $Request.Headers -message "Failed to refresh access for $Upn. $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage + $StatusCode = [HttpStatusCode]::InternalServerError + $Body = @{ Results = "Failed to refresh access: $($ErrorMessage.NormalizedError)" } + } + + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = $Body + }) +} diff --git a/backend/Tests/Endpoint/Invoke-ExecRefreshMyAccess.Tests.ps1 b/backend/Tests/Endpoint/Invoke-ExecRefreshMyAccess.Tests.ps1 new file mode 100644 index 0000000000..9e66df0c79 --- /dev/null +++ b/backend/Tests/Endpoint/Invoke-ExecRefreshMyAccess.Tests.ps1 @@ -0,0 +1,184 @@ +# Pester tests for Invoke-ExecRefreshMyAccess +# +# The endpoint is Public (a caller whose PIM elevation has not landed yet holds no CIPP +# role at all), so it must gate itself: identity comes only from the platform-injected +# principal header, API clients are refused, and a per-user cooldown caps how often the +# Graph-backed re-check can run. + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $FunctionPath = Get-ChildItem -Path (Join-Path $RepoRoot 'Modules') -Recurse -Filter 'Invoke-ExecRefreshMyAccess.ps1' -File -ErrorAction SilentlyContinue | + Select-Object -First 1 -ExpandProperty FullName + if (-not $FunctionPath) { throw 'Could not locate Invoke-ExecRefreshMyAccess.ps1 under Modules/' } + + class HttpResponseContext { + [object]$StatusCode + [object]$Body + } + + $Accelerators = [psobject].Assembly.GetType('System.Management.Automation.TypeAccelerators') + if (-not $Accelerators::Get.ContainsKey('HttpStatusCode')) { + $Accelerators::Add('HttpStatusCode', [System.Net.HttpStatusCode]) + } + + function Get-CippTable { param($TableName) } + function Get-CIPPAzDataTableEntity { param($TableName, $Filter) } + function Add-CIPPAzDataTableEntity { param($TableName, $Entity, [switch]$Force) } + function Remove-CIPPAzDataTableEntity { param($TableName, $Entity, [switch]$Force) } + function Test-CIPPAccessUserRole { param($User) } + function Start-UserSyncTimer { } + function Write-LogMessage { param($headers, $API, $message, $sev, $LogData) } + function Get-CippException { param($Exception) } + + . $FunctionPath + + function New-RefreshRequest { + param( + $Principal = @{ userDetails = 'user@contoso.com'; userRoles = @('authenticated', 'anonymous') }, + $Idp = 'azureStaticWebApps', + $PrincipalName = 'user@contoso.com' + ) + $Headers = @{ + 'x-ms-client-principal-idp' = $Idp + 'x-ms-client-principal-name' = $PrincipalName + } + if ($null -ne $Principal) { + $Json = ConvertTo-Json -InputObject $Principal -Depth 5 -Compress + $Headers['x-ms-client-principal'] = [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($Json)) + } + [pscustomobject]@{ + Params = @{ CIPPEndpoint = 'ExecRefreshMyAccess' } + Headers = $Headers + Body = [pscustomobject]@{ } + } + } +} + +Describe 'Invoke-ExecRefreshMyAccess' { + BeforeEach { + Mock -CommandName Write-LogMessage -MockWith { } + Mock -CommandName Get-CippTable -MockWith { @{ TableName = 'cacheAccessUserRoles' } } + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { $null } + Mock -CommandName Add-CIPPAzDataTableEntity -MockWith { } + Mock -CommandName Remove-CIPPAzDataTableEntity -MockWith { } + Mock -CommandName Test-CIPPAccessUserRole -MockWith { + [pscustomobject]@{ + userDetails = 'user@contoso.com' + userRoles = @('admin', 'authenticated', 'anonymous') + } + } + Mock -CommandName Start-UserSyncTimer -MockWith { } + Mock -CommandName Get-CippException -MockWith { @{ NormalizedError = 'boom' } } + } + + It 'refreshes and returns the group-mapped roles for a signed-in user' { + $Response = Invoke-ExecRefreshMyAccess -Request (New-RefreshRequest) -TriggerMetadata $null + + $Response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::OK) + $Response.Body.Roles | Should -Be @('admin') + $Response.Body.Results | Should -Match 'admin' + Should -Invoke Test-CIPPAccessUserRole -Times 1 -Exactly + Should -Invoke Start-UserSyncTimer -Times 1 -Exactly + } + + It 'seeds the re-check with placeholder roles only, never the principal roles' { + # A stale principal can still carry old roles; baking them into the re-check would + # write them straight back into the cache row this endpoint just cleared. + $Request = New-RefreshRequest -Principal @{ userDetails = 'user@contoso.com'; userRoles = @('readonly', 'authenticated', 'anonymous') } + $null = Invoke-ExecRefreshMyAccess -Request $Request -TriggerMetadata $null + + Should -Invoke Test-CIPPAccessUserRole -Times 1 -Exactly -ParameterFilter { + ($User.userRoles -join ',') -eq 'authenticated,anonymous' + } + } + + It 'removes the cached role row before re-resolving' { + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { + [pscustomobject]@{ PartitionKey = 'AccessUser'; RowKey = 'user@contoso.com'; Role = '["admin"]' } + } -ParameterFilter { $Filter -like "*AccessUser*" } + + $Response = Invoke-ExecRefreshMyAccess -Request (New-RefreshRequest) -TriggerMetadata $null + + $Response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::OK) + Should -Invoke Remove-CIPPAzDataTableEntity -Times 1 -Exactly + } + + It 'reports when no group maps to a role' { + Mock -CommandName Test-CIPPAccessUserRole -MockWith { + [pscustomobject]@{ + userDetails = 'user@contoso.com' + userRoles = @('authenticated', 'anonymous') + } + } + + $Response = Invoke-ExecRefreshMyAccess -Request (New-RefreshRequest) -TriggerMetadata $null + + $Response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::OK) + @($Response.Body.Roles).Count | Should -Be 0 + $Response.Body.Results | Should -Match 'none of your Entra group memberships' + } + + It 'enforces the cooldown between refreshes' { + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { + [pscustomobject]@{ Timestamp = [System.DateTimeOffset]::UtcNow.AddSeconds(-5) } + } -ParameterFilter { $Filter -like "*AccessRefresh*" } + + $Response = Invoke-ExecRefreshMyAccess -Request (New-RefreshRequest) -TriggerMetadata $null + + $Response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::TooManyRequests) + Should -Invoke Test-CIPPAccessUserRole -Times 0 -Exactly + Should -Invoke Start-UserSyncTimer -Times 0 -Exactly + } + + It 'allows a refresh once the cooldown has elapsed' { + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { + [pscustomobject]@{ Timestamp = [System.DateTimeOffset]::UtcNow.AddSeconds(-45) } + } -ParameterFilter { $Filter -like "*AccessRefresh*" } + + $Response = Invoke-ExecRefreshMyAccess -Request (New-RefreshRequest) -TriggerMetadata $null + + $Response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::OK) + Should -Invoke Test-CIPPAccessUserRole -Times 1 -Exactly + } + + It 'extracts the UPN from a claims-shaped principal' { + $Claims = @{ + claims = @( + @{ typ = 'preferred_username'; val = 'claims@contoso.com' } + ) + } + $null = Invoke-ExecRefreshMyAccess -Request (New-RefreshRequest -Principal $Claims -PrincipalName 'claims@contoso.com') -TriggerMetadata $null + + Should -Invoke Test-CIPPAccessUserRole -Times 1 -Exactly -ParameterFilter { + $User.userDetails -eq 'claims@contoso.com' + } + } + + It 'refuses a request without a principal header' { + $Response = Invoke-ExecRefreshMyAccess -Request (New-RefreshRequest -Principal $null) -TriggerMetadata $null + + $Response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::Unauthorized) + Should -Invoke Test-CIPPAccessUserRole -Times 0 -Exactly + } + + It 'refuses an app-only API client' { + $Request = New-RefreshRequest -Principal @{ + userDetails = '11111111-2222-3333-4444-555555555555' + userRoles = @() + } -Idp 'aad' -PrincipalName '11111111-2222-3333-4444-555555555555' + + $Response = Invoke-ExecRefreshMyAccess -Request $Request -TriggerMetadata $null + + $Response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::Unauthorized) + Should -Invoke Test-CIPPAccessUserRole -Times 0 -Exactly + } + + It 'returns a server error when the refresh itself fails' { + Mock -CommandName Test-CIPPAccessUserRole -MockWith { throw 'graph unavailable' } + + $Response = Invoke-ExecRefreshMyAccess -Request (New-RefreshRequest) -TriggerMetadata $null + + $Response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::InternalServerError) + $Response.Body.Results | Should -Match 'Failed to refresh access' + } +} diff --git a/frontend/src/layouts/account-popover.js b/frontend/src/layouts/account-popover.js index eb6f140692..b5e69409fb 100644 --- a/frontend/src/layouts/account-popover.js +++ b/frontend/src/layouts/account-popover.js @@ -2,6 +2,7 @@ import { useCallback } from "react"; import PropTypes from "prop-types"; import { useRouter } from "next/navigation"; import toast from "react-hot-toast"; +import ArrowPathIcon from "@heroicons/react/24/outline/ArrowPathIcon"; import ArrowRightOnRectangleIcon from "@heroicons/react/24/outline/ArrowRightOnRectangleIcon"; import ChevronDownIcon from "@heroicons/react/24/outline/ChevronDownIcon"; import MagnifyingGlassIcon from "@heroicons/react/24/outline/MagnifyingGlassIcon"; @@ -23,8 +24,10 @@ import { useMediaQuery, } from "@mui/material"; import { usePopover } from "../hooks/use-popover"; +import { useDialog } from "../hooks/use-dialog"; import { paths } from "../paths"; import { ApiGetCall } from "../api/ApiCall"; +import { CippApiDialog } from "../components/CippComponents/CippApiDialog"; import { CogIcon, DocumentTextIcon, LifebuoyIcon, TrashIcon } from "@heroicons/react/24/outline"; import ArrowTopRightOnSquareIcon from "@heroicons/react/24/outline/ArrowTopRightOnSquareIcon"; import { useReleaseNotes } from "../contexts/release-notes-context"; @@ -66,6 +69,11 @@ export const AccountPopover = (props) => { convertToDataUrl: true, }); + // Re-checks Entra group membership server-side, then refetches /api/me so a role granted + // through a just-activated PIM group applies without waiting out the role cache. Runs + // through the standard confirm dialog, which also renders the API result. + const refreshAccessDialog = useDialog(); + const handleLogout = useCallback(async () => { try { popover.handleClose(); @@ -132,6 +140,20 @@ export const AccountPopover = (props) => { )} + {orgData.data?.clientPrincipal?.userDetails && ( + + )} {orgData.data?.clientPrincipal?.userDetails && ( { )} + { + popover.handleClose(); + refreshAccessDialog.handleOpen(); + }} + > + + + + + + + diff --git a/frontend/src/pages/unauthenticated.js b/frontend/src/pages/unauthenticated.js index 4e170132fb..c9dc6a2832 100644 --- a/frontend/src/pages/unauthenticated.js +++ b/frontend/src/pages/unauthenticated.js @@ -1,10 +1,11 @@ import Head from 'next/head' -import { useMemo } from 'react' -import { Box, Stack, SvgIcon, Typography } from '@mui/material' -import { Microsoft, PersonOutlineOutlined } from '@mui/icons-material' +import { useMemo, useState } from 'react' +import { Alert, Box, Button, Stack, SvgIcon, Typography } from '@mui/material' +import { Microsoft, PersonOutlineOutlined, Refresh } from '@mui/icons-material' import { CippAuthShell } from '../components/CippComponents/CippAuthShell' import { CippImpersonationBanner } from '../components/CippComponents/CippImpersonationBanner' -import { ApiGetCall } from '../api/ApiCall' +import { ApiGetCall, ApiPostCall } from '../api/ApiCall' +import { getCippError } from '../utils/get-cipp-error' import { hasSeenSession } from '../utils/auth-session' const LOGIN_BASE = '/.auth/login/aad?prompt=select_account' @@ -50,6 +51,26 @@ const Page = ({ reason = 'session' }) => { swaStatus.isSuccess && !!swaStatus?.data?.clientPrincipal && userRoles.length > 0 const signedInAs = swaStatus?.data?.clientPrincipal?.userDetails + // Server-side re-check of Entra group membership, for roles granted through a PIM-activated + // group. Invalidating authmecipp makes PrivateRoute refetch /api/me, so a successful + // elevation walks the user straight into the app without another sign-in. + const [refreshResult, setRefreshResult] = useState(null) + const refreshAccess = ApiPostCall({ + relatedQueryKeys: ['authmecipp'], + onResult: (result) => + setRefreshResult({ + severity: result?.Roles?.length > 0 ? 'success' : 'info', + text: result?.Results ?? 'Access refreshed.', + }), + }) + const handleRefreshAccess = () => { + setRefreshResult(null) + refreshAccess.mutate( + { url: '/api/ExecRefreshMyAccess', data: {} }, + { onError: (error) => setRefreshResult({ severity: 'warning', text: getCippError(error) }) } + ) + } + // A signed-in identity plus a /me message is not a missing session — it's a denial the // server explained (e.g. "your IP is not in the allowed range"). Show the explanation // instead of the generic sign-in prompt, whatever reason the caller guessed. Without a @@ -114,6 +135,35 @@ const Page = ({ reason = 'session' }) => { actionHref: loginUrl(), secondaryText: canReturnHome ? 'Return to Home' : undefined, secondaryHref: canReturnHome ? '/' : undefined, + busy: refreshAccess.isPending, + // below the card rather than in its button row: both slots are taken when the user + // already holds roles, and that is exactly the PIM case (standing readonly, elevated + // to admin) this affordance exists for + children: ( + + {refreshResult && {refreshResult.text}} + + + + Just activated a role through PIM? Re-check your access. + + + + ), } return ( From 6ddd24f245b10051b33dc8a5a45ea76fd43823ef Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Wed, 19 Aug 2026 00:47:55 +0800 Subject: [PATCH 165/226] fix(auth): apply role group mapping changes to user access immediately Changing which Entra group maps to a CIPP role only bumped the access scope version, which covers what a role can see - not which roles a user resolves to. Users kept their previously resolved roles for up to 15 minutes (the cacheAccessUserRoles TTL) plus the allowedUsers sync interval. ExecCustomRole now detects an actual mapping change (assign, reassign, unmap, or role delete with a mapping) and clears the cached per-user resolutions via the new Clear-CippAccessUserCache helper, then runs the user sync and invalidates CRAFT's user cache, matching ExecCIPPUsers. Permission-only role edits skip the fanout. Set-CIPPAccessRole gets the same treatment, and a repair: it always threw on its string-typed Group parameter, wrote the mapping under the wrong partition key with an invalid -Table argument, and its pre-read used '=' instead of 'eq'. It now writes the same shape ExecCustomRole does. --- .../Clear-CippAccessUserCache.ps1 | 43 ++++ .../Authentication/Set-CIPPAccessRole.ps1 | 21 +- .../CIPP/Settings/Invoke-ExecCustomRole.ps1 | 19 ++ .../Endpoint/Invoke-ExecCustomRole.Tests.ps1 | 207 ++++++++++++++++++ 4 files changed, 282 insertions(+), 8 deletions(-) create mode 100644 backend/Modules/CIPPCore/Public/Authentication/Clear-CippAccessUserCache.ps1 create mode 100644 backend/Tests/Endpoint/Invoke-ExecCustomRole.Tests.ps1 diff --git a/backend/Modules/CIPPCore/Public/Authentication/Clear-CippAccessUserCache.ps1 b/backend/Modules/CIPPCore/Public/Authentication/Clear-CippAccessUserCache.ps1 new file mode 100644 index 0000000000..0af199897b --- /dev/null +++ b/backend/Modules/CIPPCore/Public/Authentication/Clear-CippAccessUserCache.ps1 @@ -0,0 +1,43 @@ +function Clear-CippAccessUserCache { + <# + .SYNOPSIS + Clear the cached per-user role resolutions. + + .DESCRIPTION + Deletes every cached user-to-role resolution (the AccessUser partition of + cacheAccessUserRoles) so the next request re-resolves Entra group membership instead of + reusing roles derived from the old group mappings. The cache repopulates on demand. + + Call this from anything that changes which Entra group maps to a CIPP role. The + companion Clear-CippAccessScopeCache covers what a role is allowed to see; this covers + which roles a user resolves to. Callers that also maintain the allowedUsers projection + should fire Start-UserSyncTimer and invalidate CRAFT's user cache alongside this. + + A failure is logged rather than thrown - the mapping change the operator just saved is + already durable, and the cache TTL bounds how long a missed clear can linger. + + .EXAMPLE + Clear-CippAccessUserCache + + .FUNCTIONALITY + Internal + #> + [CmdletBinding(SupportsShouldProcess = $true)] + param() + + if (-not $PSCmdlet.ShouldProcess('cacheAccessUserRoles', 'Clear cached user role resolutions')) { + return + } + + try { + $Table = Get-CippTable -TableName 'cacheAccessUserRoles' + $CachedUsers = Get-CIPPAzDataTableEntity @Table -Filter "PartitionKey eq 'AccessUser'" + foreach ($CachedUser in @($CachedUsers)) { + if ($CachedUser) { + Remove-CIPPAzDataTableEntity -Force @Table -Entity $CachedUser + } + } + } catch { + Write-LogMessage -API 'AccessUserCache' -message "Failed to clear cached user roles. Users keep their previously resolved roles until the cache expires. $($_.Exception.Message)" -Sev 'Error' + } +} diff --git a/backend/Modules/CIPPCore/Public/Authentication/Set-CIPPAccessRole.ps1 b/backend/Modules/CIPPCore/Public/Authentication/Set-CIPPAccessRole.ps1 index 9bfa11f84f..623a318b47 100644 --- a/backend/Modules/CIPPCore/Public/Authentication/Set-CIPPAccessRole.ps1 +++ b/backend/Modules/CIPPCore/Public/Authentication/Set-CIPPAccessRole.ps1 @@ -4,13 +4,15 @@ function Set-CIPPAccessRole { Set the access role mappings .DESCRIPTION - Set the access role mappings for Entra groups + Set the access role mapping for an Entra group, and apply the change immediately: the + cached per-user role resolutions are cleared and the allowedUsers projection CRAFT + authenticates against is refreshed, so nobody waits out the caches. .PARAMETER Role The role to set (e.g. 'superadmin','admin','editor','readonly','customrole') .PARAMETER Group - The Entra group to set the role for + The Entra group to map to the role, as an object carrying id and displayName .FUNCTIONALITY Internal @@ -20,7 +22,7 @@ function Set-CIPPAccessRole { [Parameter(Mandatory = $true)] [string]$Role, [Parameter(Mandatory = $true)] - [string]$Group + $Group ) $BlacklistedRoles = @('authenticated', 'anonymous') @@ -35,21 +37,24 @@ function Set-CIPPAccessRole { $Role = $Role.ToLower().Trim() -replace ' ', '' + # PartitionKey must match what Test-CIPPAccessUserRole and Start-UserSyncTimer read. $Table = Get-CippTable -TableName AccessRoleGroups - $AccessGroup = Get-CIPPAzDataTableEntity @Table -Filter "RowKey = '$Role'" - $AccessGroup = [PSCustomObject]@{ - PartitionKey = [string]'AccessRole' + PartitionKey = [string]'AccessRoleGroups' RowKey = [string]$Role GroupId = [string]$Group.id GroupName = [string]$Group.displayName } if ($PSCmdlet.ShouldProcess("Setting access role $Role for group $($Group.displayName)")) { - Add-CIPPAzDataTableEntity -Table $Table -Entity $AccessGroup -Force + Add-CIPPAzDataTableEntity @Table -Entity $AccessGroup -Force # Group to role mapping decides which roles a user resolves to, so the cached scope rules - # have to be invalidated with it + # have to be invalidated with it - and so do the cached per-user resolutions plus the + # allowedUsers projection CRAFT authenticates against. Clear-CippAccessScopeCache + Clear-CippAccessUserCache + try { Start-UserSyncTimer } catch {} + try { [Craft.Services.AuthBridge]::InvalidateUsers() } catch {} } } diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecCustomRole.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecCustomRole.ps1 index 31dfc61c50..1594453d77 100644 --- a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecCustomRole.ps1 +++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecCustomRole.ps1 @@ -26,6 +26,10 @@ function Invoke-ExecCustomRole { throw "Role name $($Request.Body.RoleName) cannot be used" } + # Set when an action changes which Entra group maps to a role - that invalidates every + # user's cached role resolution, not just the scope rules. + $AccessGroupChanged = $false + switch ($Action) { 'AddUpdate' { try { @@ -113,6 +117,7 @@ function Invoke-ExecCustomRole { } } if ($Request.Body.EntraGroup) { + $ExistingRoleGroup = Get-CIPPAzDataTableEntity @AccessRoleGroupTable -Filter "PartitionKey eq 'AccessRoleGroups' and RowKey eq '$($Request.Body.RoleName.ToLower())'" $RoleGroup = @{ 'PartitionKey' = 'AccessRoleGroups' 'RowKey' = "$($Request.Body.RoleName.ToLower())" @@ -120,12 +125,16 @@ function Invoke-ExecCustomRole { 'GroupName' = $Request.Body.EntraGroup.label } Add-CIPPAzDataTableEntity @AccessRoleGroupTable -Entity $RoleGroup -Force | Out-Null + if (!$ExistingRoleGroup -or $ExistingRoleGroup.GroupId -ne $Request.Body.EntraGroup.value) { + $AccessGroupChanged = $true + } $Results.Add("Security group '$($Request.Body.EntraGroup.label)' assigned to the '$($Request.Body.RoleName)' role.") Write-LogMessage -headers $Request.Headers -API 'ExecCustomRole' -message "Security group '$($Request.Body.EntraGroup.label)' assigned to the '$($Request.Body.RoleName)' role." -Sev 'Info' } else { $AccessRoleGroup = Get-CIPPAzDataTableEntity @AccessRoleGroupTable -Filter "RowKey eq '$($Request.Body.RoleName)'" if ($AccessRoleGroup) { Remove-CIPPAzDataTableEntity -Force @AccessRoleGroupTable -Entity $AccessRoleGroup + $AccessGroupChanged = $true $Results.Add("Security group '$($AccessRoleGroup.GroupName)' removed from the '$($Request.Body.RoleName)' role.") Write-LogMessage -headers $Request.Headers -API 'ExecCustomRole' -message "Security group '$($AccessRoleGroup.GroupName)' removed from the '$($Request.Body.RoleName)' role." -Sev 'Info' } @@ -191,6 +200,7 @@ function Invoke-ExecCustomRole { $AccessRoleGroup = Get-CIPPAzDataTableEntity @AccessRoleGroupTable -Filter "PartitionKey eq 'AccessRoleGroups' and RowKey eq '$($Request.Body.RoleName)'" if ($AccessRoleGroup) { Remove-CIPPAzDataTableEntity -Force @AccessRoleGroupTable -Entity $AccessRoleGroup + $AccessGroupChanged = $true } $AccessIPRange = Get-CIPPAzDataTableEntity @AccessIPRangeTable -Filter "PartitionKey eq 'AccessIPRanges' and RowKey eq '$($Request.Body.RoleName)'" if ($AccessIPRange) { @@ -337,6 +347,15 @@ function Invoke-ExecCustomRole { Clear-CippAccessScopeCache } + # A group mapping change alters which roles a user resolves to, not just what those roles can + # see. Drop the cached per-user resolutions and refresh the allowedUsers projection CRAFT + # authenticates against, so the change applies now instead of when the caches age out. + if ($AccessGroupChanged) { + Clear-CippAccessUserCache + try { Start-UserSyncTimer } catch {} + try { [Craft.Services.AuthBridge]::InvalidateUsers() } catch {} + } + return ([HttpResponseContext]@{ StatusCode = [HttpStatusCode]::OK Body = $Body diff --git a/backend/Tests/Endpoint/Invoke-ExecCustomRole.Tests.ps1 b/backend/Tests/Endpoint/Invoke-ExecCustomRole.Tests.ps1 new file mode 100644 index 0000000000..fc64da0604 --- /dev/null +++ b/backend/Tests/Endpoint/Invoke-ExecCustomRole.Tests.ps1 @@ -0,0 +1,207 @@ +# Pester tests for Invoke-ExecCustomRole +# +# Focused on the cache fanout: changing which Entra group maps to a role must clear the +# cached per-user role resolutions (cacheAccessUserRoles) and refresh the allowedUsers +# projection, not just bump the access-scope version. Permission-only edits must NOT pay +# for a full user re-sync. + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $FunctionPath = Get-ChildItem -Path (Join-Path $RepoRoot 'Modules') -Recurse -Filter 'Invoke-ExecCustomRole.ps1' -File -ErrorAction SilentlyContinue | + Select-Object -First 1 -ExpandProperty FullName + if (-not $FunctionPath) { throw 'Could not locate Invoke-ExecCustomRole.ps1 under Modules/' } + + class HttpResponseContext { + [object]$StatusCode + [object]$Body + } + + $Accelerators = [psobject].Assembly.GetType('System.Management.Automation.TypeAccelerators') + if (-not $Accelerators::Get.ContainsKey('HttpStatusCode')) { + $Accelerators::Add('HttpStatusCode', [System.Net.HttpStatusCode]) + } + + # The endpoint reads Config\cipp-roles.json relative to CIPPRootPath. + $script:OriginalCippRootPath = $env:CIPPRootPath + $env:CIPPRootPath = $RepoRoot + + function Get-CippTable { param($tablename) } + function Get-CIPPAzDataTableEntity { param($TableName, $Filter, $Property) } + function Add-CIPPAzDataTableEntity { param($TableName, $Entity, [switch]$Force) } + function Remove-CIPPAzDataTableEntity { param($TableName, $Entity, [switch]$Force) } + function Write-LogMessage { param($headers, $API, $message, $Sev, $LogData) } + function Clear-CippAccessScopeCache { } + function Clear-CippAccessUserCache { } + function Start-UserSyncTimer { } + function ConvertTo-CippPermissionRules { param($Permissions) } + function New-GraphGetRequest { param($uri, $tenantid, $NoAuthCheck) } + + . $FunctionPath + + function New-RoleRequest { + param($Body) + [pscustomobject]@{ + Params = @{ CIPPEndpoint = 'ExecCustomRole' } + Query = [pscustomobject]@{ } + Headers = @{ } + Body = [pscustomobject]$Body + } + } +} + +AfterAll { + $env:CIPPRootPath = $script:OriginalCippRootPath +} + +Describe 'Invoke-ExecCustomRole group mapping fanout' { + BeforeEach { + Mock -CommandName Write-LogMessage -MockWith { } + Mock -CommandName Get-CippTable -MockWith { @{ TableName = $tablename } } + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { $null } + Mock -CommandName Add-CIPPAzDataTableEntity -MockWith { } + Mock -CommandName Remove-CIPPAzDataTableEntity -MockWith { } + Mock -CommandName Clear-CippAccessScopeCache -MockWith { } + Mock -CommandName Clear-CippAccessUserCache -MockWith { } + Mock -CommandName Start-UserSyncTimer -MockWith { } + } + + It 'clears the user role cache when a group is first mapped to a role' { + $Request = New-RoleRequest @{ + Action = 'AddUpdate' + RoleName = 'admin' + EntraGroup = [pscustomobject]@{ label = 'CIPP Admins'; value = 'guid-1' } + } + + $Response = Invoke-ExecCustomRole -Request $Request -TriggerMetadata $null + + $Response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::OK) + Should -Invoke Clear-CippAccessUserCache -Times 1 -Exactly + Should -Invoke Start-UserSyncTimer -Times 1 -Exactly + Should -Invoke Clear-CippAccessScopeCache -Times 1 -Exactly + } + + It 'clears the user role cache when the mapped group changes' { + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { + [pscustomobject]@{ PartitionKey = 'AccessRoleGroups'; RowKey = 'admin'; GroupId = 'guid-old'; GroupName = 'Old Group' } + } -ParameterFilter { $TableName -eq 'AccessRoleGroups' } + + $Request = New-RoleRequest @{ + Action = 'AddUpdate' + RoleName = 'admin' + EntraGroup = [pscustomobject]@{ label = 'CIPP Admins'; value = 'guid-1' } + } + + $null = Invoke-ExecCustomRole -Request $Request -TriggerMetadata $null + + Should -Invoke Clear-CippAccessUserCache -Times 1 -Exactly + Should -Invoke Start-UserSyncTimer -Times 1 -Exactly + } + + It 'does not re-sync when the mapping is saved unchanged' { + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { + [pscustomobject]@{ PartitionKey = 'AccessRoleGroups'; RowKey = 'admin'; GroupId = 'guid-1'; GroupName = 'CIPP Admins' } + } -ParameterFilter { $TableName -eq 'AccessRoleGroups' } + + $Request = New-RoleRequest @{ + Action = 'AddUpdate' + RoleName = 'admin' + EntraGroup = [pscustomobject]@{ label = 'CIPP Admins'; value = 'guid-1' } + } + + $null = Invoke-ExecCustomRole -Request $Request -TriggerMetadata $null + + Should -Invoke Clear-CippAccessUserCache -Times 0 -Exactly + Should -Invoke Start-UserSyncTimer -Times 0 -Exactly + # The scope-rule stamp still bumps on every role save. + Should -Invoke Clear-CippAccessScopeCache -Times 1 -Exactly + } + + It 'clears the user role cache when a mapping is removed on save' { + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { + [pscustomobject]@{ PartitionKey = 'AccessRoleGroups'; RowKey = 'admin'; GroupId = 'guid-1'; GroupName = 'CIPP Admins' } + } -ParameterFilter { $TableName -eq 'AccessRoleGroups' } + + $Request = New-RoleRequest @{ + Action = 'AddUpdate' + RoleName = 'admin' + } + + $null = Invoke-ExecCustomRole -Request $Request -TriggerMetadata $null + + Should -Invoke Remove-CIPPAzDataTableEntity -Times 1 -Exactly -ParameterFilter { $TableName -eq 'AccessRoleGroups' } + Should -Invoke Clear-CippAccessUserCache -Times 1 -Exactly + Should -Invoke Start-UserSyncTimer -Times 1 -Exactly + } + + It 'clears the user role cache when deleting a role that had a mapping' { + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { + [pscustomobject]@{ PartitionKey = 'AccessRoleGroups'; RowKey = 'testrole'; GroupId = 'guid-1'; GroupName = 'CIPP Admins' } + } -ParameterFilter { $TableName -eq 'AccessRoleGroups' } + + $Request = New-RoleRequest @{ + Action = 'Delete' + RoleName = 'testrole' + } + + $null = Invoke-ExecCustomRole -Request $Request -TriggerMetadata $null + + Should -Invoke Clear-CippAccessUserCache -Times 1 -Exactly + Should -Invoke Start-UserSyncTimer -Times 1 -Exactly + } + + It 'does not touch the user role cache when deleting a role with no mapping' { + $Request = New-RoleRequest @{ + Action = 'Delete' + RoleName = 'testrole' + } + + $null = Invoke-ExecCustomRole -Request $Request -TriggerMetadata $null + + Should -Invoke Clear-CippAccessUserCache -Times 0 -Exactly + Should -Invoke Start-UserSyncTimer -Times 0 -Exactly + Should -Invoke Clear-CippAccessScopeCache -Times 1 -Exactly + } +} + +Describe 'Clear-CippAccessUserCache' { + BeforeAll { + $HelperPath = Get-ChildItem -Path (Join-Path $RepoRoot 'Modules') -Recurse -Filter 'Clear-CippAccessUserCache.ps1' -File -ErrorAction SilentlyContinue | + Select-Object -First 1 -ExpandProperty FullName + if (-not $HelperPath) { throw 'Could not locate Clear-CippAccessUserCache.ps1 under Modules/' } + . $HelperPath + } + + BeforeEach { + Mock -CommandName Write-LogMessage -MockWith { } + Mock -CommandName Get-CippTable -MockWith { @{ TableName = 'cacheAccessUserRoles' } } + Mock -CommandName Remove-CIPPAzDataTableEntity -MockWith { } + } + + It 'removes every cached AccessUser row' { + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { + @( + [pscustomobject]@{ PartitionKey = 'AccessUser'; RowKey = 'a@contoso.com' } + [pscustomobject]@{ PartitionKey = 'AccessUser'; RowKey = 'b@contoso.com' } + ) + } + + Clear-CippAccessUserCache + + Should -Invoke Remove-CIPPAzDataTableEntity -Times 2 -Exactly + } + + It 'does nothing when the cache is empty' { + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { $null } + + Clear-CippAccessUserCache + + Should -Invoke Remove-CIPPAzDataTableEntity -Times 0 -Exactly + } + + It 'logs instead of throwing when storage is unavailable' { + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { throw 'storage offline' } + + { Clear-CippAccessUserCache } | Should -Not -Throw + Should -Invoke Write-LogMessage -Times 1 -Exactly + } +} From 23fbd3433206b8e6b78a3f56d0fbd5c25715ca8a Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Wed, 19 Aug 2026 00:47:56 +0800 Subject: [PATCH 166/226] chore(api): update api spec --- backend/Config/openapi.json | 63 +++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/backend/Config/openapi.json b/backend/Config/openapi.json index 03b5cc6a5a..fa3bca27f7 100644 --- a/backend/Config/openapi.json +++ b/backend/Config/openapi.json @@ -27938,6 +27938,69 @@ "x-cipp-role": "Exchange.SpamFilter.ReadWrite" } }, + "/api/ExecRefreshMyAccess": { + "get": { + "summary": "Re-check the caller's Entra group membership and refresh their CIPP roles", + "operationId": "ExecRefreshMyAccess", + "tags": [ + "CIPP > Settings" + ], + "description": "Clears the caller's cached role resolution and re-checks Entra group membership, so a\njust-activated PIM group grants its mapped CIPP role without waiting out the role cache.\nOnly ever refreshes the calling user's own access.", + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Derived from the fields written into the storage table it reads. Fields taken from the storage writers may be omitted by this endpoint, and the response may carry computed fields not listed here.", + "properties": { + "ETag": { + "type": "string", + "x-cipp-field-source": "storage" + }, + "PartitionKey": { + "x-cipp-field-source": "storage" + }, + "Role": { + "type": "string", + "x-cipp-field-source": "storage" + }, + "RowKey": { + "type": "string", + "x-cipp-field-source": "storage" + }, + "Timestamp": { + "type": "string", + "x-cipp-field-source": "storage" + } + } + } + } + } + }, + "401": { + "description": "Unauthorized - invalid or missing bearer token" + }, + "403": { + "description": "Forbidden - caller lacks the required RBAC role" + }, + "429": { + "description": "Throttled by the upstream Microsoft API" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "x-cipp-role": "Public", + "x-cipp-any-tenant": true + } + }, "/api/ExecRegistrationCampaign": { "post": { "summary": "ExecRegistrationCampaign", From 0930bdedbd1357f86db1b475a4c8735a7147b08a Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Wed, 19 Aug 2026 01:06:10 +0800 Subject: [PATCH 167/226] fix(sharepoint): show an empty state instead of a permanent skeleton on empty chart cards CippChartCard treated an empty series as a loading state, so a chart whose data is legitimately empty - e.g. Top External Recipients on a tenant with only organization-scope links - rendered a skeleton forever. Loading now shows the skeleton; loaded-but-empty shows a "No data to display" placeholder. --- .../src/components/CippCards/CippChartCard.jsx | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/CippCards/CippChartCard.jsx b/frontend/src/components/CippCards/CippChartCard.jsx index 36a0dd670a..9102a62191 100644 --- a/frontend/src/components/CippCards/CippChartCard.jsx +++ b/frontend/src/components/CippCards/CippChartCard.jsx @@ -154,9 +154,23 @@ export const CippChartCard = ({ { - //if the chartType is not defined, or if the data is fetching, or if the data is empty, show a skeleton - chartType === undefined || isFetching || chartSeries.length === 0 ? ( + //if the chartType is not defined or the data is fetching, show a skeleton; an empty + //series after loading is real data ("nothing to chart"), not a loading state + chartType === undefined || isFetching ? ( + ) : chartSeries.length === 0 ? ( + + + No data to display + + ) : ( Date: Wed, 19 Aug 2026 01:11:16 +0800 Subject: [PATCH 168/226] fix(sharepoint): treat locked sites as inactive instead of failed in the sharing-links scan A NoAccess-locked site (typically an offboarded user's OneDrive) blocks all content access including sharing-link redemption, so its links are dead while the lock stands. The scan previously completed such sites as failed, which protected their cached rows every cycle and logged a warning per site per scan. A locked site now completes un-failed without scanning, letting finalisation prune its inactive links; an unlock later triggers a fresh full scan that re-adds them. Locks appearing mid-scan get the same treatment at the drive level. --- ...Push-DBCacheSharePointSiteSharingLinks.ps1 | 32 ++++++++++++++++--- .../SharePointSharingLinks.Resume.Tests.ps1 | 16 +++++++++- 2 files changed, 43 insertions(+), 5 deletions(-) diff --git a/backend/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/SharePoint Sharing/Push-DBCacheSharePointSiteSharingLinks.ps1 b/backend/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/SharePoint Sharing/Push-DBCacheSharePointSiteSharingLinks.ps1 index 2d97d858cc..7f9b9ca82a 100644 --- a/backend/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/SharePoint Sharing/Push-DBCacheSharePointSiteSharingLinks.ps1 +++ b/backend/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/SharePoint Sharing/Push-DBCacheSharePointSiteSharingLinks.ps1 @@ -281,6 +281,16 @@ function Push-DBCacheSharePointSiteSharingLinks { try { $Drives = @(New-GraphGetRequest -uri "https://graph.microsoft.com/beta/sites/$SiteId/drives?`$select=id,name,driveType,webUrl" -tenantid $TenantFilter -asapp $true) } catch { + if ($_.Exception.Message -match 'Access to this site has been blocked') { + # A NoAccess-locked site (typically an offboarded user's OneDrive) blocks ALL + # content access, sharing-link redemption included - its links are dead while + # the lock stands. Complete un-failed WITHOUT scanning: finalisation then + # prunes the site's stale rows, and an unlock later triggers a fresh full + # scan that re-adds them. + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Sharing links: skipping locked site '$SiteUrl' - access is blocked, so its sharing links are inactive" -sev Info + Complete-Site + return @() + } Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Sharing links: could not list drives for '$SiteUrl': $($_.Exception.Message)" -sev Warning Complete-Site -Failed return @() @@ -515,10 +525,16 @@ function Push-DBCacheSharePointSiteSharingLinks { Set-DriveState -DeltaLink $DeltaLink -FullScan } } catch { - Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Sharing links: failed scanning drive '$($Drive.name)' on '$SiteUrl': $($_.Exception.Message)" -sev Warning - # A current LastScanId with an empty token both protects this drive's cached - # rows from pruning and forces the next scan to run full. - Set-DriveState -DeltaLink '' + if ($_.Exception.Message -match 'Access to this site has been blocked') { + # Site locked mid-scan: links are inactive, so leave the drive state stale + # for finalisation to prune rather than protecting this drive's rows. + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Sharing links: drive '$($Drive.name)' on '$SiteUrl' is locked; leaving its rows for pruning" -sev Info + } else { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Sharing links: failed scanning drive '$($Drive.name)' on '$SiteUrl': $($_.Exception.Message)" -sev Warning + # A current LastScanId with an empty token both protects this drive's cached + # rows from pruning and forces the next scan to run full. + Set-DriveState -DeltaLink '' + } } Remove-DriveCheckpoint Complete-Drive @@ -560,6 +576,14 @@ function Push-DBCacheSharePointSiteSharingLinks { $ExistingRowsByItem = $null continue } + if ($ErrorMessage -match 'Access to this site has been blocked') { + # Site locked mid-scan: links are inactive, so leave the drive state stale + # for finalisation to prune rather than protecting this drive's rows. + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Sharing links: drive '$($Drive.name)' on '$SiteUrl' is locked; leaving its rows for pruning" -sev Info + Remove-DriveCheckpoint + Complete-Drive + return @() + } Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Sharing links: failed scanning drive '$($Drive.name)' on '$SiteUrl': $ErrorMessage" -sev Warning $DriveFailed = $true break diff --git a/backend/Tests/DBCache/SharePointSharingLinks.Resume.Tests.ps1 b/backend/Tests/DBCache/SharePointSharingLinks.Resume.Tests.ps1 index c6e2927467..27d71e927e 100644 --- a/backend/Tests/DBCache/SharePointSharingLinks.Resume.Tests.ps1 +++ b/backend/Tests/DBCache/SharePointSharingLinks.Resume.Tests.ps1 @@ -283,7 +283,7 @@ Describe 'Per-drive sharing-links scan' { It 'completes the site as failed when the drive listing is refused' { $ScanId = 'scan-dispatch-2' Initialize-TestScan -ScanId $ScanId -TotalSites 2 - $script:GraphGetHandler = { param($Uri) throw 'Access to this site has been blocked.' } + $script:GraphGetHandler = { param($Uri) throw 'The request has been throttled' } Push-DBCacheSharePointSiteSharingLinks -Item (New-SiteItem -ScanId $ScanId) @@ -292,6 +292,20 @@ Describe 'Per-drive sharing-links scan' { # Not the last site, so no finalisation. Get-CacheRowKeys | Should -Not -Contain 'SharePointSharingLinks-Count' } + + It 'skips a locked site un-failed so finalisation prunes its dead links' { + $ScanId = 'scan-dispatch-3' + Initialize-TestScan -ScanId $ScanId -TotalSites 2 + $script:GraphGetHandler = { param($Uri) throw 'Access to this site has been blocked. Please contact the administrator to resolve this problem.' } + + Push-DBCacheSharePointSiteSharingLinks -Item (New-SiteItem -ScanId $ScanId) + + # Completed un-failed and nothing dispatched: the site's stale drive rows are left + # unprotected, which is what lets finalisation prune its now-inactive links. + $Marker = (Get-FakeTableRows -TableName 'CippSharingLinksState') | Where-Object { $_.RowKey -eq 'done-contoso.sharepoint.com,site1,web1' } + [string]$Marker.Failed | Should -Be 'False' + $script:Orchestrations.Count | Should -Be 0 + } } Context 'Principal-mode full scan of a team-site drive' { From d0f916aec852ee3f506ff15c69ff9fffcdfdd914 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Wed, 19 Aug 2026 01:23:17 +0800 Subject: [PATCH 169/226] fix(sharepoint): authenticate StorageQuotas reads with the SAM certificate The tenant quota endpoint and the quota alert called SPO admin REST StorageQuotas() with a delegated client-secret token, which 401s on tenants where the service account lacks SharePoint admin rights; the endpoint then swallowed the failure into "Not available" and the alert silently skipped the tenant. Cert-based app-only auth - the same mode the other SPO admin REST callers already use - succeeds on the tenants the delegated call failed on. --- .../Public/Alerts/Get-CIPPAlertSharepointQuota.ps1 | 4 +++- .../Teams-Sharepoint/Invoke-ListSharepointQuota.ps1 | 5 ++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/backend/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertSharepointQuota.ps1 b/backend/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertSharepointQuota.ps1 index 7c8efa8cdf..3e0f6cd935 100644 --- a/backend/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertSharepointQuota.ps1 +++ b/backend/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertSharepointQuota.ps1 @@ -15,7 +15,9 @@ function Get-CIPPAlertSharepointQuota { $extraHeaders = @{ 'Accept' = 'application/json' } - $sharepointQuota = (New-GraphGetRequest -extraHeaders $extraHeaders -scope "$($SharePointInfo.AdminUrl)/.default" -tenantid $TenantFilter -uri "$($SharePointInfo.AdminUrl)/_api/StorageQuotas()?api-version=1.3.2") + # Cert-based app-only auth: SPO admin REST 401s delegated client-secret tokens on + # tenants where the service account lacks SharePoint admin rights. + $sharepointQuota = (New-GraphGetRequest -extraHeaders $extraHeaders -scope "$($SharePointInfo.AdminUrl)/.default" -tenantid $TenantFilter -uri "$($SharePointInfo.AdminUrl)/_api/StorageQuotas()?api-version=1.3.2" -asapp $true -UseCertificate) } catch { return } diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSharepointQuota.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSharepointQuota.ps1 index 58fd78d785..01ca13eb14 100644 --- a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSharepointQuota.ps1 +++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSharepointQuota.ps1 @@ -25,7 +25,10 @@ Function Invoke-ListSharepointQuota { # collection, on every other tenant a single row. Used storage is therefore the sum # across geos, while TenantStorageMB is the shared tenant pool repeated identically # on every row and must be taken once rather than summed. - $SharePointQuota = New-GraphGetRequest -extraHeaders $extraHeaders -scope "$($SharePointInfo.AdminUrl)/.default" -tenantid $TenantFilter -uri "$($SharePointInfo.AdminUrl)/_api/StorageQuotas()?api-version=1.3.2" + # Cert-based app-only auth: SPO admin REST 401s delegated client-secret tokens on + # tenants where the service account lacks SharePoint admin rights, which made this + # endpoint silently return 'Not available'. + $SharePointQuota = New-GraphGetRequest -extraHeaders $extraHeaders -scope "$($SharePointInfo.AdminUrl)/.default" -tenantid $TenantFilter -uri "$($SharePointInfo.AdminUrl)/_api/StorageQuotas()?api-version=1.3.2" -asapp $true -UseCertificate $GeoUsedStorageMB = ($SharePointQuota.GeoUsedStorageMB | Measure-Object -Sum).Sum $TenantStorageMB = $SharePointQuota.TenantStorageMB | Select-Object -First 1 From 4f2b055790e59806ae5131dcd7f659d457b9fa98 Mon Sep 17 00:00:00 2001 From: Brian Simpson <50429915+bmsimp@users.noreply.github.com> Date: Tue, 18 Aug 2026 17:29:33 -0500 Subject: [PATCH 170/226] docs(shared-features): document the entity switcher Detail pages now render their title as a switcher that opens a searchable list of sibling records, so you can move between users, groups, devices, app registrations and enterprise applications without going back to the table. Adds the shared page covering it, registers it in the nav, and points the View Individual User header paragraph at it. Co-Authored-By: Claude Opus 5 (1M context) --- docs/SUMMARY.md | 1 + .../administration/users/user/README.md | 2 +- .../shared-features/entity-switcher.md | 33 +++++++++++++++++++ 3 files changed, 35 insertions(+), 1 deletion(-) create mode 100644 docs/user-documentation/shared-features/entity-switcher.md diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index f2e6473ce2..28aa108c1b 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -95,6 +95,7 @@ * [Variable Auto Complete](user-documentation/shared-features/variable-auto-complete.md) * [Release Notes Notification](user-documentation/shared-features/release-notes-notification.md) * [Breadcrumb Navigation](user-documentation/shared-features/breadcrumb-navigation.md) + * [Entity Switcher](user-documentation/shared-features/entity-switcher.md) * [Global Page Icon](user-documentation/shared-features/global-page-icon.md) * [CIPP Dashboard](user-documentation/dashboard/README.md) * [Identity](user-documentation/dashboard/identity.md) diff --git a/docs/user-documentation/identity/administration/users/user/README.md b/docs/user-documentation/identity/administration/users/user/README.md index 7a0e264b28..5b64f70b13 100644 --- a/docs/user-documentation/identity/administration/users/user/README.md +++ b/docs/user-documentation/identity/administration/users/user/README.md @@ -1,6 +1,6 @@ # View Individual User -This page brings together everything CIPP knows about a single user, and is where most investigation starts before an action is taken. The header shows the user's display name along with their user principal name, object ID and creation date, each of which can be copied, and a **View in Entra** button that opens the same account in the Microsoft Entra admin center. The **Actions** menu in the header offers the same [#table-actions](../#table-actions "mention") available from the Users list, minus the ones that navigate elsewhere: View User, Edit User and Research Compromised Account are reachable from the tabs instead. +This page brings together everything CIPP knows about a single user, and is where most investigation starts before an action is taken. The header shows the user's display name along with their user principal name, object ID and creation date, each of which can be copied, and a **View in Entra** button that opens the same account in the Microsoft Entra admin center. The display name is also a switcher, opening the tenant's user list so you can move to another account without going back to the Users table: see [entity-switcher.md](../../../../shared-features/entity-switcher.md "mention"). The **Actions** menu in the header offers the same [#table-actions](../#table-actions "mention") available from the Users list, minus the ones that navigate elsewhere: View User, Edit User and Research Compromised Account are reachable from the tabs instead. Apart from the profile photo, the MFA method controls and the role removal action described below, everything on this page is read only. Use the Edit User tab to change the account. diff --git a/docs/user-documentation/shared-features/entity-switcher.md b/docs/user-documentation/shared-features/entity-switcher.md new file mode 100644 index 0000000000..56c9dbd2d3 --- /dev/null +++ b/docs/user-documentation/shared-features/entity-switcher.md @@ -0,0 +1,33 @@ +# Entity Switcher + +On a page that shows a single record, the page title is a control rather than plain text. It carries a small chevron, and selecting it opens a searchable list of the other records of the same kind, so you can move from one to the next without going back to the table you came from. + +## Where It Appears + +| Page | The list holds | Shown beneath each name | +| -------------------------------------------------------------------------------------------------------- | ------------------------------------------ | -------------------------------- | +| [View Individual User](../identity/administration/users/user/ "mention"), on every tab | Every user in the tenant | User principal name | +| Group | Every group in the tenant | Mail address | +| Device | Every Intune managed device in the tenant | The user principal name recorded against the device | +| App Registration, on both tabs | Every app registration in the tenant | Application (client) ID | +| Enterprise Application, on both tabs | Every enterprise application in the tenant | Application ID | + +## Using It + +Selecting the title opens the list, ordered alphabetically by name. The box at the top filters as you type and matches on both lines of an entry, so a user can be found by display name or by user principal name. A tick marks the record you are already viewing. + +Choosing a record loads it in place. You stay on the tab you were on, so moving from one user's Exchange settings to another's takes a single selection rather than a trip back through the users list, and the tenant you are working in does not change. + +{% hint style="info" %} +The list is fetched the first time you open it rather than with the page, so there can be a short pause on a large tenant while it loads. It is held for the rest of your session after that. +{% endhint %} + +{% hint style="warning" %} +The list is not affected by any filter, search or preset applied to the table you arrived from. It holds every record of that kind in the tenant, so an account hidden from your table view still appears here. +{% endhint %} + +## On Narrow Screens + +On a phone the list opens as a sheet from the bottom of the screen, headed with the record type, and works the same way. See [mobile-layout.md](mobile-layout.md "mention"). + +{% include "../../../.gitbook/includes/feature-request.md" %} From 08c22eecd115a3a872161c252f9d145dc2dcf7ba Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 19 Aug 2026 06:08:54 +0000 Subject: [PATCH 171/226] chore(licenses): update Microsoft license SKU data --- backend/Config/ConversionTable.csv | 22 +--- frontend/src/data/M365Licenses.json | 176 ++++------------------------ 2 files changed, 27 insertions(+), 171 deletions(-) diff --git a/backend/Config/ConversionTable.csv b/backend/Config/ConversionTable.csv index cdd72dd19b..74131ea425 100644 --- a/backend/Config/ConversionTable.csv +++ b/backend/Config/ConversionTable.csv @@ -1864,7 +1864,6 @@ Microsoft 365 E3,SPE_E3,05e9a617-0261-4cee-bb44-138d3ef5d965,KAIZALA_O365_P3,aeb Microsoft 365 E3,SPE_E3,05e9a617-0261-4cee-bb44-138d3ef5d965,FORMS_PLAN_E3,2789c901-c14e-48ab-a76a-be334d9d793a,Microsoft Forms (Plan E3) Microsoft 365 E3,SPE_E3,05e9a617-0261-4cee-bb44-138d3ef5d965,MDE_LITE,292cc034-7b7c-4950-aaf5-943befd3f1d4,Microsoft Defender for Endpoint Plan 1 Microsoft 365 E3,SPE_E3,05e9a617-0261-4cee-bb44-138d3ef5d965,MICROSOFT_SEARCH,94065c59-bc8e-4e8b-89e5-5138d471eaff,Microsoft Search -Microsoft 365 E3,SPE_E3,05e9a617-0261-4cee-bb44-138d3ef5d965,ATP_ENTERPRISE,f20fedf3-f3c3-43c3-8267-2bfdd51c0939,Microsoft Defender for Office 365 (Plan 1) Microsoft 365 E3 - Unattended License,SPE_E3_RPA1,c2ac2ee4-9bb1-47e4-8541-d689c7e83371,SHAREPOINTENTERPRISE,5dbe027f-2339-4123-9542-606e4d348a72,SharePoint (Plan 2) Microsoft 365 E3 - Unattended License,SPE_E3_RPA1,c2ac2ee4-9bb1-47e4-8541-d689c7e83371,PROJECT_O365_P2,31b4e2fc-4cd6-4e7d-9c1b-41407303bd66,Project for Office (Plan E3) Microsoft 365 E3 - Unattended License,SPE_E3_RPA1,c2ac2ee4-9bb1-47e4-8541-d689c7e83371,MCOSTANDARD,0feaeb32-d00e-4d66-bd5a-43b5b83db82c,Skype for Business Online (Plan 2) @@ -2148,7 +2147,6 @@ Microsoft 365 E3_USGOV_DOD,SPE_E3_USGOV_DOD,d61d61cc-f992-433f-a577-5bd016037eeb Microsoft 365 E3_USGOV_DOD,SPE_E3_USGOV_DOD,d61d61cc-f992-433f-a577-5bd016037eeb,AAD_PREMIUM,41781fb2-bc02-4b7c-bd55-b576c07bb09d,Microsoft Entra ID P1 Microsoft 365 E3_USGOV_DOD,SPE_E3_USGOV_DOD,d61d61cc-f992-433f-a577-5bd016037eeb,SHAREPOINTENTERPRISE,5dbe027f-2339-4123-9542-606e4d348a72,SharePoint Online (Plan 2) Microsoft 365 E3_USGOV_DOD,SPE_E3_USGOV_DOD,d61d61cc-f992-433f-a577-5bd016037eeb,RMS_S_ENTERPRISE,bea4c11e-220a-4e6d-8eb8-8ea15d019f90,Microsoft Microsoft Entra Rights -Microsoft 365 E3_USGOV_DOD,SPE_E3_USGOV_DOD,d61d61cc-f992-433f-a577-5bd016037eeb,ATP_ENTERPRISE,f20fedf3-f3c3-43c3-8267-2bfdd51c0939,Microsoft Defender for Office 365 (Plan 1) Microsoft 365 E3_USGOV_GCCHIGH,SPE_E3_USGOV_GCCHIGH,ca9d1dd9-dfe9-4fef-b97c-9bc1ea3c3658,OFFICESUBSCRIPTION,43de0ff5-c92c-492b-9116-175376d08c38,Office 365 ProPlus Microsoft 365 E3_USGOV_GCCHIGH,SPE_E3_USGOV_GCCHIGH,ca9d1dd9-dfe9-4fef-b97c-9bc1ea3c3658,STREAM_O365_E3,9e700747-8b1d-45e5-ab8d-ef187ceec156,Microsoft Stream for O365 E3 SKU Microsoft 365 E3_USGOV_GCCHIGH,SPE_E3_USGOV_GCCHIGH,ca9d1dd9-dfe9-4fef-b97c-9bc1ea3c3658,TEAMS_AR_GCCHIGH,9953b155-8aef-4c56-92f3-72b0487fce41,Microsoft Teams for GCCHigh (AR) @@ -2163,7 +2161,6 @@ Microsoft 365 E3_USGOV_GCCHIGH,SPE_E3_USGOV_GCCHIGH,ca9d1dd9-dfe9-4fef-b97c-9bc1 Microsoft 365 E3_USGOV_GCCHIGH,SPE_E3_USGOV_GCCHIGH,ca9d1dd9-dfe9-4fef-b97c-9bc1ea3c3658,RMS_S_PREMIUM,6c57d4b6-3b23-47a5-9bc9-69f17b4947b3,Azure Information Protection Premium P Microsoft 365 E3_USGOV_GCCHIGH,SPE_E3_USGOV_GCCHIGH,ca9d1dd9-dfe9-4fef-b97c-9bc1ea3c3658,RMS_S_ENTERPRISE,bea4c11e-220a-4e6d-8eb8-8ea15d019f90,Microsoft Microsoft Entra Rights Microsoft 365 E3_USGOV_GCCHIGH,SPE_E3_USGOV_GCCHIGH,ca9d1dd9-dfe9-4fef-b97c-9bc1ea3c3658,ADALLOM_S_DISCOVERY,932ad362-64a8-4783-9106-97849a1a30b9,Cloud App Security Discovery -Microsoft 365 E3_USGOV_GCCHIGH,SPE_E3_USGOV_GCCHIGH,ca9d1dd9-dfe9-4fef-b97c-9bc1ea3c3658,ATP_ENTERPRISE,f20fedf3-f3c3-43c3-8267-2bfdd51c0939,Microsoft Defender for Office 365 (Plan 1) Microsoft 365 E5,SPE_E5,06ebc4ee-1bb5-47dd-8120-11324bc54e06,Deskless,8c7d2df8-86f0-4902-b2ed-a0458298f3b3,Microsoft StaffHub Microsoft 365 E5,SPE_E5,06ebc4ee-1bb5-47dd-8120-11324bc54e06,MCOSTANDARD,0feaeb32-d00e-4d66-bd5a-43b5b83db82c,Skype for Business Online (Plan 2) Microsoft 365 E5,SPE_E5,06ebc4ee-1bb5-47dd-8120-11324bc54e06,SHAREPOINTENTERPRISE,5dbe027f-2339-4123-9542-606e4d348a72,SharePoint (Plan 2) @@ -2928,8 +2925,6 @@ Microsoft 365 E5 Suite features,M365_E5_SUITE_COMPONENTS,99cc8282-2f74-4954-83b7 Microsoft 365 E5 Suite features,M365_E5_SUITE_COMPONENTS,99cc8282-2f74-4954-83b7-c6a9a1999067,WINDOWSUPDATEFORBUSINESS_DEPLOYMENTSERVICE,7bf960f6-2cd9-443a-8046-5dbff9558365,Windows Update for Business Deployment Service Microsoft 365 E5 Suite features,M365_E5_SUITE_COMPONENTS,99cc8282-2f74-4954-83b7-c6a9a1999067,Defender_for_Iot_Enterprise,99cd49a9-0e54-4e07-aea1-d8d9f5f704f5,Defender for IoT - Enterprise IoT Security Microsoft 365 E5 Suite features,M365_E5_SUITE_COMPONENTS,99cc8282-2f74-4954-83b7-c6a9a1999067,MESH_AVATARS_ADDITIONAL_FOR_TEAMS,3efbd4ed-8958-4824-8389-1321f8730af8,Avatars for Teams (additional) -Microsoft 365 E5 Suite features,M365_E5_SUITE_COMPONENTS,99cc8282-2f74-4954-83b7-c6a9a1999067,CLOUD_PKI,795aec3a-93a2-45be-92c4-47b9a76340ca,Microsoft Cloud PKI -Microsoft 365 E5 Suite features,M365_E5_SUITE_COMPONENTS,99cc8282-2f74-4954-83b7-c6a9a1999067,3_PARTY_APP_PATCH,3afa0b92-83ef-41c1-8d64-586ab882a951,Intune Enterprise Application Management Microsoft 365 E5 with Calling Minutes,SPE_E5_CALLINGMINUTES,a91fc4e0-65e5-4266-aa76-4037509c1626,PREMIUM_ENCRYPTION,617b097b-4b93-4ede-83de-5f075bb5fb2f,Premium Encryption in Office 365 Microsoft 365 E5 with Calling Minutes,SPE_E5_CALLINGMINUTES,a91fc4e0-65e5-4266-aa76-4037509c1626,BI_AZURE_P2,70d33638-9c74-4d01-bfd3-562de28bd4ba,Power BI Pro Microsoft 365 E5 with Calling Minutes,SPE_E5_CALLINGMINUTES,a91fc4e0-65e5-4266-aa76-4037509c1626,POWERAPPS_O365_P3,9c0dab89-a30c-4117-86e7-97bda240acd2,Power Apps for Office 365 (Plan 3) @@ -3540,7 +3535,6 @@ Microsoft 365 G3 GCC,M365_G3_GOV,e823ca47-49c4-46b3-b38d-ca11d5abe3d2,POWERAPPS_ Microsoft 365 G3 GCC,M365_G3_GOV,e823ca47-49c4-46b3-b38d-ca11d5abe3d2,INTUNE_A,c1ec4a95-1f05-45b3-a911-aa3fa01094f5,Microsoft Intune Plan 1 Microsoft 365 G3 GCC,M365_G3_GOV,e823ca47-49c4-46b3-b38d-ca11d5abe3d2,CDS_O365_P2_GCC,a70bbf38-cdda-470d-adb8-5804b8770f41,Common Data Service for Teams Microsoft 365 G3 GCC,M365_G3_GOV,e823ca47-49c4-46b3-b38d-ca11d5abe3d2,FLOW_O365_P2_GOV,c537f360-6a00-4ace-a7f5-9128d0ac1e4b,Power Automate for Office 365 for Government -Microsoft 365 G3 GCC,M365_G3_GOV,e823ca47-49c4-46b3-b38d-ca11d5abe3d2,ATP_ENTERPRISE,f20fedf3-f3c3-43c3-8267-2bfdd51c0939,Microsoft Defender for Office 365 (Plan 1) Microsoft 365 GCC G5,M365_G5_GCC,e2be619b-b125-455f-8660-fb503e431a5d,FORMS_GOV_E5,843da3a8-d2cc-4e7a-9e90-dc46019f964c,Microsoft Forms for Government (Plan E5) Microsoft 365 GCC G5,M365_G5_GCC,e2be619b-b125-455f-8660-fb503e431a5d,CDS_O365_P3_GCC,bce5e5ca-c2fd-4d53-8ee2-58dfffed4c10,Common Data Service for Teams Microsoft 365 GCC G5,M365_G5_GCC,e2be619b-b125-455f-8660-fb503e431a5d,LOCKBOX_ENTERPRISE_GOV,89b5d3b1-3855-49fe-b46c-87c66dbc1526,Customer Lockbox for Government @@ -4128,6 +4122,7 @@ Microsoft Viva Suite,VIVA,61902246-d7cb-453e-85cd-53ee28eec138,VIVA_LEARNING_PRE Microsoft Workplace Analytics,WORKPLACE_ANALYTICS,3d957427-ecdc-4df2-aacd-01cc9d519da8,WORKPLACE_ANALYTICS,f477b0f0-3bb1-4890-940c-40fcee6ce05f,Microsoft Workplace Analytics Microsoft Workplace Analytics,WORKPLACE_ANALYTICS,3d957427-ecdc-4df2-aacd-01cc9d519da8,WORKPLACE_ANALYTICS_INSIGHTS_BACKEND,ff7b261f-d98b-415b-827c-42a3fdf015af,Microsoft Workplace Analytics Insights Backend Microsoft Workplace Analytics,WORKPLACE_ANALYTICS,3d957427-ecdc-4df2-aacd-01cc9d519da8,WORKPLACE_ANALYTICS_INSIGHTS_USER,b622badb-1b45-48d5-920f-4b27a2c0996c,Microsoft Workplace Analytics Insights User +Microsoft Workplace Analytics,WORKPLACE_ANALYTICS,3d957427-ecdc-4df2-aacd-01cc9d519da8,SKILLS_IN_VIVA,ccaebebf-3634-4975-a0ad-3eccb697f393,Skills in Viva Minecraft Education Faculty,MEE_FACULTY,984df360-9a74-4647-8cf8-696749f6247a,EXCHANGE_S_FOUNDATION,113feb6c-3fe4-4440-bddc-54d774bf0318,Exchange Foundation Minecraft Education Faculty,MEE_FACULTY,984df360-9a74-4647-8cf8-696749f6247a,MINECRAFT_EDUCATION_EDITION,4c246bbc-f513-4311-beff-eba54c353256,Minecraft Education Minecraft Education Student,MEE_STUDENT,533b8f26-f74b-4e9c-9c59-50fc4b393b63,MINECRAFT_EDUCATION_EDITION,4c246bbc-f513-4311-beff-eba54c353256,Minecraft Education @@ -4513,7 +4508,6 @@ Office 365 E1 (no Teams),Office_365_E1_(no_Teams),f8ced641-8e17-4dc5-b014-f5a2d5 Office 365 E1 (no Teams),Office_365_E1_(no_Teams),f8ced641-8e17-4dc5-b014-f5a2d53f6ac8,DYN365_CDS_O365_P1,40b010bb-0b69-4654-ac5e-ba161433f4b4,Common Data Service Office 365 E1 (no Teams),Office_365_E1_(no_Teams),f8ced641-8e17-4dc5-b014-f5a2d53f6ac8,MICROSOFTBOOKINGS,199a5c09-e0ca-4e37-8f7c-b05d533e1ea2,Microsoft Bookings Office 365 E1 (no Teams),Office_365_E1_(no_Teams),f8ced641-8e17-4dc5-b014-f5a2d53f6ac8,SHAREPOINTWAC,e95bec33-7c88-4a70-8e19-b10bd9d0c014,Office for the Web -Office 365 E1 (no Teams),Office_365_E1_(no_Teams),f8ced641-8e17-4dc5-b014-f5a2d53f6ac8,MDOLITE_ENTERPRISE,c6675fa4-68fe-415f-aec1-a44520f0c3a3,Microsoft 365 built-in email and collaboration security Office 365 E1 EEA (no Teams),Office_365_w/o_Teams_Bundle_E1,b57282e3-65bd-4252-9502-c0eae1e5ab7f,SHAREPOINTWAC,e95bec33-7c88-4a70-8e19-b10bd9d0c014,Office for the Web Office 365 E1 EEA (no Teams),Office_365_w/o_Teams_Bundle_E1,b57282e3-65bd-4252-9502-c0eae1e5ab7f,YAMMER_ENTERPRISE,7547a3fe-08ee-4ccb-b430-5077c5041653,Yammer Enterprise Office 365 E1 EEA (no Teams),Office_365_w/o_Teams_Bundle_E1,b57282e3-65bd-4252-9502-c0eae1e5ab7f,VIVAENGAGE_CORE,a82fbf69-b4d7-49f4-83a6-915b2cf354f4,Viva Engage Core @@ -4610,7 +4604,6 @@ Office 365 E3,ENTERPRISEPACK,6fd2c87f-b296-42f0-b197-1e91e994b900,FLOW_O365_P2,7 Office 365 E3,ENTERPRISEPACK,6fd2c87f-b296-42f0-b197-1e91e994b900,POWERAPPS_O365_P2,c68f8d98-5534-41c8-bf36-22fa496fa792,Power Apps for Office 365 Office 365 E3,ENTERPRISEPACK,6fd2c87f-b296-42f0-b197-1e91e994b900,YAMMER_ENTERPRISE,7547a3fe-08ee-4ccb-b430-5077c5041653,Yammer Enterprise Office 365 E3,ENTERPRISEPACK,6fd2c87f-b296-42f0-b197-1e91e994b900,POWER_VIRTUAL_AGENTS_O365_P2,041fe683-03e4-45b6-b1af-c0cdc516daee,Power Virtual Agents for Office 365 -Office 365 E3,ENTERPRISEPACK,6fd2c87f-b296-42f0-b197-1e91e994b900,ATP_ENTERPRISE,f20fedf3-f3c3-43c3-8267-2bfdd51c0939,Microsoft Defender for Office 365 (Plan 1) Office 365 E3 (no Teams),Office_365_E3_(no_Teams),46c3a859-c90d-40b3-9551-6178a48d5c18,MESH_AVATARS_FOR_TEAMS,dcf9d2f4-772e-4434-b757-77a453cfbc02,Avatars for Teams Office 365 E3 (no Teams),Office_365_E3_(no_Teams),46c3a859-c90d-40b3-9551-6178a48d5c18,KAIZALA_O365_P3,aebd3021-9f8f-4bf8-bbe3-0ed2f4f047a1,Microsoft Kaizala Pro Office 365 E3 (no Teams),Office_365_E3_(no_Teams),46c3a859-c90d-40b3-9551-6178a48d5c18,FORMS_PLAN_E3,2789c901-c14e-48ab-a76a-be334d9d793a,Microsoft Forms (Plan E3) @@ -4701,7 +4694,6 @@ Office 365 E3_USGOV_DOD,ENTERPRISEPACK_USGOV_DOD,b107e5a3-3e60-4c0d-a184-a7e4395 Office 365 E3_USGOV_DOD,ENTERPRISEPACK_USGOV_DOD,b107e5a3-3e60-4c0d-a184-a7e4395eb44c,SHAREPOINTENTERPRISE,5dbe027f-2339-4123-9542-606e4d348a72,SharePoint Online (Plan 2) Office 365 E3_USGOV_DOD,ENTERPRISEPACK_USGOV_DOD,b107e5a3-3e60-4c0d-a184-a7e4395eb44c,MCOSTANDARD,0feaeb32-d00e-4d66-bd5a-43b5b83db82c,Skype for Business Online (Plan 2) Office 365 E3_USGOV_DOD,ENTERPRISEPACK_USGOV_DOD,b107e5a3-3e60-4c0d-a184-a7e4395eb44c,OFFICESUBSCRIPTION,43de0ff5-c92c-492b-9116-175376d08c38,Office 365 ProPlus -Office 365 E3_USGOV_DOD,ENTERPRISEPACK_USGOV_DOD,b107e5a3-3e60-4c0d-a184-a7e4395eb44c,ATP_ENTERPRISE,f20fedf3-f3c3-43c3-8267-2bfdd51c0939,Microsoft Defender for Office 365 (Plan 1) Office 365 E3_USGOV_GCCHIGH,ENTERPRISEPACK_USGOV_GCCHIGH,aea38a85-9bd5-4981-aa00-616b411205bf,MCOSTANDARD,0feaeb32-d00e-4d66-bd5a-43b5b83db82c,Skype for Business Online (Plan 2) Office 365 E3_USGOV_GCCHIGH,ENTERPRISEPACK_USGOV_GCCHIGH,aea38a85-9bd5-4981-aa00-616b411205bf,SHAREPOINTENTERPRISE,5dbe027f-2339-4123-9542-606e4d348a72,SharePoint Online (Plan 2) Office 365 E3_USGOV_GCCHIGH,ENTERPRISEPACK_USGOV_GCCHIGH,aea38a85-9bd5-4981-aa00-616b411205bf,SHAREPOINTWAC,e95bec33-7c88-4a70-8e19-b10bd9d0c014,Office Online @@ -4711,7 +4703,6 @@ Office 365 E3_USGOV_GCCHIGH,ENTERPRISEPACK_USGOV_GCCHIGH,aea38a85-9bd5-4981-aa00 Office 365 E3_USGOV_GCCHIGH,ENTERPRISEPACK_USGOV_GCCHIGH,aea38a85-9bd5-4981-aa00-616b411205bf,RMS_S_ENTERPRISE,bea4c11e-220a-4e6d-8eb8-8ea15d019f90,Microsoft Microsoft Entra Rights Office 365 E3_USGOV_GCCHIGH,ENTERPRISEPACK_USGOV_GCCHIGH,aea38a85-9bd5-4981-aa00-616b411205bf,EXCHANGE_S_ENTERPRISE,efb87545-963c-4e0d-99df-69c6916d9eb0,Exchange Online (Plan 2) Office 365 E3_USGOV_GCCHIGH,ENTERPRISEPACK_USGOV_GCCHIGH,aea38a85-9bd5-4981-aa00-616b411205bf,TEAMS_AR_GCCHIGH,9953b155-8aef-4c56-92f3-72b0487fce41,Microsoft Teams for GCCHigh (AR) -Office 365 E3_USGOV_GCCHIGH,ENTERPRISEPACK_USGOV_GCCHIGH,aea38a85-9bd5-4981-aa00-616b411205bf,ATP_ENTERPRISE,f20fedf3-f3c3-43c3-8267-2bfdd51c0939,Microsoft Defender for Office 365 (Plan 1) Office 365 E4,ENTERPRISEWITHSCAL,1392051d-0cb9-4b7a-88d5-621fee5e8711,BPOS_S_TODO_2,c87f142c-d1e9-4363-8630-aaea9c4d9ae5,BPOS_S_TODO_2 Office 365 E4,ENTERPRISEWITHSCAL,1392051d-0cb9-4b7a-88d5-621fee5e8711,Deskless,8c7d2df8-86f0-4902-b2ed-a0458298f3b3,MICROSOFT STAFFHUB Office 365 E4,ENTERPRISEWITHSCAL,1392051d-0cb9-4b7a-88d5-621fee5e8711,FLOW_O365_P2,76846ad7-7776-4c40-a281-a386362dd1b9,FLOW FOR OFFICE 365 @@ -4785,7 +4776,6 @@ Office 365 E5,ENTERPRISEPREMIUM,c7df2760-2c81-4ef7-b578-5b5392b571df,Deskless,8c Office 365 E5,ENTERPRISEPREMIUM,c7df2760-2c81-4ef7-b578-5b5392b571df,STREAM_O365_E5,6c6042f5-6f01-4d67-b8c1-eb99d36eed3e,Microsoft Stream for O365 E5 SKU Office 365 E5,ENTERPRISEPREMIUM,c7df2760-2c81-4ef7-b578-5b5392b571df,TEAMS1,57ff2da0-773e-42df-b2af-ffb7a2317929,Microsoft Teams Office 365 E5,ENTERPRISEPREMIUM,c7df2760-2c81-4ef7-b578-5b5392b571df,RECORDS_MANAGEMENT,65cc641f-cccd-4643-97e0-a17e3045e541,Microsoft Records Management -Office 365 E5,ENTERPRISEPREMIUM,c7df2760-2c81-4ef7-b578-5b5392b571df,MICROSOFT_TEAMS_EVENTS,29c62f1c-8ffc-4304-9cb9-398a6aa1852b,Microsoft Teams Events Office 365 E5 EEA (no Teams),Office_365_w/o_Teams_Bundle_E5,cf50bae9-29e8-4775-b07c-56ee10e3776d,DYN365_CDS_O365_P3,28b0fa46-c39a-4188-89e2-58e979a6b014,Common Data Service Office 365 E5 EEA (no Teams),Office_365_w/o_Teams_Bundle_E5,cf50bae9-29e8-4775-b07c-56ee10e3776d,POWER_VIRTUAL_AGENTS_O365_P3,ded3d325-1bdc-453e-8432-5bac26d7a014,Power Virtual Agents for Office 365 Office 365 E5 EEA (no Teams),Office_365_w/o_Teams_Bundle_E5,cf50bae9-29e8-4775-b07c-56ee10e3776d,BI_AZURE_P2,70d33638-9c74-4d01-bfd3-562de28bd4ba,Power BI Pro @@ -5069,7 +5059,6 @@ Office 365 G3 GCC,ENTERPRISEPACK_GOV,535a3a29-c5f0-42fe-8215-d3b9e1f38c4a,MIP_S_ Office 365 G3 GCC,ENTERPRISEPACK_GOV,535a3a29-c5f0-42fe-8215-d3b9e1f38c4a,ContentExplorer_Standard,2b815d45-56e4-4e3a-b65c-66cb9175b560,Information Protection and Governance Analytics – Standard Office 365 G3 GCC,ENTERPRISEPACK_GOV,535a3a29-c5f0-42fe-8215-d3b9e1f38c4a,PROJECT_O365_P2_GOV,e7d09ae4-099a-4c34-a2a2-3e166e95c44a,Project for Government (Plan E3) Office 365 G3 GCC,ENTERPRISEPACK_GOV,535a3a29-c5f0-42fe-8215-d3b9e1f38c4a,MYANALYTICS_P2_GOV,6e5b7995-bd4f-4cbd-9d19-0e32010c72f0,Insights by MyAnalytics for Government -Office 365 G3 GCC,ENTERPRISEPACK_GOV,535a3a29-c5f0-42fe-8215-d3b9e1f38c4a,ATP_ENTERPRISE,f20fedf3-f3c3-43c3-8267-2bfdd51c0939,Microsoft Defender for Office 365 (Plan 1) Office 365 G3 without Microsoft 365 Apps GCC,ENTERPRISEPACKWITHOUTPROPLUS_GOV,24aebea8-7fac-48d0-8750-de4ee1fde205,CDS_O365_P2_GCC,a70bbf38-cdda-470d-adb8-5804b8770f41,Common Data Service for Teams Office 365 G3 without Microsoft 365 Apps GCC,ENTERPRISEPACKWITHOUTPROPLUS_GOV,24aebea8-7fac-48d0-8750-de4ee1fde205,EXCHANGE_S_ENTERPRISE_GOV,8c3069c0-ccdb-44be-ab77-986203a67df2,Exchange Online (Plan 2) for Government Office 365 G3 without Microsoft 365 Apps GCC,ENTERPRISEPACKWITHOUTPROPLUS_GOV,24aebea8-7fac-48d0-8750-de4ee1fde205,MIP_S_CLP1,5136a095-5cf0-4aff-bec3-e84448b38ea5,Information Protection for Office 365 - Standard @@ -5838,6 +5827,7 @@ Windows 365 Enterprise 2 vCPU 8 GB 128 GB (Preview),CPC_LVL_2,461cb62c-6db7-41aa Windows 365 Enterprise 2 vCPU 8 GB 256 GB,CPC_E_2C_8GB_256GB,1c79494f-e170-431f-a409-428f6053fa35,EXCHANGE_S_FOUNDATION,113feb6c-3fe4-4440-bddc-54d774bf0318,Exchange Foundation Windows 365 Enterprise 2 vCPU 8 GB 256 GB,CPC_E_2C_8GB_256GB,1c79494f-e170-431f-a409-428f6053fa35,CPC_E_2C_8GB_256GB,d3468c8c-3545-4f44-a32f-b465934d2498,Windows 365 Enterprise 2 vCPU 8 GB 256 GB Windows 365 Enterprise 4 vCPU 16 GB 128 GB,CPC_E_4C_16GB_128GB,d201f153-d3b2-4057-be2f-fe25c8983e6f,EXCHANGE_S_FOUNDATION,113feb6c-3fe4-4440-bddc-54d774bf0318,Exchange Foundation +Windows 365 Enterprise 4 vCPU 16 GB 128 GB,CPC_E_4C_16GB_128GB,d201f153-d3b2-4057-be2f-fe25c8983e6f,Windows_10_ESU_Commercial,6dc0e3c6-2e4e-463c-90a4-9989d8543841,Windows 10 ESU Commercial Windows 365 Enterprise 4 vCPU 16 GB 128 GB,CPC_E_4C_16GB_128GB,d201f153-d3b2-4057-be2f-fe25c8983e6f,CPC_E_4C_16GB_128GB,2de9c682-ca3f-4f2b-b360-dfc4775db133,Windows 365 Enterprise 4 vCPU 16 GB 128 GB Windows 365 Enterprise 4 vCPU 16 GB 256 GB,CPC_E_4C_16GB_256GB,96d2951e-cb42-4481-9d6d-cad3baac177e,EXCHANGE_S_FOUNDATION,113feb6c-3fe4-4440-bddc-54d774bf0318,Exchange Foundation Windows 365 Enterprise 4 vCPU 16 GB 256 GB,CPC_E_4C_16GB_256GB,96d2951e-cb42-4481-9d6d-cad3baac177e,CPC_E_4C_16GB_256GB,9ecf691d-8b82-46cb-b254-cd061b2c02fb,Windows 365 Enterprise 4 vCPU 16 GB 256 GB @@ -5856,6 +5846,7 @@ Windows 365 Shared Use 2 vCPU 4 GB 256 GB,Windows_365_S_2vCPU_4GB_256GB,8fe96593 Windows 365 Shared Use 2 vCPU 4 GB 64 GB,Windows_365_S_2vCPU_4GB_64GB,1f9990ca-45d9-4c8d-8d04-a79241924ce1,CPC_S_2C_4GB_64GB,64981bdb-a5a6-4a22-869f-a9455366d5bc,Windows 365 Shared Use 2 vCPU 4 GB 64 GB Windows 365 Shared Use 2 vCPU 8 GB 128 GB,Windows_365_S_2vCPU_8GB_128GB,2d21fc84-b918-491e-ad84-e24d61ccec94,CPC_S_2C_8GB_128GB,057efbfe-a95d-4263-acb0-12b4a31fed8d,Windows 365 Shared Use 2 vCPU 8 GB 128 GB Windows 365 Shared Use 2 vCPU 8 GB 256 GB,Windows_365_S_2vCPU_8GB_256GB,2eaa4058-403e-4434-9da9-ea693f5d96dc,CPC_S_2C_8GB_256GB,50ef7026-6174-40ba-bff7-f0e4fcddbf65,Windows 365 Shared Use 2 vCPU 8 GB 256 GB +Windows 365 Shared Use 4 vCPU 16 GB 128 GB,Windows_365_S_4vCPU_16GB_128GB,1bf40e76-4065-4530-ac37-f1513f362f50,WINDOWS_10_ESU_TENANT,a22efeae-e37a-47ac-9a61-1572d74202e5,Windows 10 ESU Tenant Windows 365 Shared Use 4 vCPU 16 GB 128 GB,Windows_365_S_4vCPU_16GB_128GB,1bf40e76-4065-4530-ac37-f1513f362f50,CPC_S_4C_16GB_128GB,dd3801e2-4aa1-4b16-a44b-243e55497584,Windows 365 Shared Use 4 vCPU 16 GB 128 GB Windows 365 Shared Use 4 vCPU 16 GB 256 GB,Windows_365_S_4vCPU_16GB_256GB,a9d1e0df-df6f-48df-9386-76a832119cca,CPC_S_4C_16GB_256GB,2d1d344e-d10c-41bb-953b-b3a47521dca0,Windows 365 Shared Use 4 vCPU 16 GB 256 GB Windows 365 Shared Use 4 vCPU 16 GB 512 GB,Windows_365_S_4vCPU_16GB_512GB,469af4da-121c-4529-8c85-9467bbebaa4b,CPC_S_4C_16GB_512GB,48b82071-99a5-4214-b493-406a637bd68d,Windows 365 Shared Use 4 vCPU 16 GB 512 GB @@ -5990,10 +5981,3 @@ Agent 365,AGENT_365,796a6fb4-740b-4d36-bf56-9c12ca7fa069,ENTRA_ID_GOV_FOR_ASSIST Agent 365,AGENT_365,796a6fb4-740b-4d36-bf56-9c12ca7fa069,ENTRA_NETWORK_CONTROLS_FOR_ASSISTIVE_AGENTS,27e196a4-8b80-4930-bd65-53fd28581878,Microsoft Entra Network Controls for Assistive Agents Agent 365,AGENT_365,796a6fb4-740b-4d36-bf56-9c12ca7fa069,INFORMATION_PROTECTION_FOR_AGENTS,48478b49-91a1-4ded-94f0-066db80035ca,Microsoft Purview Information Protection for Agents Agent 365,AGENT_365,796a6fb4-740b-4d36-bf56-9c12ca7fa069,INSIDER_RISK_MANAGEMENT_FOR_AGENTS,004ddfc0-c92f-4b0a-90c5-c60646299d71,Microsoft Purview Insider Risk Management for Agents -Microsoft Teams Premium,M365_TEAMS_PREMIUM,6432c818-bcef-43b6-9290-aec052964950,TEAMSPRO_MGMT,0504111f-feb8-4a3c-992a-70280f9a2869,Microsoft Teams Premium Intelligent -Microsoft Teams Premium,M365_TEAMS_PREMIUM,6432c818-bcef-43b6-9290-aec052964950,TEAMSPRO_CUST,cc8c0802-a325-43df-8cba-995d0c6cb373,Microsoft Teams Premium Personalized -Microsoft Teams Premium,M365_TEAMS_PREMIUM,6432c818-bcef-43b6-9290-aec052964950,TEAMSPRO_PROTECTION,f8b44f54-18bb-46a3-9658-44ab58712968,Microsoft Teams Premium Secure -Microsoft Teams Premium,M365_TEAMS_PREMIUM,6432c818-bcef-43b6-9290-aec052964950,TEAMSPRO_VIRTUALAPPT,9104f592-f2a7-4f77-904c-ca5a5715883f,Microsoft Teams Premium Virtual Appointment -Microsoft Teams Premium,M365_TEAMS_PREMIUM,6432c818-bcef-43b6-9290-aec052964950,MCO_VIRTUAL_APPT,711413d0-b36e-4cd4-93db-0a50a4ab7ea3,Microsoft Teams Premium Virtual Appointments -Microsoft Teams Premium,M365_TEAMS_PREMIUM,6432c818-bcef-43b6-9290-aec052964950,QUEUES_APP,ab2d4fb5-f80a-4bf1-a11d-7f1da254041b,Queues app for Microsoft Teams -Skype for Business PSTN Domestic and International Calling,MCOSMS2,d4009785-b899-4cab-97b6-d06a7c799507,MCOSMS2,d4009785-b899-4cab-97b6-d06a7c799507,DOMESTIC AND INTERNATIONAL CALLING PLAN diff --git a/frontend/src/data/M365Licenses.json b/frontend/src/data/M365Licenses.json index 61bd872fbf..e83914500e 100644 --- a/frontend/src/data/M365Licenses.json +++ b/frontend/src/data/M365Licenses.json @@ -14919,14 +14919,6 @@ "Service_Plan_Id": "94065c59-bc8e-4e8b-89e5-5138d471eaff", "Service_Plans_Included_Friendly_Names": "Microsoft Search" }, - { - "Product_Display_Name": "Microsoft 365 E3", - "String_Id": "SPE_E3", - "GUID": "05e9a617-0261-4cee-bb44-138d3ef5d965", - "Service_Plan_Name": "ATP_ENTERPRISE", - "Service_Plan_Id": "f20fedf3-f3c3-43c3-8267-2bfdd51c0939", - "Service_Plans_Included_Friendly_Names": "Microsoft Defender for Office 365 (Plan 1)" - }, { "Product_Display_Name": "Microsoft 365 E3 - Unattended License", "String_Id": "SPE_E3_RPA1", @@ -17191,14 +17183,6 @@ "Service_Plan_Id": "bea4c11e-220a-4e6d-8eb8-8ea15d019f90", "Service_Plans_Included_Friendly_Names": "Microsoft Microsoft Entra Rights" }, - { - "Product_Display_Name": "Microsoft 365 E3_USGOV_DOD", - "String_Id": "SPE_E3_USGOV_DOD", - "GUID": "d61d61cc-f992-433f-a577-5bd016037eeb", - "Service_Plan_Name": "ATP_ENTERPRISE", - "Service_Plan_Id": "f20fedf3-f3c3-43c3-8267-2bfdd51c0939", - "Service_Plans_Included_Friendly_Names": "Microsoft Defender for Office 365 (Plan 1)" - }, { "Product_Display_Name": "Microsoft 365 E3_USGOV_GCCHIGH", "String_Id": "SPE_E3_USGOV_GCCHIGH", @@ -17311,14 +17295,6 @@ "Service_Plan_Id": "932ad362-64a8-4783-9106-97849a1a30b9", "Service_Plans_Included_Friendly_Names": "Cloud App Security Discovery" }, - { - "Product_Display_Name": "Microsoft 365 E3_USGOV_GCCHIGH", - "String_Id": "SPE_E3_USGOV_GCCHIGH", - "GUID": "ca9d1dd9-dfe9-4fef-b97c-9bc1ea3c3658", - "Service_Plan_Name": "ATP_ENTERPRISE", - "Service_Plan_Id": "f20fedf3-f3c3-43c3-8267-2bfdd51c0939", - "Service_Plans_Included_Friendly_Names": "Microsoft Defender for Office 365 (Plan 1)" - }, { "Product_Display_Name": "Microsoft 365 E5", "String_Id": "SPE_E5", @@ -23431,22 +23407,6 @@ "Service_Plan_Id": "3efbd4ed-8958-4824-8389-1321f8730af8", "Service_Plans_Included_Friendly_Names": "Avatars for Teams (additional)" }, - { - "Product_Display_Name": "Microsoft 365 E5 Suite features", - "String_Id": "M365_E5_SUITE_COMPONENTS", - "GUID": "99cc8282-2f74-4954-83b7-c6a9a1999067", - "Service_Plan_Name": "CLOUD_PKI", - "Service_Plan_Id": "795aec3a-93a2-45be-92c4-47b9a76340ca", - "Service_Plans_Included_Friendly_Names": "Microsoft Cloud PKI" - }, - { - "Product_Display_Name": "Microsoft 365 E5 Suite features", - "String_Id": "M365_E5_SUITE_COMPONENTS", - "GUID": "99cc8282-2f74-4954-83b7-c6a9a1999067", - "Service_Plan_Name": "3_PARTY_APP_PATCH", - "Service_Plan_Id": "3afa0b92-83ef-41c1-8d64-586ab882a951", - "Service_Plans_Included_Friendly_Names": "Intune Enterprise Application Management" - }, { "Product_Display_Name": "Microsoft 365 E5 with Calling Minutes", "String_Id": "SPE_E5_CALLINGMINUTES", @@ -28327,14 +28287,6 @@ "Service_Plan_Id": "c537f360-6a00-4ace-a7f5-9128d0ac1e4b", "Service_Plans_Included_Friendly_Names": "Power Automate for Office 365 for Government" }, - { - "Product_Display_Name": "Microsoft 365 G3 GCC", - "String_Id": "M365_G3_GOV", - "GUID": "e823ca47-49c4-46b3-b38d-ca11d5abe3d2", - "Service_Plan_Name": "ATP_ENTERPRISE", - "Service_Plan_Id": "f20fedf3-f3c3-43c3-8267-2bfdd51c0939", - "Service_Plans_Included_Friendly_Names": "Microsoft Defender for Office 365 (Plan 1)" - }, { "Product_Display_Name": "Microsoft 365 GCC G5", "String_Id": "M365_G5_GCC", @@ -33031,6 +32983,14 @@ "Service_Plan_Id": "b622badb-1b45-48d5-920f-4b27a2c0996c", "Service_Plans_Included_Friendly_Names": "Microsoft Workplace Analytics Insights User" }, + { + "Product_Display_Name": "Microsoft Workplace Analytics", + "String_Id": "WORKPLACE_ANALYTICS", + "GUID": "3d957427-ecdc-4df2-aacd-01cc9d519da8", + "Service_Plan_Name": "SKILLS_IN_VIVA", + "Service_Plan_Id": "ccaebebf-3634-4975-a0ad-3eccb697f393", + "Service_Plans_Included_Friendly_Names": "Skills in Viva" + }, { "Product_Display_Name": "Minecraft Education Faculty", "String_Id": "MEE_FACULTY", @@ -36111,14 +36071,6 @@ "Service_Plan_Id": "e95bec33-7c88-4a70-8e19-b10bd9d0c014", "Service_Plans_Included_Friendly_Names": "Office for the Web" }, - { - "Product_Display_Name": "Office 365 E1 (no Teams)", - "String_Id": "Office_365_E1_(no_Teams)", - "GUID": "f8ced641-8e17-4dc5-b014-f5a2d53f6ac8", - "Service_Plan_Name": "MDOLITE_ENTERPRISE", - "Service_Plan_Id": "c6675fa4-68fe-415f-aec1-a44520f0c3a3", - "Service_Plans_Included_Friendly_Names": "Microsoft 365 built-in email and collaboration security" - }, { "Product_Display_Name": "Office 365 E1 EEA (no Teams)", "String_Id": "Office_365_w/o_Teams_Bundle_E1", @@ -36887,14 +36839,6 @@ "Service_Plan_Id": "041fe683-03e4-45b6-b1af-c0cdc516daee", "Service_Plans_Included_Friendly_Names": "Power Virtual Agents for Office 365" }, - { - "Product_Display_Name": "Office 365 E3", - "String_Id": "ENTERPRISEPACK", - "GUID": "6fd2c87f-b296-42f0-b197-1e91e994b900", - "Service_Plan_Name": "ATP_ENTERPRISE", - "Service_Plan_Id": "f20fedf3-f3c3-43c3-8267-2bfdd51c0939", - "Service_Plans_Included_Friendly_Names": "Microsoft Defender for Office 365 (Plan 1)" - }, { "Product_Display_Name": "Office 365 E3 (no Teams)", "String_Id": "Office_365_E3_(no_Teams)", @@ -37615,14 +37559,6 @@ "Service_Plan_Id": "43de0ff5-c92c-492b-9116-175376d08c38", "Service_Plans_Included_Friendly_Names": "Office 365 ProPlus" }, - { - "Product_Display_Name": "Office 365 E3_USGOV_DOD", - "String_Id": "ENTERPRISEPACK_USGOV_DOD", - "GUID": "b107e5a3-3e60-4c0d-a184-a7e4395eb44c", - "Service_Plan_Name": "ATP_ENTERPRISE", - "Service_Plan_Id": "f20fedf3-f3c3-43c3-8267-2bfdd51c0939", - "Service_Plans_Included_Friendly_Names": "Microsoft Defender for Office 365 (Plan 1)" - }, { "Product_Display_Name": "Office 365 E3_USGOV_GCCHIGH", "String_Id": "ENTERPRISEPACK_USGOV_GCCHIGH", @@ -37695,14 +37631,6 @@ "Service_Plan_Id": "9953b155-8aef-4c56-92f3-72b0487fce41", "Service_Plans_Included_Friendly_Names": "Microsoft Teams for GCCHigh (AR)" }, - { - "Product_Display_Name": "Office 365 E3_USGOV_GCCHIGH", - "String_Id": "ENTERPRISEPACK_USGOV_GCCHIGH", - "GUID": "aea38a85-9bd5-4981-aa00-616b411205bf", - "Service_Plan_Name": "ATP_ENTERPRISE", - "Service_Plan_Id": "f20fedf3-f3c3-43c3-8267-2bfdd51c0939", - "Service_Plans_Included_Friendly_Names": "Microsoft Defender for Office 365 (Plan 1)" - }, { "Product_Display_Name": "Office 365 E4", "String_Id": "ENTERPRISEWITHSCAL", @@ -38287,14 +38215,6 @@ "Service_Plan_Id": "65cc641f-cccd-4643-97e0-a17e3045e541", "Service_Plans_Included_Friendly_Names": "Microsoft Records Management" }, - { - "Product_Display_Name": "Office 365 E5", - "String_Id": "ENTERPRISEPREMIUM", - "GUID": "c7df2760-2c81-4ef7-b578-5b5392b571df", - "Service_Plan_Name": "MICROSOFT_TEAMS_EVENTS", - "Service_Plan_Id": "29c62f1c-8ffc-4304-9cb9-398a6aa1852b", - "Service_Plans_Included_Friendly_Names": "Microsoft Teams Events" - }, { "Product_Display_Name": "Office 365 E5 EEA (no Teams)", "String_Id": "Office_365_w/o_Teams_Bundle_E5", @@ -40559,14 +40479,6 @@ "Service_Plan_Id": "6e5b7995-bd4f-4cbd-9d19-0e32010c72f0", "Service_Plans_Included_Friendly_Names": "Insights by MyAnalytics for Government" }, - { - "Product_Display_Name": "Office 365 G3 GCC", - "String_Id": "ENTERPRISEPACK_GOV", - "GUID": "535a3a29-c5f0-42fe-8215-d3b9e1f38c4a", - "Service_Plan_Name": "ATP_ENTERPRISE", - "Service_Plan_Id": "f20fedf3-f3c3-43c3-8267-2bfdd51c0939", - "Service_Plans_Included_Friendly_Names": "Microsoft Defender for Office 365 (Plan 1)" - }, { "Product_Display_Name": "Office 365 G3 without Microsoft 365 Apps GCC", "String_Id": "ENTERPRISEPACKWITHOUTPROPLUS_GOV", @@ -46711,6 +46623,14 @@ "Service_Plan_Id": "113feb6c-3fe4-4440-bddc-54d774bf0318", "Service_Plans_Included_Friendly_Names": "Exchange Foundation" }, + { + "Product_Display_Name": "Windows 365 Enterprise 4 vCPU 16 GB 128 GB", + "String_Id": "CPC_E_4C_16GB_128GB", + "GUID": "d201f153-d3b2-4057-be2f-fe25c8983e6f", + "Service_Plan_Name": "Windows_10_ESU_Commercial", + "Service_Plan_Id": "6dc0e3c6-2e4e-463c-90a4-9989d8543841", + "Service_Plans_Included_Friendly_Names": "Windows 10 ESU Commercial" + }, { "Product_Display_Name": "Windows 365 Enterprise 4 vCPU 16 GB 128 GB", "String_Id": "CPC_E_4C_16GB_128GB", @@ -46855,6 +46775,14 @@ "Service_Plan_Id": "50ef7026-6174-40ba-bff7-f0e4fcddbf65", "Service_Plans_Included_Friendly_Names": "Windows 365 Shared Use 2 vCPU 8 GB 256 GB" }, + { + "Product_Display_Name": "Windows 365 Shared Use 4 vCPU 16 GB 128 GB", + "String_Id": "Windows_365_S_4vCPU_16GB_128GB", + "GUID": "1bf40e76-4065-4530-ac37-f1513f362f50", + "Service_Plan_Name": "WINDOWS_10_ESU_TENANT", + "Service_Plan_Id": "a22efeae-e37a-47ac-9a61-1572d74202e5", + "Service_Plans_Included_Friendly_Names": "Windows 10 ESU Tenant" + }, { "Product_Display_Name": "Windows 365 Shared Use 4 vCPU 16 GB 128 GB", "String_Id": "Windows_365_S_4vCPU_16GB_128GB", @@ -47926,61 +47854,5 @@ "Service_Plan_Name": "INSIDER_RISK_MANAGEMENT_FOR_AGENTS", "Service_Plan_Id": "004ddfc0-c92f-4b0a-90c5-c60646299d71", "Service_Plans_Included_Friendly_Names": "Microsoft Purview Insider Risk Management for Agents" - }, - { - "Product_Display_Name": "Microsoft Teams Premium", - "String_Id": "M365_TEAMS_PREMIUM", - "GUID": "6432c818-bcef-43b6-9290-aec052964950", - "Service_Plan_Name": "TEAMSPRO_MGMT", - "Service_Plan_Id": "0504111f-feb8-4a3c-992a-70280f9a2869", - "Service_Plans_Included_Friendly_Names": "Microsoft Teams Premium Intelligent" - }, - { - "Product_Display_Name": "Microsoft Teams Premium", - "String_Id": "M365_TEAMS_PREMIUM", - "GUID": "6432c818-bcef-43b6-9290-aec052964950", - "Service_Plan_Name": "TEAMSPRO_CUST", - "Service_Plan_Id": "cc8c0802-a325-43df-8cba-995d0c6cb373", - "Service_Plans_Included_Friendly_Names": "Microsoft Teams Premium Personalized" - }, - { - "Product_Display_Name": "Microsoft Teams Premium", - "String_Id": "M365_TEAMS_PREMIUM", - "GUID": "6432c818-bcef-43b6-9290-aec052964950", - "Service_Plan_Name": "TEAMSPRO_PROTECTION", - "Service_Plan_Id": "f8b44f54-18bb-46a3-9658-44ab58712968", - "Service_Plans_Included_Friendly_Names": "Microsoft Teams Premium Secure" - }, - { - "Product_Display_Name": "Microsoft Teams Premium", - "String_Id": "M365_TEAMS_PREMIUM", - "GUID": "6432c818-bcef-43b6-9290-aec052964950", - "Service_Plan_Name": "TEAMSPRO_VIRTUALAPPT", - "Service_Plan_Id": "9104f592-f2a7-4f77-904c-ca5a5715883f", - "Service_Plans_Included_Friendly_Names": "Microsoft Teams Premium Virtual Appointment" - }, - { - "Product_Display_Name": "Microsoft Teams Premium", - "String_Id": "M365_TEAMS_PREMIUM", - "GUID": "6432c818-bcef-43b6-9290-aec052964950", - "Service_Plan_Name": "MCO_VIRTUAL_APPT", - "Service_Plan_Id": "711413d0-b36e-4cd4-93db-0a50a4ab7ea3", - "Service_Plans_Included_Friendly_Names": "Microsoft Teams Premium Virtual Appointments" - }, - { - "Product_Display_Name": "Microsoft Teams Premium", - "String_Id": "M365_TEAMS_PREMIUM", - "GUID": "6432c818-bcef-43b6-9290-aec052964950", - "Service_Plan_Name": "QUEUES_APP", - "Service_Plan_Id": "ab2d4fb5-f80a-4bf1-a11d-7f1da254041b", - "Service_Plans_Included_Friendly_Names": "Queues app for Microsoft Teams" - }, - { - "Product_Display_Name": "Skype for Business PSTN Domestic and International Calling", - "String_Id": "MCOSMS2", - "GUID": "d4009785-b899-4cab-97b6-d06a7c799507", - "Service_Plan_Name": "MCOSMS2", - "Service_Plan_Id": "d4009785-b899-4cab-97b6-d06a7c799507", - "Service_Plans_Included_Friendly_Names": "DOMESTIC AND INTERNATIONAL CALLING PLAN" } ] From e8d7faee6202703f22345bd10db6bcbb5b3c7dac Mon Sep 17 00:00:00 2001 From: Brian Simpson <50429915+bmsimp@users.noreply.github.com> Date: Wed, 19 Aug 2026 06:56:30 -0500 Subject: [PATCH 172/226] docs(user-documentation): drop stale drawer action claims The extended-info drawer no longer carries row action buttons, so four pages claiming the same actions were available from inside the flyout were wrong. On the Devices page the remaining flyout sentence moves into Table Details, where the rest of the corpus keeps row-flyout prose. Co-Authored-By: Claude Opus 5 (1M context) --- docs/user-documentation/email/administration/mailbox-rules.md | 2 -- docs/user-documentation/email/administration/quarantine.md | 2 +- .../email/resources/management/room-lists/README.md | 2 +- docs/user-documentation/endpoint/mem/devices/README.md | 4 ++-- 4 files changed, 4 insertions(+), 6 deletions(-) diff --git a/docs/user-documentation/email/administration/mailbox-rules.md b/docs/user-documentation/email/administration/mailbox-rules.md index 6bd33f65c4..0705726b0a 100644 --- a/docs/user-documentation/email/administration/mailbox-rules.md +++ b/docs/user-documentation/email/administration/mailbox-rules.md @@ -18,6 +18,4 @@ The row flyout shows the rule's full definition, which is where its actual condi
    ActionDescriptionBulk Action Available
    Enable Mailbox RuleEnables the mailbox rule so it starts acting on mail again. Greyed out on a rule that is already enabled.true
    Disable Mailbox RuleStops the mailbox rule acting on mail while leaving it in place, so it can be turned back on later. Greyed out on a rule that is already disabled.true
    Remove Mailbox RuleDeletes the mailbox rule. This cannot be undone, so disable a rule first if you may need to reinstate it.true
    More InfoOpens the Extended Info flyout with the full details for the selected row.false
    -All three actions are also available from inside the flyout. - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/email/administration/quarantine.md b/docs/user-documentation/email/administration/quarantine.md index 1f5d08b5f4..610dee20ca 100644 --- a/docs/user-documentation/email/administration/quarantine.md +++ b/docs/user-documentation/email/administration/quarantine.md @@ -20,7 +20,7 @@ Messages are listed newest first. Choosing AllTenants starts a background job to
    ActionDescriptionBulk Action Available
    View MessageOpens a modal that renders the quarantined message so its contents, headers, and attachments can be inspected safely.false
    View Message TraceOpens a modal with a table of the message's trace history, showing where it was received from and what happened to it at each step.false
    ReleaseReleases the message to all of its recipients. Greyed out on a message that has already been released.true
    DenyTurns down a recipient's request to have the message released. Greyed out unless the recipient has actually requested release.true
    Release & Allow SenderReleases the message and adds the sender to the allowed senders list of the anti-spam policy that quarantined it, so future mail from them is not quarantined. Greyed out on a message that has already been released.true
    More InfoOpens the Extended Info flyout with the full details for the selected row.false
    -The flyout carries the same actions and highlights the message ID, recipient address, and quarantine type. +The flyout highlights the message ID, recipient address, and quarantine type. {% hint style="warning" %} **Release & Allow Sender** adds a standing allow entry to the anti-spam policy, and that entry stays until it is removed by hand. Use it for a sender that is genuinely being caught wrongly, and prefer a plain **Release** otherwise. diff --git a/docs/user-documentation/email/resources/management/room-lists/README.md b/docs/user-documentation/email/resources/management/room-lists/README.md index b6dd865536..a5493d04b9 100644 --- a/docs/user-documentation/email/resources/management/room-lists/README.md +++ b/docs/user-documentation/email/resources/management/room-lists/README.md @@ -24,7 +24,7 @@ Creates a new room list in the selected tenant. The properties returned are for the Exchange Online PowerShell command `Get-DistributionGroup` with a filter for `RoomList`. For more information on the command please see the [Microsoft documentation](https://learn.microsoft.com/en-us/powershell/module/exchange/get-distributiongroup?view=exchange-ps). -**More Info** opens the Extended Info flyout, which shows the room list's display name, address, identity, phone, notes, and GUID, and carries the same actions as the table. +**More Info** opens the Extended Info flyout, which shows the room list's display name, address, identity, phone, notes, and GUID. ## Table Actions diff --git a/docs/user-documentation/endpoint/mem/devices/README.md b/docs/user-documentation/endpoint/mem/devices/README.md index a27a8b7379..b148bd3ecc 100644 --- a/docs/user-documentation/endpoint/mem/devices/README.md +++ b/docs/user-documentation/endpoint/mem/devices/README.md @@ -20,9 +20,9 @@ Synchronises the tenant's Apple Device Enrollment Program tokens, bringing in ne The properties returned are for the Graph resource type `managedDevice`. For more information on the properties please see the [Graph documentation](https://learn.microsoft.com/graph/api/resources/intune-devices-manageddevice?view=graph-rest-beta#properties). -## Table Actions +Selecting a row opens a flyout showing the device name and its assigned user. -Selecting a row opens a flyout showing the device name and its assigned user, from which the same actions are available. +## Table Actions
    ActionDescriptionBulk Action Available
    View DeviceOpens the device's device.md page in CIPP, with its full details, applications, and users.false
    View in IntuneOpens the device in the Microsoft Intune admin center in a new tab.false
    Change Primary UserSets a different user as the device's primary user.true
    Add to GroupAdds the device to one or more Entra ID groups. Groups are listed with their name and type, and several can be selected at once, with the device added to each. Devices cannot be added to Distribution List or Mail-Enabled Security groups, and selecting one returns an error for that group rather than failing the whole action.true
    Rename DeviceChanges the device's name to one you specify.true
    Sync DeviceAsks the device to check in with Intune, so that pending policies and applications are applied sooner than the next scheduled sync.true
    Reboot DeviceRestarts the device.true
    Locate DeviceRequests the device's current location.true
    Retrieve LAPS passwordRetrieves the local administrator password held for the device by Windows LAPS. Windows devices only.true
    Rotate Local Admin PasswordForces the local administrator password to be changed and a new one stored. Windows devices only.true
    Retrieve BIOS PasswordRetrieves the BIOS password Intune holds for the device. A password only exists where the device is targeted by a BIOS configuration profile that manages per-device passwords, otherwise the action reports that none was found. Windows devices only.true
    Retrieve BitLocker KeysRetrieves the BitLocker recovery keys escrowed for the device. Windows devices only.true
    Retrieve FileVault KeyRetrieves the FileVault recovery key escrowed for the device. macOS devices only.true
    Reset PasscodeResets the device's passcode. Android devices only.true
    Remove PasscodeRemoves the device's passcode. iOS devices only.true
    Windows Defender Full ScanStarts a full Microsoft Defender scan on the device.true
    Windows Defender Quick ScanStarts a quick Microsoft Defender scan on the device.true
    Update Windows DefenderUpdates the Microsoft Defender signatures on the device.true
    Fresh Start (Remove user data)Reinstalls Windows on the device and removes the user's data. Windows devices only.true
    Fresh Start (Do not remove user data)Reinstalls Windows on the device while retaining the user's data. Windows devices only.true
    Wipe Device, keep enrollment dataWipes the device but retains its enrolment data, so it remains managed. Windows devices only.true
    Wipe Device, remove enrollment dataWipes the device and removes its enrolment data, so it is no longer managed. Windows devices only.true
    Wipe Device, keep enrollment data, and continue at powerlossAs above, retaining enrolment data, but the wipe resumes if the device loses power part-way through. Windows devices only.true
    Wipe Device, remove enrollment data, and continue at powerlossAs above, removing enrolment data, but the wipe resumes if the device loses power part-way through. Windows devices only.true
    Autopilot ResetResets the device and re-runs the Autopilot provisioning process. Windows devices only.true
    Delete deviceDeletes the device record from Intune.true
    Retire deviceRemoves company data and management from the device while leaving the user's personal data in place.true
    More InfoOpens the Extended Info flyout with the full details for the selected row.false
    From 794b474b7d733601b26c7c90c81c064299773390 Mon Sep 17 00:00:00 2001 From: Brian Simpson <50429915+bmsimp@users.noreply.github.com> Date: Wed, 19 Aug 2026 07:07:18 -0500 Subject: [PATCH 173/226] docs(identity): document last successful sign-in column Commit 47170a68 added `lastSuccessfulSignInDateTime` to the inactive users report and changed `lastRefreshedDateTime` to the users cache row timestamp rather than request time. Update Table Details to match, correct the inactivity rule to the most recent of the three sign-in fields, and add a warning that the interactive and non-interactive columns record attempts rather than successes. Drop the Tenant row, which is covered centrally by table-features.md. Co-Authored-By: Claude Opus 5 (1M context) --- .../identity/reports/inactive-users-report.md | 28 +++++++++++-------- 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/docs/user-documentation/identity/reports/inactive-users-report.md b/docs/user-documentation/identity/reports/inactive-users-report.md index 2beb6389a0..4b2cfb2db0 100644 --- a/docs/user-documentation/identity/reports/inactive-users-report.md +++ b/docs/user-documentation/identity/reports/inactive-users-report.md @@ -1,6 +1,6 @@ # Inactive Users -This report lists accounts that have not signed in for six months or more, so licences sitting on dormant accounts can be found and reclaimed. Both interactive and non-interactive sign-ins are taken into account, and the most recent of the two decides whether an account counts as inactive. +This report lists accounts that have not signed in for six months or more, so licences sitting on dormant accounts can be found and reclaimed. Interactive sign-ins, non-interactive sign-ins and successful sign-ins are all taken into account, and the most recent of the three decides whether an account counts as inactive. {% hint style="info" %} Accounts that have never signed in at all are included, since an account with no sign-in history has by definition not signed in during the period. Days Since Last Sign In is empty for those. @@ -12,17 +12,21 @@ Disabled accounts and guests are left out. A disabled account is already handled ## Table Details -| Column | Description | -| -------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Tenant | The tenant the user belongs to. Shown when All Tenants is selected. | -| Tenant Display Name | The tenant's name. | -| User Principal Name | The user's sign-in name. | -| Display Name | The user's name. | -| Last Sign In Date Time | The most recent interactive sign-in, where the user signed in themselves. | -| Last Non Interactive Sign In Date Time | The most recent sign-in performed by a client on the user's behalf, such as a mail client refreshing a token. See [Microsoft Learn](https://learn.microsoft.com/en-us/entra/identity/monitoring-health/concept-noninteractive-sign-ins) for what counts. | -| Number Of Assigned Licenses | How many licences the account holds, which is the figure that turns this report into a reclamation list. | -| Days Since Last Sign In | How long the account has been dormant, counted from the later of the two sign-in dates. | -| Last Refreshed Date Time | When this report was produced. | +| Column | Description | +| -------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Tenant Display Name | The tenant's name. | +| User Principal Name | The user's sign-in name. | +| Display Name | The user's name. | +| Last Sign In Date Time | The most recent interactive sign-in attempt, where the user signed in themselves. Attempts that failed are recorded here as well as ones that worked. | +| Last Non Interactive Sign In Date Time | The most recent sign-in attempt made by a client on the user's behalf, such as a mail client refreshing a token. See [Microsoft Learn](https://learn.microsoft.com/en-us/entra/identity/monitoring-health/concept-noninteractive-sign-ins) for what counts. | +| Last Successful Sign In Date Time | The most recent sign-in that actually succeeded, whether the user signed in themselves or a client did it for them. This is the column to trust when judging whether an account is still in use. | +| Number Of Assigned Licenses | How many licences the account holds, which is the figure that turns this report into a reclamation list. | +| Days Since Last Sign In | How long the account has been dormant, counted from the most recent of the three sign-in dates. | +| Last Refreshed Date Time | When the user data behind this report was last refreshed, which is not the same as when you opened the page. | + +{% hint style="warning" %} +The three sign-in columns can disagree with each other. **Last Sign In Date Time** and **Last Non Interactive Sign In Date Time** record attempts, whether or not they succeeded, so an account with a run of failed sign-ins reads as more recently active in those two columns than it really was. **Last Successful Sign In Date Time** is the only one that records access that actually worked. +{% endhint %} ## Table Actions From 81dd2ae6456d31fa393bee8b1319b686ec77889f Mon Sep 17 00:00:00 2001 From: Brian Simpson <50429915+bmsimp@users.noreply.github.com> Date: Wed, 19 Aug 2026 07:12:32 -0500 Subject: [PATCH 174/226] docs(ca-templates): document custom variables in CA templates No page covered custom variables in Conditional Access templates, even though a template is only portable across an estate because of them. Adds a Custom Variables section to the Create CA Template page: that any field accepts a %variablename% token, that substitution runs before CIPP matches names in the target tenant (so a variable-named named location, authentication strength or authentication context matches the existing object instead of being recreated on every deploy), that the whole template is substituted at once so a location reference must use the same variable text as its display name, and where values are set. Also notes that this editor's fields carry no variable autocomplete: the builder has no textFieldWithVariables field, so the % list never appears and the name has to be typed in full. Co-Authored-By: Claude Opus 5 (1M context) --- .../conditional/list-template/create-ca-template.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/docs/user-documentation/tenant/conditional/list-template/create-ca-template.md b/docs/user-documentation/tenant/conditional/list-template/create-ca-template.md index 7237ccd572..1a6671d49e 100644 --- a/docs/user-documentation/tenant/conditional/list-template/create-ca-template.md +++ b/docs/user-documentation/tenant/conditional/list-template/create-ca-template.md @@ -169,4 +169,16 @@ Use **Add Named Location** to add an entry, and the delete icon on any entry to A template deployed to a tenant is not linked to it afterwards. Editing the template later does not change policies already deployed from it, unless the template is applied through a standard, which redeploys on drift. {% endhint %} +## Custom Variables + +Any field in this editor accepts a variable, written as `%variablename%`, which is replaced with the target tenant's value each time the template is deployed. That is what lets one template cover an estate where the details differ per client, such as a site name or an office IP range. + +Substitution happens before CIPP matches names in the target tenant, so a named location, authentication strength, or authentication context whose name is built from a variable is matched to the existing object of that name rather than created again on every deployment. Because the whole template is substituted at once, a variable used in a **Named Locations** display name resolves to the same value where it is referenced under **Include Locations** or **Exclude Locations**, so write the reference with the same variable text rather than a resolved value. + +Values are set for every tenant in [global-variables.md](../../administration/tenants/global-variables.md "mention"), or for one tenant in the Custom Variables box on [edit.md](../../manage/edit.md "mention"), where the tenant's own value wins. + +{% hint style="warning" %} +The fields in this editor do not offer the [variable-auto-complete.md](../../../shared-features/variable-auto-complete.md "mention") list, so the variable name has to be typed in full. A name that matches nothing for the tenant being deployed to is left in place as literal text, which is covered under Unresolved Variables on [global-variables.md](../../administration/tenants/global-variables.md "mention"). +{% endhint %} + {% include "../../../../../.gitbook/includes/feature-request.md" %} From b3530b01b030635c5cc95a40c2605eae10d20949 Mon Sep 17 00:00:00 2001 From: Brian Simpson <50429915+bmsimp@users.noreply.github.com> Date: Wed, 19 Aug 2026 07:18:42 -0500 Subject: [PATCH 175/226] docs(pwpush): document expiry ranges and test failure reporting Follows 07e0415e, which changed behaviour the page described incorrectly. - Expiration in Days and Expiration after views now state the accepted 1-90 and 1-100 ranges, and that an out-of-range value is ignored in favour of the Password Pusher default. - The branding account selector shows a single explanatory row when no accounts can be retrieved, and that row cannot be selected. The "Choose branding" step said the list stays empty instead. - The Test step now covers failure: the result names the reason rather than reporting "PWPush is not enabled" for everything. - Removed the five em dashes flagged by lint_docs.py. Co-Authored-By: Claude Opus 5 (1M context) --- .../cipp/integrations/passwordpusher.md | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/user-documentation/cipp/integrations/passwordpusher.md b/docs/user-documentation/cipp/integrations/passwordpusher.md index 4f6cdb62ae..fdb10f3db8 100644 --- a/docs/user-documentation/cipp/integrations/passwordpusher.md +++ b/docs/user-documentation/cipp/integrations/passwordpusher.md @@ -1,6 +1,6 @@ # Password Pusher -The Password Pusher integration replaces plain text passwords in CIPP with single-use links. When it is enabled, any password CIPP generates — for a new user, a password reset, a JIT admin account, or a bulk user import — is pushed to Password Pusher and the resulting link is returned in place of the password itself. The expiry, passphrase and retrieval settings configured here apply to every link CIPP creates. +The Password Pusher integration replaces plain text passwords in CIPP with single-use links. When it is enabled, any password CIPP generates (for a new user, a password reset, a JIT admin account, or a bulk user import) is pushed to Password Pusher and the resulting link is returned in place of the password itself. The expiry, passphrase and retrieval settings configured here apply to every link CIPP creates. Both the hosted service at [pwpush.com](https://pwpush.com) and self-hosted instances are supported. @@ -15,11 +15,11 @@ If your Password Pusher instance sits behind a Cloudflare Zero Trust tunnel, set | Enable Integration | Turns the integration on. Every other setting and the **Test** button remain unavailable until this is enabled and saved. | | Use Bearer Authentication (Hosted only) | Authenticates with a bearer token rather than an email address and API key. Only available on the hosted service; self-hosted instances must use the email address and API key method. | | PWPush URL | The base URL of your Password Pusher instance. Leave blank to use the hosted service at `https://pwpush.com`. | -| PWPush API Key | The API key or bearer token for your account. Optional — leaving it blank creates anonymous pushes. Stored securely and masked once saved. | +| PWPush API Key | The API key or bearer token for your account. Optional: leaving it blank creates anonymous pushes. Stored securely and masked once saved. | | PWPush email address | The email address of your Password Pusher account, used together with the API key. Hidden when bearer authentication is enabled. | -| Select your PWPush Account for branding (Pro/Premium only, optional with Custom Domain) | The Password Pusher account whose branding is applied to generated links. Only appears when bearer authentication is enabled, and requires a Pro or Premium subscription. | -| Expiration in Days | The number of days before a link expires. Leave blank to use the Password Pusher default. | -| Expiration after views | The number of views before a link expires. Leave blank to use the Password Pusher default. | +| Select your PWPush Account for branding (Pro/Premium only, optional with Custom Domain) | The Password Pusher account whose branding is applied to generated links. Only appears when bearer authentication is enabled, and requires a Pro or Premium subscription on the hosted service. When no accounts can be retrieved the list shows a single explanatory row instead, which cannot be selected. | +| Expiration in Days | The number of days before a link expires, from 1 to 90. Leave blank to use the Password Pusher default. A value outside that range is ignored and the default is used instead. | +| Expiration after views | The number of views before a link expires, from 1 to 100. Leave blank to use the Password Pusher default. A value outside that range is ignored and the default is used instead. | | Default Passphrase | A passphrase the recipient must enter before the password is revealed. Applied to every link CIPP creates, so it needs to be something you can communicate out of band. | | Click to retrieve password (recommended if passphrase is not set) | Adds an interstitial page requiring a deliberate click before the password is shown, which prevents link preview scanners in mail and chat clients from consuming a view. | | Allow deletion of passwords | Allows the recipient to delete the push once they have retrieved it. | @@ -57,7 +57,7 @@ Enter your **PWPush URL** if you are self-hosting, and configure whichever authe {% step %} ### Choose branding -Hosted Pro and Premium customers can select an account under **Select your PWPush Account for branding**. Save the configuration first — the account list is retrieved using the credentials you have stored, so it stays empty until they are saved. +Hosted Pro and Premium customers can select an account under **Select your PWPush Account for branding**. Save the configuration first, because the account list is retrieved using the credentials you have stored. Until they are saved, and whenever the token is not a valid Pro or Premium bearer token for the hosted service, the list shows a single explanatory row. That row is there to be read and cannot be selected. {% endstep %} {% step %} @@ -69,7 +69,7 @@ Set **Expiration in Days**, **Expiration after views**, and either a **Default P {% step %} ### Save and test -Select **Submit**, then select **Test**. A successful test creates a real push containing a test payload and offers a copy button, so you can open the link and confirm the expiry, passphrase and branding behave as you expect. +Select **Submit**, then select **Test**. A successful test creates a real push containing a test payload and offers a copy button, so you can open the link and confirm the expiry, passphrase and branding behave as you expect. A failed test reports the reason it failed, so a wrong URL, a rejected token or an unreachable instance shows up here rather than later as plain text passwords. {% endstep %} {% endstepper %} @@ -78,7 +78,7 @@ Select **Submit**, then select **Test**. A successful test creates a real push c Once enabled, the integration applies automatically wherever CIPP produces a password. This includes creating a user, resetting a user's password, bulk user creation, JIT admin account provisioning, and restore tasks that generate credentials. No per-action setting is required, and there is nothing to map. {% hint style="info" %} -If a link cannot be created — for example the instance is unreachable or the credentials are wrong — CIPP falls back to returning the plain text password and records a warning in the logbook. Passwords are never lost because of an integration failure, but it does mean a silent misconfiguration shows up as plain passwords rather than an obvious error. +If a link cannot be created, for example because the instance is unreachable or the credentials are wrong, CIPP falls back to returning the plain text password and records a warning in the logbook. Passwords are never lost because of an integration failure, but it does mean a silent misconfiguration shows up as plain passwords rather than an obvious error. {% endhint %} {% hint style="danger" %} @@ -86,7 +86,7 @@ Do not enable the **Force the default value?** option on a Password Pusher websi {% endhint %} {% hint style="info" %} -Password Pusher's own password generator policy applies only to the generator on its website, which is a convenience tool. It has no bearing on the passwords CIPP generates — those are controlled by CIPP's own password configuration. +Password Pusher's own password generator policy applies only to the generator on its website, which is a convenience tool. It has no bearing on the passwords CIPP generates, which are controlled by CIPP's own password configuration. {% endhint %} {% include "../../../../.gitbook/includes/feature-request.md" %} From 6e344fa3a97c493b116f5c57af947715f09a5ad6 Mon Sep 17 00:00:00 2001 From: Brian Simpson <50429915+bmsimp@users.noreply.github.com> Date: Wed, 19 Aug 2026 07:23:10 -0500 Subject: [PATCH 176/226] docs(groups): drop dynamic distribution group from group pages The group type was removed from the add group form, the add group template form and the deploy templates wizard in 67adec2b, so it no longer appears as a selectable type anywhere in the UI. Removes the type from the Group Type tables on the add group and add group template pages and from the wizard field table on the deploy page, narrows the external sender and membership rule settings to the types that still offer them, and drops the warning describing which fields a dynamic distribution group ignores at creation. The stored value is left documented on the group templates list page, because the backend still handles the type and templates saved before the change keep it. Co-Authored-By: Claude Opus 5 (1M context) --- .../identity/administration/group-templates/add.md | 5 ++--- .../identity/administration/group-templates/deploy.md | 4 ++-- .../identity/administration/groups/add.md | 7 ++----- 3 files changed, 6 insertions(+), 10 deletions(-) diff --git a/docs/user-documentation/identity/administration/group-templates/add.md b/docs/user-documentation/identity/administration/group-templates/add.md index 9a90144549..0c4b09d68d 100644 --- a/docs/user-documentation/identity/administration/group-templates/add.md +++ b/docs/user-documentation/identity/administration/group-templates/add.md @@ -30,7 +30,6 @@ Select one group type. The type determines which additional settings appear belo | Security Group | A standard security group, used for granting access to resources and for group-based licensing. | | Microsoft 365 Group | A Microsoft 365 (unified) group with a shared mailbox, calendar, and associated SharePoint site. | | Dynamic Group | A security group whose membership is calculated automatically from a membership rule. | -| Dynamic Distribution Group | An Exchange Online distribution group whose membership is resolved at send time from a recipient filter. | | Distribution List | An Exchange Online distribution group for delivering mail to a static list of recipients. | | Mail Enabled Security Group | A security group that can also receive mail, allowing it to be used both for permissions and for mail delivery. | @@ -41,10 +40,10 @@ These settings appear only for the group types listed against them. | Setting | Group Types | Description | | --------------------------------------------------- | ---------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Licenses (optional) | Security Group | Licences assigned to the group, so members inherit them through group-based licensing. Group-based licensing requires the tenant to be licensed for Entra ID P1 or higher. Assigning licences through a group without the appropriate licensing is not compliant with Microsoft's licensing terms. | -| Let people outside the organization email the group | Distribution List, Dynamic Distribution Group | Allows senders outside the organisation to email the group. When left off, only authenticated internal senders can deliver to it. | +| Let people outside the organization email the group | Distribution List | Allows senders outside the organisation to email the group. When left off, only authenticated internal senders can deliver to it. | | Email Aliases | Distribution List, Mail Enabled Security Group | Additional email addresses for the group, entered one per line. Added as secondary addresses alongside the primary address. | | Hide this group from the Global Address List (GAL) | Distribution List, Mail Enabled Security Group | Hides the group from address lists, so it does not appear when users browse or search for recipients. | -| Dynamic Group Parameters | Dynamic Group, Dynamic Distribution Group | The rule that determines membership. Dynamic groups use Entra ID membership rule syntax; dynamic distribution groups use an Exchange Online recipient filter. | +| Dynamic Group Parameters | Dynamic Group | The rule that determines membership, written in Entra ID membership rule syntax. | {% hint style="info" %} An example membership rule for a dynamic group, excluding guests and external users: diff --git a/docs/user-documentation/identity/administration/group-templates/deploy.md b/docs/user-documentation/identity/administration/group-templates/deploy.md index 3f7d0c2adb..33d83f4461 100644 --- a/docs/user-documentation/identity/administration/group-templates/deploy.md +++ b/docs/user-documentation/identity/administration/group-templates/deploy.md @@ -20,12 +20,12 @@ Choose the tenants the group should be created in. Several can be selected, and | Field | Description | | -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Group Type | The kind of group to create: Dynamic Group, Dynamic Distribution Group, Security Group, Distribution Group, Azure Role Group or Mail Enabled Security Group. Required, and it decides which of the settings below appear. | +| Group Type | The kind of group to create: Dynamic Group, Security Group, Distribution Group, Azure Role Group or Mail Enabled Security Group. Required, and it decides which of the settings below appear. | | Group Display Name | The name the group is created with. Required. | | Group Description | A description for the group. | | Group Username | The mail nickname the group's email address is built from. | | Allow external emails to the group | Allows senders outside the organisation to email the group. Shown for a Distribution Group. | -| Membership Rules | The rule that decides membership. Shown for a Dynamic Group or Dynamic Distribution Group, and required for both. | +| Membership Rules | The rule that decides membership. Shown for a Dynamic Group, and required. | | Email Aliases | Additional email addresses, one per line. Shown for a Distribution Group or Mail Enabled Security Group. | | Hide this group from the Global Address List (GAL) | Hides the group from address lists. Shown for a Distribution Group or Mail Enabled Security Group. | {% endstep %} diff --git a/docs/user-documentation/identity/administration/groups/add.md b/docs/user-documentation/identity/administration/groups/add.md index c141af5636..898a084b45 100644 --- a/docs/user-documentation/identity/administration/groups/add.md +++ b/docs/user-documentation/identity/administration/groups/add.md @@ -29,7 +29,6 @@ Select one group type. The type determines which additional settings appear belo | Security Group | A standard security group, used for granting access to resources and for group-based licensing. | | Microsoft 365 Group | A Microsoft 365 (unified) group with a shared mailbox, calendar, and associated SharePoint site. | | Dynamic Group | A security group whose membership is calculated automatically from a membership rule. | -| Dynamic Distribution Group | An Exchange Online distribution group whose membership is resolved at send time from a recipient filter. | | Distribution List | An Exchange Online distribution group for delivering mail to a static list of recipients. | | Mail Enabled Security Group | A security group that can also receive mail, allowing it to be used both for permissions and for mail delivery. | @@ -41,11 +40,11 @@ These settings appear only for the group types listed against them. | --------------------------------------------------------------- | -------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Disable group nesting (prevent other groups from being members) | Azure Role Group, Security Group, Microsoft 365 Group, Dynamic Group | Prevents other groups from being added as members, so the group can only contain users. | | Licenses (optional) | Security Group | Assigns one or more licences to the group, so members inherit them through group-based licensing. Each licence is listed with the number of units currently available. | -| Let people outside the organization email the group | Distribution List, Dynamic Distribution Group | Allows senders outside the organisation to email the group. When left off, only authenticated internal senders can deliver to it. | +| Let people outside the organization email the group | Distribution List | Allows senders outside the organisation to email the group. When left off, only authenticated internal senders can deliver to it. | | Email Aliases | Distribution List, Mail Enabled Security Group | Additional email addresses for the group, entered one per line as full SMTP addresses. These are added as secondary addresses alongside the primary address. | | Hide this group from the Global Address List (GAL) | Distribution List, Mail Enabled Security Group | Hides the group from address lists, so it does not appear when users browse or search for recipients. | | Subscribe members to receive group emails | Microsoft 365 Group | Automatically subscribes new members to the group's conversations, so group mail is delivered to their own inbox as well as the group mailbox. | -| Dynamic Group Parameters | Dynamic Group, Dynamic Distribution Group | The rule that determines membership. Dynamic groups use Entra ID membership rule syntax; dynamic distribution groups use an Exchange Online recipient filter. | +| Dynamic Group Parameters | Dynamic Group | The rule that determines membership, written in Entra ID membership rule syntax. | {% hint style="info" %} An example membership rule for a dynamic group, excluding guests and external users: @@ -55,8 +54,6 @@ An example membership rule for a dynamic group, excluding guests and external us {% hint style="warning" %} Members entered on this page are ignored for a **Dynamic Group**, because membership is calculated from the rule rather than assigned directly. Owners are still applied. - -A **Dynamic Distribution Group** goes further and ignores owners, members and the description as well. Only the display name, the recipient filter, the email address and the external sender setting are used at creation, so anything else needed on the group has to be set afterwards from the edit.md page or Exchange Online. {% endhint %} {% hint style="warning" %} From 5059b016780cd7ce4399be669544515a438becb9 Mon Sep 17 00:00:00 2001 From: Brian Simpson <50429915+bmsimp@users.noreply.github.com> Date: Wed, 19 Aug 2026 07:27:12 -0500 Subject: [PATCH 177/226] docs(user-documentation): document version history and hosting details The Versions card on Application Settings gained Hosting, App Service SKU, Runtime Stack and Last Updated rows plus a Copy for Ticket button, and the container management Status & Updates page gained an Update History card. Document both, including the Unknown fallbacks and the fact that version transitions are only recorded from the next update onward. Co-Authored-By: Claude Opus 5 (1M context) --- .../advanced/container-management/status.md | 8 ++++++++ .../cipp/settings/README.md | 20 ++++++++++++++----- 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/docs/user-documentation/cipp/advanced/container-management/status.md b/docs/user-documentation/cipp/advanced/container-management/status.md index 4c1b275f0e..6e9f4fd616 100644 --- a/docs/user-documentation/cipp/advanced/container-management/status.md +++ b/docs/user-documentation/cipp/advanced/container-management/status.md @@ -103,4 +103,12 @@ Select **Save Settings** to store these options. Setting Check Interval to **Dis Note that if the container restarts for any reason, the latest image for the current release channel is pulled regardless of these settings. +## Update History + +Lists the version changes recorded for this instance, newest first, so you can see when it landed on the build it is running and what it was on before. Each row gives the date and time the change was recorded, in UTC, followed by the version it moved from, the version it moved to, and the image tag it landed on. + +A change is recorded when the container starts on a different version from the one last seen, so the timestamp is when the new build first ran rather than when the image was published. The most recent 25 changes are kept. + +Until a change has been recorded the card reads **No updates recorded**. Version transitions are recorded from the next update onward, so a newly deployed instance starts with an empty history rather than a backfilled one. + {% include "../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/cipp/settings/README.md b/docs/user-documentation/cipp/settings/README.md index 63c09b5c58..e532113075 100644 --- a/docs/user-documentation/cipp/settings/README.md +++ b/docs/user-documentation/cipp/settings/README.md @@ -8,15 +8,25 @@ The General tab of the application settings brings together the instance-wide co ## Version -Shows the versions currently running, with the frontend and backend reported separately. +Shows the versions currently running, with the frontend and backend reported separately, together with how this instance is hosted. -| Field | Description | -| -------- | --------------------------------------------------------- | -| Frontend | The version of the CIPP web interface currently deployed. | -| Backend | The version of the CIPP API currently deployed. | +| Field | Description | +| --------------- | ---------------------------------------------------------------------------------------------------------------------------- | +| Frontend | The version of the CIPP web interface currently deployed. | +| Backend | The version of the CIPP API currently deployed. | +| Hosting | Whether this is a CyberDrain-hosted instance or a self-hosted one. | +| App Service SKU | The App Service plan the instance runs on, where the platform reports one. | +| Runtime Stack | The platform the API runs on: Flex Consumption, Linux, or Windows. | +| Last Updated | The most recent version change recorded for this instance, as the version it moved from and to, with the date and time in UTC. | Each version displays a tick when it is current, or a warning icon together with the newer version number when an update is available. Selecting **Check For Updates** re-queries both, which is worth doing after an upgrade rather than relying on a cached result. +Selecting **Copy for Ticket** copies the whole card to your clipboard as plain text, ready to paste into a support ticket. The button reads **Copied!** for a couple of seconds to confirm. + +Version changes are recorded from the next update onward, so a freshly deployed instance shows **No update recorded yet** against Last Updated until it has moved between versions at least once. The full record is on the [status.md](../advanced/container-management/status.md "mention") page. + +Any detail the instance cannot report is shown as **Unknown**. The version numbers themselves fall back to Unknown when the check against the published release cannot reach GitHub, for example when the request has been rate-limited, so the rest of the card still gives you something to send with a ticket. + {% hint style="info" %} The frontend and backend are versioned and deployed separately, so it is normal to see one flagged as out of date while the other is current during an upgrade. Both should match once the upgrade completes. {% endhint %} From 9c221f25472adf279a3807213f45e0dfbf16ab5d Mon Sep 17 00:00:00 2001 From: Brian Simpson <50429915+bmsimp@users.noreply.github.com> Date: Wed, 19 Aug 2026 07:40:51 -0500 Subject: [PATCH 178/226] docs(shared-features): cover GDAP relationships in entity switcher Add the GDAP relationship pages to the entity switcher's Where It Appears table, along with a note that its entries are named for the customer and a warning that the list is partner-level rather than tenant-scoped. Correct the caching claim to the five minutes the app actually reuses the list for, and cross-link the group, device, app registration, enterprise application and relationship pages. Co-Authored-By: Claude Opus 5 (1M context) --- .../shared-features/entity-switcher.md | 25 ++++++++++++------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/docs/user-documentation/shared-features/entity-switcher.md b/docs/user-documentation/shared-features/entity-switcher.md index 56c9dbd2d3..94888eaa49 100644 --- a/docs/user-documentation/shared-features/entity-switcher.md +++ b/docs/user-documentation/shared-features/entity-switcher.md @@ -4,13 +4,16 @@ On a page that shows a single record, the page title is a control rather than pl ## Where It Appears -| Page | The list holds | Shown beneath each name | -| -------------------------------------------------------------------------------------------------------- | ------------------------------------------ | -------------------------------- | -| [View Individual User](../identity/administration/users/user/ "mention"), on every tab | Every user in the tenant | User principal name | -| Group | Every group in the tenant | Mail address | -| Device | Every Intune managed device in the tenant | The user principal name recorded against the device | -| App Registration, on both tabs | Every app registration in the tenant | Application (client) ID | -| Enterprise Application, on both tabs | Every enterprise application in the tenant | Application ID | +| Page | The list holds | Shown beneath each name | +| ------------------------------------------------------------------------------------------------------------- | ------------------------------------------------- | --------------------------------------------------- | +| [View Individual User](../identity/administration/users/user/ "mention"), on every tab | Every user in the tenant | User principal name | +| [View Group](../identity/administration/groups/group.md "mention") | Every group in the tenant | Mail address | +| [View Device](../endpoint/mem/devices/device.md "mention") | Every Intune managed device in the tenant | The user principal name recorded against the device | +| [View App Registration](../tenant/administration/applications/app-registrations/appid.md "mention"), on both tabs | Every app registration in the tenant | Application (client) ID | +| [View Enterprise Application](../tenant/administration/applications/enterprise-apps/spid.md "mention"), on both tabs | Every enterprise application in the tenant | Application ID | +| [Relationship Summary](../tenant/gdap-management/relationships/relationship/ "mention"), on both tabs | Every GDAP relationship, across all your customers | The relationship name | + +On the GDAP relationship pages each entry is named for the customer rather than the relationship, matching the page header, with the relationship name on the second line. A relationship that has no customer recorded against it is listed as **No Customer Set**. ## Using It @@ -19,11 +22,15 @@ Selecting the title opens the list, ordered alphabetically by name. The box at t Choosing a record loads it in place. You stay on the tab you were on, so moving from one user's Exchange settings to another's takes a single selection rather than a trip back through the users list, and the tenant you are working in does not change. {% hint style="info" %} -The list is fetched the first time you open it rather than with the page, so there can be a short pause on a large tenant while it loads. It is held for the rest of your session after that. +The list is fetched the first time you open it rather than with the page, so there can be a short pause on a large tenant while it loads. Opening it again is immediate for the next five minutes, after which the following opening fetches it afresh. +{% endhint %} + +{% hint style="warning" %} +The list is not affected by any filter, search or preset applied to the table you arrived from. It holds every record of that kind, so an account hidden from your table view still appears here. {% endhint %} {% hint style="warning" %} -The list is not affected by any filter, search or preset applied to the table you arrived from. It holds every record of that kind in the tenant, so an account hidden from your table view still appears here. +The GDAP relationship list is the one exception to being scoped to a single tenant. It covers your customers as a whole, so selecting an entry can move you to a relationship belonging to a different customer. {% endhint %} ## On Narrow Screens From ddc53ecb493528eb01ab3328d1a7df6f4fcad8a9 Mon Sep 17 00:00:00 2001 From: Brian Simpson <50429915+bmsimp@users.noreply.github.com> Date: Wed, 19 Aug 2026 07:54:44 -0500 Subject: [PATCH 179/226] docs(shared-features): document the support file generator Adds a Generate Support File entry to the speed dial's Options table and a section covering the two capture modes, the recording chip, and redaction. Also adds the action to the mobile hint's larger-screen list, since it is registered on the speed dial only and does not reach the account menu. Co-Authored-By: Claude Opus 5 (1M context) --- .../shared-features/speed-dial.md | 32 ++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/docs/user-documentation/shared-features/speed-dial.md b/docs/user-documentation/shared-features/speed-dial.md index 8541692ec3..2031232ff3 100644 --- a/docs/user-documentation/shared-features/speed-dial.md +++ b/docs/user-documentation/shared-features/speed-dial.md @@ -3,7 +3,7 @@ The CIPP speed dial gives you quick access to help, feedback, and troubleshooting from anywhere in the application. It sits as a round button in the lower right corner of your browser window, and opens when you hover over it or click it. Clicking anywhere outside closes it again. {% hint style="info" %} -On a phone the lower right corner holds the actions for the page you are on, so the speed dial is not shown. **Report Bug**, **Request Feature**, **Join the Discord!**, **Check the Documentation** and **Clear Cache and Reload** move into your account menu instead. **Tutorials** and **License** are available on a larger screen. See [mobile-layout.md](mobile-layout.md "mention"). +On a phone the lower right corner holds the actions for the page you are on, so the speed dial is not shown. **Report Bug**, **Request Feature**, **Join the Discord!**, **Check the Documentation** and **Clear Cache and Reload** move into your account menu instead. **Tutorials**, **License** and **Generate Support File** are available on a larger screen. See [mobile-layout.md](mobile-layout.md "mention"). {% endhint %} ## Options @@ -15,6 +15,7 @@ On a phone the lower right corner holds the actions for the page you are on, so | Join the Discord! | Opens a new tab to join the [CyberDrain Discord server](https://discord.gg/cyberdrain). | | Request Feature | Opens a new tab to the GitHub feature request form. | | Report Bug | Opens a new tab to the GitHub bug report form. | +| Generate Support File | Collects what support needs to diagnose a problem into a single file you can download and attach to a ticket. See [#generate-support-file](speed-dial.md#generate-support-file "mention") below. | | License | Opens CIPP's own licence page, showing the GNU Affero General Public License terms. | | Clear Cache and Reload | Clears CIPP's cached data from your browser and reloads the page. This is especially helpful if you recently updated CIPP and are still seeing an older version. | @@ -28,4 +29,33 @@ The **Tutorials** option opens a list of guided walkthroughs that highlight part Your progress is tracked, with a count of how many tutorials you have completed shown at the foot of the list and completed entries marked. A reset control at the top of the dialog clears that progress so the tutorials can be taken again. +## Generate Support File + +**Generate Support File** gathers the information support usually has to ask for into one JSON file: the requests CIPP made to its own API, the version, hosting and update details of your instance, and your signed-in identity and the roles you hold. You download the file and attach it to your ticket or Discord thread. + +Choose how the requests are captured. + +| Option | Description | +| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| **Capture This Page** | Reloads the data on the page you are on and captures the requests it makes. Use this where the problem is visible on a single page, such as a table that will not load or a value that looks wrong. | +| **Record Actions** | Starts recording and closes the dialog so you can reproduce the problem yourself. Use this where the problem only appears after a sequence of steps, or spans more than one page. | + +While a recording is running, a red **Recording** chip sits beside the speed dial. Selecting it reopens the dialog, where **Stop & Generate** ends the recording and builds the file, **Continue Recording** returns you to the page, and **Discard** throws the recording away. Reloading the browser also discards it. + +Once the file is ready the dialog reports how many requests were captured and how many of them failed, and **Download** saves it. + +### Redaction + +**Redact tenant IDs, domains and email addresses** is on by default. With it on, every email address, GUID and tenant domain in the file is replaced before you ever see it: addresses become `user1@redacted.invalid`, tenant domains become `domain1.invalid`, and GUIDs become placeholder GUIDs. The replacement is consistent, so the same value always becomes the same placeholder and support can still follow one user or one tenant through the file without learning who they are. Your own CIPP instance's address is kept, because that identifies the installation rather than a customer. + +Turning redaction off produces a file containing real tenant data from the requests that were captured. + +{% hint style="warning" %} +Read the file before you send it, particularly with redaction turned off. It contains the responses CIPP received, which can include user names, addresses and tenant identifiers. +{% endhint %} + +{% hint style="info" %} +Authentication tokens are removed from every file, whether or not redaction is on. Very large responses are shortened and marked as such, so one oversized page cannot produce a file too big to attach. +{% endhint %} + {% include "../../../.gitbook/includes/feature-request.md" %} From 7d6a6a92eab9b7de1114fb83ba45809f8429a697 Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Wed, 19 Aug 2026 14:57:12 +0200 Subject: [PATCH 180/226] add macosx wipe --- .../Endpoint/MEM/Invoke-ExecDeviceAction.ps1 | 15 +++++++++ .../CippIntuneDeviceActions.jsx | 32 +++++++++++++++---- 2 files changed, 41 insertions(+), 6 deletions(-) diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ExecDeviceAction.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ExecDeviceAction.ps1 index 8b3123ea19..bad51eddf0 100644 --- a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ExecDeviceAction.ps1 +++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ExecDeviceAction.ps1 @@ -38,6 +38,21 @@ function Invoke-ExecDeviceAction { Write-Host "ActionBody: $ActionBody" break } + 'wipe' { + # Graph rejects/ignores unknown wipe parameters and an empty macOsUnlockCode, + # so forward only the supported ones instead of the raw request body + $WipeBody = @{} + foreach ($Param in @('keepUserData', 'keepEnrollmentData', 'useProtectedWipe', 'persistEsimDataPlan')) { + if ($null -ne $Request.Body.$Param) { + $WipeBody[$Param] = [System.Convert]::ToBoolean("$($Request.Body.$Param)") + } + } + if (-not [string]::IsNullOrWhiteSpace($Request.Body.macOsUnlockCode)) { + $WipeBody.macOsUnlockCode = "$($Request.Body.macOsUnlockCode)" + } + $ActionBody = $WipeBody | ConvertTo-Json -Compress + break + } 'createDeviceLogCollectionRequest' { $ActionBody = @{ templateType = @{ diff --git a/frontend/src/components/CippComponents/CippIntuneDeviceActions.jsx b/frontend/src/components/CippComponents/CippIntuneDeviceActions.jsx index 40b0fef8df..8f56cbb7f3 100644 --- a/frontend/src/components/CippComponents/CippIntuneDeviceActions.jsx +++ b/frontend/src/components/CippComponents/CippIntuneDeviceActions.jsx @@ -365,7 +365,7 @@ export const getIntuneDeviceActions = ({ tenantFilter } = {}) => [ url: '/api/ExecDeviceAction', data: { GUID: 'id', - Action: 'cleanWindowsDevice', + Action: 'wipe', keepUserData: false, keepEnrollmentData: true, }, @@ -379,7 +379,7 @@ export const getIntuneDeviceActions = ({ tenantFilter } = {}) => [ url: '/api/ExecDeviceAction', data: { GUID: 'id', - Action: 'cleanWindowsDevice', + Action: 'wipe', keepUserData: false, keepEnrollmentData: false, }, @@ -393,7 +393,7 @@ export const getIntuneDeviceActions = ({ tenantFilter } = {}) => [ url: '/api/ExecDeviceAction', data: { GUID: 'id', - Action: 'cleanWindowsDevice', + Action: 'wipe', keepEnrollmentData: true, keepUserData: false, useProtectedWipe: true, @@ -409,7 +409,7 @@ export const getIntuneDeviceActions = ({ tenantFilter } = {}) => [ url: '/api/ExecDeviceAction', data: { GUID: 'id', - Action: 'cleanWindowsDevice', + Action: 'wipe', keepEnrollmentData: false, keepUserData: false, useProtectedWipe: true, @@ -418,6 +418,26 @@ export const getIntuneDeviceActions = ({ tenantFilter } = {}) => [ confirmText: 'Are you sure you want to wipe [deviceName]? This will also remove enrollment data. Continuing at powerloss may cause boot issues if wipe is interrupted.', }, + { + label: 'Wipe Device', + type: 'POST', + icon: , + url: '/api/ExecDeviceAction', + data: { + GUID: 'id', + Action: 'wipe', + }, + fields: [ + { + type: 'textField', + name: 'macOsUnlockCode', + label: 'Recovery PIN (optional, 6 digits)', + }, + ], + condition: (row) => row.operatingSystem === 'macOS', + confirmText: + 'Are you sure you want to wipe [deviceName]? This erases all content and settings and cannot be undone. Intel Macs without a T2 security chip require the recovery PIN to unlock the device after the wipe.', + }, { label: 'Autopilot Reset', type: 'POST', @@ -426,8 +446,8 @@ export const getIntuneDeviceActions = ({ tenantFilter } = {}) => [ data: { GUID: 'id', Action: 'wipe', - keepUserData: 'false', - keepEnrollmentData: 'true', + keepUserData: false, + keepEnrollmentData: true, }, condition: (row) => row.operatingSystem === 'Windows', confirmText: 'Are you sure you want to Autopilot Reset [deviceName]?', From d654e282a509a8ff4f0fce3547d4cef82fb3e14f Mon Sep 17 00:00:00 2001 From: Brian Simpson <50429915+bmsimp@users.noreply.github.com> Date: Wed, 19 Aug 2026 08:02:41 -0500 Subject: [PATCH 181/226] docs(alert-configuration): document alert enable and disable actions Covers the new Enabled column and the Enable Alert and Disable Alert row actions added in 8dc8739, and corrects View Task Details to describe its condition as greyed out rather than hidden. Co-Authored-By: Claude Opus 5 (1M context) --- .../alert-configuration/README.md | 27 +++++++++++-------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/docs/user-documentation/tenant/administration/alert-configuration/README.md b/docs/user-documentation/tenant/administration/alert-configuration/README.md index 52f16c39c2..484ea8c402 100644 --- a/docs/user-documentation/tenant/administration/alert-configuration/README.md +++ b/docs/user-documentation/tenant/administration/alert-configuration/README.md @@ -1,6 +1,6 @@ # Alert Configuration -Alerts in CIPP come in two flavours, and both are listed here. Audit log alerts watch the Microsoft 365 audit log and fire as matching entries arrive. Scripted alerts run on a recurring schedule and check a specific condition each time they execute. This page shows every configured alert rule of both kinds, with the tenants they cover and what happens when they trigger, and lets you edit, clone or remove them. +Alerts in CIPP come in two flavours, and both are listed here. Audit log alerts watch the Microsoft 365 audit log and fire as matching entries arrive. Scripted alerts run on a recurring schedule and check a specific condition each time they execute. This page shows every configured alert rule of both kinds, with the tenants they cover and what happens when they trigger, and lets you edit, clone, disable or remove them. ## Action Buttons @@ -8,15 +8,16 @@ Use [alert.md](alert.md "mention") to create a new alert rule of either type. ## Table Details -| Column | Description | -| ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Tenants | The tenants or tenant groups the alert is scoped to. | -| Event Type | Whether the rule is an audit log alert (`Audit log Alert`) or a scripted alert that runs on a schedule (`Scheduled Task`). | -| Conditions | For audit log alerts, the configured conditions written out in plain language, joined with "and" when more than one is set. For scripted alerts, the name of the alert being run. | -| Repeats Every | How often the alert runs. Audit log alerts show `When received`, as they fire as matching log entries arrive; scripted alerts show their configured recurrence. | -| Actions | What CIPP does when the alert triggers. For audit log alerts this is the list of chosen response actions, such as generating a ticket or disabling the user in the log entry. For scripted alerts it is the configured delivery method. | -| Alert Comment | The optional free-text comment saved with the alert. | -| Excluded Tenants | Any tenants or tenant groups left out of the alert's scope. | +| Column | Description | +| ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Tenants | The tenants or tenant groups the alert is scoped to. | +| Event Type | Whether the rule is an audit log alert (`Audit log Alert`) or a scripted alert that runs on a schedule (`Scheduled Task`). | +| Enabled | Whether the alert is currently live. A disabled alert keeps its full configuration but never triggers: audit log alerts stop matching incoming entries, and scripted alerts are skipped when their next run is due. | +| Conditions | For audit log alerts, the configured conditions written out in plain language, joined with "and" when more than one is set. For scripted alerts, the name of the alert being run. | +| Repeats Every | How often the alert runs. Audit log alerts show `When received`, as they fire as matching log entries arrive; scripted alerts show their configured recurrence. | +| Actions | What CIPP does when the alert triggers. For audit log alerts this is the list of chosen response actions, such as generating a ticket or disabling the user in the log entry. For scripted alerts it is the configured delivery method. | +| Alert Comment | The optional free-text comment saved with the alert. | +| Excluded Tenants | Any tenants or tenant groups left out of the alert's scope. | {% hint style="info" %} Excluded tenants only apply where the alert is scoped broadly, such as to all tenants or to a tenant group. A scripted alert that names its tenants individually has nothing to exclude, so this column stays empty. @@ -24,6 +25,10 @@ Excluded tenants only apply where the alert is scoped broadly, such as to all te ## Table Actions -
    ActionDescriptionBulk Action Available
    View Task DetailsOpens the underlying scheduled task, showing its run history and results. Only available for rows with an Event Type of Scheduled Task.false
    Edit AlertOpens the alert for editing so its tenants, conditions, schedule and actions can be adjusted and saved back over the existing rule.false
    Clone & Edit AlertOpens a copy of the alert for editing, saving it as a new rule and leaving the original untouched. Useful for applying the same alert to a different set of tenants.false
    Delete AlertRemoves the alert rule after confirmation. The alert stops firing immediately and cannot be recovered.true
    +
    ActionDescriptionBulk Action Available
    View Task DetailsOpens the underlying scheduled task, showing its run history and results. Greyed out for any row whose Event Type is not Scheduled Task.false
    Edit AlertOpens the alert for editing so its tenants, conditions, schedule and actions can be adjusted and saved back over the existing rule.false
    Clone & Edit AlertOpens a copy of the alert for editing, saving it as a new rule and leaving the original untouched. Useful for applying the same alert to a different set of tenants.false
    Enable AlertTurns a disabled alert back on after confirmation, so it starts triggering again from the next matching log entry or scheduled run. Greyed out for alerts that are already enabled.true
    Disable AlertStops the alert triggering after confirmation, leaving its tenants, conditions and actions in place so it can be switched back on later. Greyed out for alerts that are already disabled.true
    Delete AlertRemoves the alert rule after confirmation. The alert stops firing immediately and cannot be recovered.true
    + +{% hint style="info" %} +Disabling is the reversible alternative to deleting. Editing a disabled alert and saving it keeps it disabled, so an alert stays off until you explicitly enable it again. +{% endhint %} {% include "../../../../../.gitbook/includes/feature-request.md" %} From afeb8ffedd74ac78d8f45f16aeec4bb2a9841dbd Mon Sep 17 00:00:00 2001 From: Brian Simpson <50429915+bmsimp@users.noreply.github.com> Date: Wed, 19 Aug 2026 08:07:15 -0500 Subject: [PATCH 182/226] docs(offboarding): document quarantine release request alert removal Add the RemoveQuarantineAlert switch to the offboarding actions table, covering what it deletes and what happens when the tenant has no such alert. Co-Authored-By: Claude Opus 5 (1M context) --- docs/user-documentation/tenant/gdap-management/offboarding.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/user-documentation/tenant/gdap-management/offboarding.md b/docs/user-documentation/tenant/gdap-management/offboarding.md index f8523e131d..52dd7af2b2 100644 --- a/docs/user-documentation/tenant/gdap-management/offboarding.md +++ b/docs/user-documentation/tenant/gdap-management/offboarding.md @@ -25,6 +25,7 @@ The tenant will not be fully offboarded unless all the relationships and contrac | Remove all guest users originating from the CSP tenant. | Removes guest accounts in the customer tenant that came from your partner tenant. | | Remove all notification contacts originating from the CSP tenant (technical, security, marketing notifications). | Clears your partner tenant's addresses from the customer's notification contacts. | | Remove all Domain Analyser results for this tenant. | Deletes the tenant's stored Domain Analyser data from CIPP. | +| Remove the quarantine release request alert created by the CIPP standard. | Deletes the alert that the Quarantine Release Request Alert standard created in the tenant, which emails a nominated address whenever a user asks for a quarantined message to be released. If the tenant has no such alert, the offboarding reports that none was found. | {% hint style="danger" %} The following actions will terminate all delegated access to the customer tenant! From afac567989b0f68d3fd36eaab95d6afc4114a0cc Mon Sep 17 00:00:00 2001 From: Brian Simpson <50429915+bmsimp@users.noreply.github.com> Date: Wed, 19 Aug 2026 08:13:00 -0500 Subject: [PATCH 183/226] docs(roles): cover a custom role tenant scope that resolves to no tenants A custom role can end up scoped to zero tenants, and 98ca5952 makes that state deny every tenant's data rather than fall through to unrestricted. The evaluation page had no coverage of it, so the symptom reads as CIPP showing no data rather than as a scope problem. Adds a section naming the three ways a scope empties out (allowed tenants all blocked, a tenant group resolving to no members, custom roles since deleted) and a matching quick-reference row. Co-Authored-By: Claude Opus 5 (1M context) --- docs/setup/resources/how-cipp-evaluates-roles.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/docs/setup/resources/how-cipp-evaluates-roles.md b/docs/setup/resources/how-cipp-evaluates-roles.md index e9b1a1a707..446a4b4f0a 100644 --- a/docs/setup/resources/how-cipp-evaluates-roles.md +++ b/docs/setup/resources/how-cipp-evaluates-roles.md @@ -69,6 +69,16 @@ If a user holds more than one custom role, their granted permissions are **combi Tenant scope is evaluated **per custom role**, not pooled across them. For any single action, one custom role must grant **both** the required permission **and** access to the target tenant. A user cannot borrow the permission from one custom role and the tenant access from another. {% endhint %} +### A tenant scope that resolves to no tenants + +A custom role's tenant scope can end up covering nothing at all. The usual causes are a role whose Allowed Tenants are all named again under Blocked Tenants, because a blocked tenant always wins over an allowed one, and a role scoped to a tenant group that currently resolves to no tenants. A user whose only custom roles have since been deleted ends up in the same position. + +A role in that state reaches no tenant's data at all, whatever API permissions it holds. The tenant selector comes back empty, All Tenants pages show nothing, and reports drawn from cached data are empty too. A scope that grants nothing is treated as nothing rather than as everything. + +{% hint style="info" %} +This is usually reported as CIPP showing no data rather than as an access problem, because nothing on screen says the tenant scope is empty. Check Allowed Tenants and Blocked Tenants on every custom role the user holds, and the membership of any tenant group either list names, before looking anywhere else. +{% endhint %} + ## Worked examples ### Example 1 — Built-in roles only @@ -148,6 +158,7 @@ Starting impersonation is written to the logs and attributed to the super admin' | `editor`/`readonly` + several custom roles | **Union** of the custom roles' grants, still capped by the built-in ceiling; tenant scope checked per role. | | One custom role, **no** base role | Exactly what that role explicitly grants — no ceiling. Unset categories are denied. | | Several custom roles, **no** base role | **Union** of all the roles' explicit grants; tenant scope checked per role. | +| A custom role whose tenant scope resolves to no tenants | **No access to any tenant's data**, whatever permissions the role grants. | {% hint style="info" %} Because `admin`/`superadmin` bypass custom roles, the most common pattern for scoped access is to map a base role (`editor` or `readonly`) to one Entra group and a custom role to another, then add users to **both** — the base role provides a safe ceiling and the custom role tailors it. Custom-roles-only assignments also work, but without a base-role ceiling they grant exactly what is defined, so review them carefully. See [Custom Roles](../setting-up-cipp/roles.md#custom-roles) for the full setup steps. From 06cf29072d9dd6d594c807307635942dff4d6b52 Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Wed, 19 Aug 2026 15:25:55 +0200 Subject: [PATCH 184/226] device encryption state --- backend/Config/CIPPDBCacheTypes.json | 2 +- .../Public/Test-CIPPCloudPCDevice.ps1 | 29 +++++++++++++++++++ .../Push-ExecGenerateReportBuilderReport.ps1 | 13 +++++++-- ...PPDBCacheManagedDeviceEncryptionStates.ps1 | 22 ++++++++++++++ .../DBCache/Set-CIPPDBCacheManagedDevices.ps1 | 8 +++++ .../components/CippPdf/previewSampleData.js | 12 ++++++++ .../src/components/ExecutiveReportButton.js | 6 +++- frontend/src/data/CIPPDBCacheTypes.json | 2 +- .../tools/report-builder/builder/index.js | 9 +++++- frontend/src/utils/is-cloud-pc-device.js | 13 +++++++++ 10 files changed, 109 insertions(+), 7 deletions(-) create mode 100644 backend/Modules/CIPPCore/Public/Test-CIPPCloudPCDevice.ps1 create mode 100644 frontend/src/utils/is-cloud-pc-device.js diff --git a/backend/Config/CIPPDBCacheTypes.json b/backend/Config/CIPPDBCacheTypes.json index 59f6eae7f6..07903eacec 100644 --- a/backend/Config/CIPPDBCacheTypes.json +++ b/backend/Config/CIPPDBCacheTypes.json @@ -362,7 +362,7 @@ { "type": "ManagedDeviceEncryptionStates", "friendlyName": "Managed Device Encryption States", - "description": "BitLocker encryption states for managed devices" + "description": "BitLocker encryption states for managed devices; Windows 365 Cloud PCs are marked encryptedByPlatform" }, { "type": "IntuneAppProtectionPolicies", diff --git a/backend/Modules/CIPPCore/Public/Test-CIPPCloudPCDevice.ps1 b/backend/Modules/CIPPCore/Public/Test-CIPPCloudPCDevice.ps1 new file mode 100644 index 0000000000..3655e08e20 --- /dev/null +++ b/backend/Modules/CIPPCore/Public/Test-CIPPCloudPCDevice.ps1 @@ -0,0 +1,29 @@ +function Test-CIPPCloudPCDevice { + <# + .SYNOPSIS + Returns whether an Intune managed device is a Windows 365 Cloud PC + + .DESCRIPTION + Cloud PCs never report BitLocker (isEncrypted stays false) although their disks are + encrypted at rest by Azure platform/storage encryption, so encryption reporting must + treat them as platform-encrypted instead of flagging them as unencrypted. + + deviceType 'cloudPC' is the documented Graph signal; the model/manufacturer pair + Windows 365 provisions ("Cloud PC ..." / "Microsoft Corporation") covers responses + where deviceType is missing. chassisType has no cloudPC member in current Graph + metadata but is checked anyway in case the service starts emitting it. The default + "CPC-" device-name prefix is deliberately NOT used - names are user-controllable. + + .PARAMETER Device + The managedDevice object (raw Graph response or CIPP-cached row) to test + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + $Device + ) + + if ($Device.isCloudPC -eq $true) { return $true } + if ($Device.deviceType -eq 'cloudPC' -or $Device.chassisType -eq 'cloudPC') { return $true } + return [bool]($Device.model -like 'Cloud PC*' -and $Device.manufacturer -eq 'Microsoft Corporation') +} diff --git a/backend/Modules/CIPPCore/Public/Tools/Push-ExecGenerateReportBuilderReport.ps1 b/backend/Modules/CIPPCore/Public/Tools/Push-ExecGenerateReportBuilderReport.ps1 index 0c2dce55bb..ee55408feb 100644 --- a/backend/Modules/CIPPCore/Public/Tools/Push-ExecGenerateReportBuilderReport.ps1 +++ b/backend/Modules/CIPPCore/Public/Tools/Push-ExecGenerateReportBuilderReport.ps1 @@ -76,7 +76,14 @@ function Push-ExecGenerateReportBuilderReport { } } $ResolveCellValue = { - param($Value) + param($Value, $Header, $Row) + # Windows 365 Cloud PCs never report BitLocker (isEncrypted stays false) although + # their disks are platform-encrypted by Azure - rendered as a distinct state so the + # device is not flagged as an encryption risk. Mirrored by the report builder's + # client-side preview (formatDatabaseContent). + if ($Header -eq 'isEncrypted' -and $Value -ne $true -and $Row -and (Test-CIPPCloudPCDevice -Device $Row)) { + return 'Encrypted (platform-managed)' + } $Items = @($Value) if ($LicenseNamesBySkuId.Count -eq 0 -or $Items.Count -eq 0 -or $null -eq $Items[0] -or -not $Items[0].PSObject.Properties['skuId']) { return $Value @@ -131,7 +138,7 @@ function Push-ExecGenerateReportBuilderReport { $Obj = [ordered]@{} foreach ($Header in $SelectedHeaders) { $Val = $Row.$Header - $Obj[$Header] = if ($null -ne $Val) { & $ResolveCellValue $Val } else { '' } + $Obj[$Header] = if ($null -ne $Val) { & $ResolveCellValue $Val $Header $Row } else { '' } } [PSCustomObject]$Obj }) @@ -231,7 +238,7 @@ function Push-ExecGenerateReportBuilderReport { $Obj = [ordered]@{} foreach ($Header in $SelectedHeaders) { $Val = $Row.$Header - $Obj[$Header] = if ($null -ne $Val) { & $ResolveCellValue $Val } else { '' } + $Obj[$Header] = if ($null -ne $Val) { & $ResolveCellValue $Val $Header $Row } else { '' } } [PSCustomObject]$Obj }) diff --git a/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheManagedDeviceEncryptionStates.ps1 b/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheManagedDeviceEncryptionStates.ps1 index 413cc53c29..962fd98117 100644 --- a/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheManagedDeviceEncryptionStates.ps1 +++ b/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheManagedDeviceEncryptionStates.ps1 @@ -19,6 +19,21 @@ function Set-CIPPDBCacheManagedDeviceEncryptionStates { try { Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching managed device encryption states' -sev Debug + # Encryption-state rows carry no model/manufacturer and their deviceType enum predates + # cloudPC, so a row cannot identify a Windows 365 Cloud PC by itself. The ManagedDevices + # cache (written earlier in the same Intune collection) can: rows share the managed + # device id. Cloud PCs are platform-encrypted by Azure but never report BitLocker, so + # without this join every Cloud PC lands in encryption reports as notEncrypted. If the + # devices cache is missing the set stays empty and rows pass through untouched. + $CloudPCIds = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + try { + foreach ($Device in @(New-CIPPDbRequest -TenantFilter $TenantFilter -Type 'ManagedDevices' -Fields 'id', 'isCloudPC', 'deviceType', 'chassisType', 'model', 'manufacturer')) { + if ($Device.id -and (Test-CIPPCloudPCDevice -Device $Device)) { $null = $CloudPCIds.Add([string]$Device.id) } + } + } catch { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Could not load the managed devices cache; Cloud PCs will not be marked platform-encrypted: $($_.Exception.Message)" -sev Warning + } + # A row per device, iterated once, so it is streamed into the writer instead of held whole. # The writer is opened before the pipeline on purpose: GetSteppablePipeline() captures # whichever scope is live, so opening it inside ForEach-Object captures the Graph call's @@ -29,6 +44,13 @@ function Set-CIPPDBCacheManagedDeviceEncryptionStates { $Writer.Begin($true) try { New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/deviceManagement/managedDeviceEncryptionStates?$top=999' -tenantid $TenantFilter -Stream | ForEach-Object { + $IsCloudPC = $CloudPCIds.Contains([string]$_.id) + $_ | Add-Member -NotePropertyName 'isCloudPC' -NotePropertyValue $IsCloudPC -Force + # A distinct state rather than a rewrite to 'encrypted': the disk IS encrypted at + # rest, but by the Azure platform, not by a BitLocker policy this report tracks. + if ($IsCloudPC -and $_.encryptionState -eq 'notEncrypted') { + $_ | Add-Member -NotePropertyName 'encryptionState' -NotePropertyValue 'encryptedByPlatform' -Force + } $CachedCount++ $Writer.Process($_) } diff --git a/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheManagedDevices.ps1 b/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheManagedDevices.ps1 index 27032d5d4a..9c82673c4b 100644 --- a/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheManagedDevices.ps1 +++ b/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheManagedDevices.ps1 @@ -19,6 +19,14 @@ function Set-CIPPDBCacheManagedDevices { try { Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching managed devices' -sev Debug New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/deviceManagement/managedDevices?$top=999' -tenantid $TenantFilter -Stream | + ForEach-Object { + # Windows 365 Cloud PCs never report BitLocker (isEncrypted stays false) although + # their disks are platform-encrypted by Azure; the marker is stamped on every row + # so it is always available as a report column, not only when the first cached + # device happens to be a Cloud PC. + $_ | Add-Member -NotePropertyName 'isCloudPC' -NotePropertyValue ([bool](Test-CIPPCloudPCDevice -Device $_)) -Force + $_ + } | Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'ManagedDevices' -AddCount Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached managed devices successfully' -sev Debug diff --git a/frontend/src/components/CippPdf/previewSampleData.js b/frontend/src/components/CippPdf/previewSampleData.js index f3a607f508..a021b7bb7e 100644 --- a/frontend/src/components/CippPdf/previewSampleData.js +++ b/frontend/src/components/CippPdf/previewSampleData.js @@ -79,6 +79,18 @@ export const SAMPLE_EXECUTIVE = { isEncrypted: true, lastSyncDateTime: '2026-08-04T21:30:00Z', }, + // A Windows 365 Cloud PC: isEncrypted is false (no BitLocker) but the disk is + // platform-encrypted by Azure, so the report counts it as encrypted. + { + deviceName: 'CPC-SAMPLE-005', + operatingSystem: 'Windows', + complianceState: 'compliant', + isEncrypted: false, + deviceType: 'cloudPC', + model: 'Cloud PC Enterprise 2vCPU/8GB/128GB', + manufacturer: 'Microsoft Corporation', + lastSyncDateTime: '2026-08-05T08:20:00Z', + }, ], // Also a plain array — `conditionalAccessData?.data?.Results` in the real report. conditionalAccessData: [ diff --git a/frontend/src/components/ExecutiveReportButton.js b/frontend/src/components/ExecutiveReportButton.js index 85dbe61f12..77dd0813fc 100644 --- a/frontend/src/components/ExecutiveReportButton.js +++ b/frontend/src/components/ExecutiveReportButton.js @@ -29,6 +29,7 @@ import { ShadowAIReportPages } from './ShadowAIReportButton' import { DEFAULT_BRANDING_OPTION } from './ReportBuilder/reportSettings' import { useReportVariables } from './CippPdf/useReportVariables' import { useBrandingSettings } from './CippPdf/useBrandingSettings' +import { isCloudPcDevice } from '../utils/is-cloud-pc-device' import { Bold, BulletList, @@ -1146,7 +1147,10 @@ export const ExecutiveReportDocument = ({ label: 'Android Devices', }, { - value: deviceData.filter((device) => device.isEncrypted === true).length, + // Cloud PCs never report BitLocker but are platform-encrypted by Azure. + value: deviceData.filter( + (device) => device.isEncrypted === true || isCloudPcDevice(device), + ).length, label: 'Encrypted', }, ]} diff --git a/frontend/src/data/CIPPDBCacheTypes.json b/frontend/src/data/CIPPDBCacheTypes.json index 9eac150e02..1833095396 100644 --- a/frontend/src/data/CIPPDBCacheTypes.json +++ b/frontend/src/data/CIPPDBCacheTypes.json @@ -332,7 +332,7 @@ { "type": "ManagedDeviceEncryptionStates", "friendlyName": "Managed Device Encryption States", - "description": "BitLocker encryption states for managed devices" + "description": "BitLocker encryption states for managed devices; Windows 365 Cloud PCs are marked encryptedByPlatform" }, { "type": "IntuneAppProtectionPolicies", diff --git a/frontend/src/pages/tools/report-builder/builder/index.js b/frontend/src/pages/tools/report-builder/builder/index.js index a28ee52cf4..116bd5ba96 100644 --- a/frontend/src/pages/tools/report-builder/builder/index.js +++ b/frontend/src/pages/tools/report-builder/builder/index.js @@ -35,6 +35,7 @@ import ReactMarkdown from 'react-markdown' import remarkGfm from 'remark-gfm' import { renderCustomScriptMarkdownTemplate } from '../../../../utils/customScriptTemplate' import { getCippLicenseTranslation } from '../../../../utils/get-cipp-license-translation' +import { isCloudPcDevice } from '../../../../utils/is-cloud-pc-device' import { escapeTableCell, isTableSeparatorRow, @@ -760,7 +761,13 @@ const formatDatabaseContent = (data, selectedHeaders, format) => { const obj = {} selectedHeaders.forEach((h) => { const val = row[h] !== undefined && row[h] !== null ? row[h] : '' - obj[h] = isLicenseAssignmentValue(val) ? getCippLicenseTranslation(val).join(', ') : val + if (h === 'isEncrypted' && val !== true && isCloudPcDevice(row)) { + // Cloud PCs never report BitLocker but are platform-encrypted by Azure. Matches the + // cell rendering the backend applies when the report is generated. + obj[h] = 'Encrypted (platform-managed)' + } else { + obj[h] = isLicenseAssignmentValue(val) ? getCippLicenseTranslation(val).join(', ') : val + } }) return obj }) diff --git a/frontend/src/utils/is-cloud-pc-device.js b/frontend/src/utils/is-cloud-pc-device.js new file mode 100644 index 0000000000..cda4c5259d --- /dev/null +++ b/frontend/src/utils/is-cloud-pc-device.js @@ -0,0 +1,13 @@ +// Windows 365 Cloud PCs never report BitLocker (isEncrypted stays false) although their disks +// are platform-encrypted by Azure, so encryption reporting must not flag them as unencrypted. +// Mirrors the backend Test-CIPPCloudPCDevice check: the cached CIPP marker, the documented +// deviceType signal (chassisType kept in case the service starts emitting it), then the +// model/manufacturer pair Windows 365 provisions. +export const isCloudPcDevice = (device) => + device?.isCloudPC === true || + device?.deviceType === 'cloudPC' || + device?.chassisType === 'cloudPC' || + (typeof device?.model === 'string' && + device.model.toLowerCase().startsWith('cloud pc') && + typeof device?.manufacturer === 'string' && + device.manufacturer.toLowerCase() === 'microsoft corporation') From 10b3efac87f2a36a51113b6fda310c5c68874331 Mon Sep 17 00:00:00 2001 From: Brian Simpson <50429915+bmsimp@users.noreply.github.com> Date: Wed, 19 Aug 2026 08:32:31 -0500 Subject: [PATCH 185/226] docs(identity): add guest users page Documents the guest lifecycle dashboard added in 25dea1b7 and moved onto the report cache in 0831a04d7: the six lifecycle statuses and the order they resolve in, the summary cards and the status filters they drive, the table columns and the two row actions. Co-Authored-By: Claude Opus 5 (1M context) --- .../identity/administration/guest-users.md | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 docs/user-documentation/identity/administration/guest-users.md diff --git a/docs/user-documentation/identity/administration/guest-users.md b/docs/user-documentation/identity/administration/guest-users.md new file mode 100644 index 0000000000..97052acbab --- /dev/null +++ b/docs/user-documentation/identity/administration/guest-users.md @@ -0,0 +1,61 @@ +# Guest Users + +Every guest account in the tenant is listed here with a lifecycle status worked out from its invitation state and its sign-in activity, so guests who never accepted their invitation, or who stopped using the tenant long ago, can be found and dealt with in one place. + +A guest is classified as follows, with the first match winning: + +| Status | Meaning | +| ------------------ | -------------------------------------------------------------------------- | +| Disabled | The account is blocked from signing in. | +| Pending Acceptance | The invitation was sent but has not been redeemed. | +| Never Signed In | The guest has accepted, but no sign-in has ever been recorded. | +| Stale | The last recorded sign-in was 90 or more days ago. | +| Active | The guest has signed in within the last 90 days. | +| Unknown | Sign-in activity could not be read, so staleness cannot be worked out. | + +A disabled guest is reported as Disabled whether or not it ever accepted the invitation. + +## Guest Status Summary + +A row of cards above the table shows how many guests fall into each status, alongside a total. Clicking a status card filters the table to that status and outlines the card, clicking it again clears the filter, and clicking **Total Guests** clears it as well. + +Guests with a status of Unknown are counted in **Total Guests** but have no card of their own. + +## Filters + +| Filter | Shows | +| ---------------------------- | ----------------------------------------------------------- | +| Active guests | Guests who have signed in within the last 90 days. | +| Stale guests | Guests whose last sign-in was 90 or more days ago. | +| Pending Acceptance guests | Guests who have not yet redeemed their invitation. | +| Never Signed In guests | Guests who accepted but have never signed in. | +| Disabled guests | Guest accounts that are blocked from signing in. | + +## Table Details + +| Column | Description | +| -------------------- | ----------------------------------------------------------------------------------------------------------------------- | +| Display Name | The name the guest appears under in the directory. | +| Mail | The external email address the invitation was sent to. | +| Source Domain | The domain part of that address, which groups guests by the organisation they come from. | +| Status | The lifecycle status described above. | +| Account Enabled | Whether the account is allowed to sign in. | +| Created Date Time | When the guest account was created in the tenant. | +| Last Sign In Date Time | The most recent sign-in recorded for the guest, taking the latest of its interactive, non-interactive and successful sign-in times. | +| Days Since Sign In | How many days have passed since that sign-in. Empty where no sign-in has been recorded. | + +The flyout adds the user principal name, the object ID, the raw invitation state and when it last changed, each of the three sign-in times separately, and any sponsors recorded against the guest. + +## Table Actions + +
    ActionDescriptionBulk Action Available
    View UserOpens the user page for the selected guest.false
    Re-invite GuestSends the guest invitation email again, pointing the guest at the My Apps portal. Greyed out for guests with no email address, and for any status other than Pending Acceptance or Stale.true
    More InfoOpens the Extended Info flyout with the full details for the selected row.false
    + +{% hint style="warning" %} +Reading sign-in activity needs an Entra ID P1 licence in the tenant. Without it, only Disabled and Pending Acceptance can be determined and every other guest is reported as Unknown, so an Unknown-heavy table means the licensing is missing rather than that the guests are inactive. +{% endhint %} + +{% hint style="info" %} +This page supports syncing under AllTenants, which queues a background refresh for every tenant rather than just the selected one. +{% endhint %} + +{% include "../../../../.gitbook/includes/feature-request.md" %} From 33227aa5772f39f3c43a968c5e883b07888f4bc2 Mon Sep 17 00:00:00 2001 From: Brian Simpson <50429915+bmsimp@users.noreply.github.com> Date: Wed, 19 Aug 2026 08:34:40 -0500 Subject: [PATCH 186/226] docs(identity): add guest users to the nav Adds the SUMMARY entry for the guest users page, placed between Users and Risky Users to mirror the order of the sidebar in the app. Co-Authored-By: Claude Opus 5 (1M context) --- docs/SUMMARY.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index 28aa108c1b..79a9cef040 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -111,6 +111,7 @@ * [Compromise Remediation](user-documentation/identity/administration/users/user/bec.md) * [Conditional Access](user-documentation/identity/administration/users/user/conditional-access.md) * [Edit Properties Wizard](user-documentation/identity/administration/users/patch-wizard.md) + * [Guest Users](user-documentation/identity/administration/guest-users.md) * [Risky Users](user-documentation/identity/administration/risky-users.md) * [Groups](user-documentation/identity/administration/groups/README.md) * [Add Group](user-documentation/identity/administration/groups/add.md) From 47644a1e9c8c1ae0ad985d197f77f0d6d3e0f5e8 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Wed, 19 Aug 2026 21:52:21 +0800 Subject: [PATCH 187/226] fix(auth): ReadWrite rule grants implied Read permission When a role rule grants X.ReadWrite, it now also matches X.Read endpoints in the permission universe. Previously, objects that only declare a .Read endpoint (e.g. Endpoint.Device) would silently lose access when the role was configured with the .ReadWrite variant. --- .../Get-CIPPRolePermissions.ps1 | 14 +++++- .../Private/Get-CIPPRolePermissions.Tests.ps1 | 49 +++++++++++++++++-- 2 files changed, 59 insertions(+), 4 deletions(-) diff --git a/backend/Modules/CIPPCore/Public/Authentication/Get-CIPPRolePermissions.ps1 b/backend/Modules/CIPPCore/Public/Authentication/Get-CIPPRolePermissions.ps1 index 484df05cfe..c06f70df5c 100644 --- a/backend/Modules/CIPPCore/Public/Authentication/Get-CIPPRolePermissions.ps1 +++ b/backend/Modules/CIPPCore/Public/Authentication/Get-CIPPRolePermissions.ps1 @@ -43,8 +43,20 @@ function Get-CIPPRolePermissions { $Expanded = [System.Collections.Generic.List[string]]::new() foreach ($Permission in $Universe) { $Allowed = $false + # ReadWrite implies Read: a rule that grants X.ReadWrite also grants the + # corresponding X.Read. Some objects only ever declare a .Read endpoint + # (e.g. Endpoint.Device), so a role granted the .ReadWrite variant would + # otherwise match nothing in the universe and silently lose all access. + $ReadCounterpart = if ($Permission -match '\.Read$') { + $Permission -replace '\.Read$', '.ReadWrite' + } else { + $null + } foreach ($Include in $Rules.Include) { - if ($Permission -like $Include) { $Allowed = $true; break } + if ($Permission -like $Include -or ($ReadCounterpart -and $ReadCounterpart -like $Include)) { + $Allowed = $true + break + } } if ($Allowed) { foreach ($Exclude in $Rules.Exclude) { diff --git a/backend/Tests/Private/Get-CIPPRolePermissions.Tests.ps1 b/backend/Tests/Private/Get-CIPPRolePermissions.Tests.ps1 index d52bcf399d..aeb30192bb 100644 --- a/backend/Tests/Private/Get-CIPPRolePermissions.Tests.ps1 +++ b/backend/Tests/Private/Get-CIPPRolePermissions.Tests.ps1 @@ -101,6 +101,48 @@ Describe 'Get-CIPPRolePermissions' { (Get-CIPPRolePermissions -RoleName 'frozen').Permissions | Should -Be @('Identity.User.Read') } + It 'grants the real .Read when a role includes a .ReadWrite the universe never declares' { + # Some objects only ever ship a .Read endpoint (e.g. Endpoint.Device), yet the role + # builder still offers a .ReadWrite toggle. A role granted that phantom .ReadWrite must + # still receive the .Read the endpoints actually check, or it loses all access. + Mock Get-CippHttpPermissions { @('Endpoint.Device.Read', 'Identity.User.Read', 'Identity.User.ReadWrite') } + Mock Get-CIPPAzDataTableEntity { + [PSCustomObject]@{ + RowKey = 'devicewriter' + Permissions = '{}' + PermissionRules = '{"Include":["Endpoint.Device.ReadWrite"],"Exclude":[]}' + } + } + + (Get-CIPPRolePermissions -RoleName 'devicewriter').Permissions | Should -Be @('Endpoint.Device.Read') + } + + It 'does not let a .Read include grant the .ReadWrite variant' { + # The implication is one-way. Read must never widen to ReadWrite. + Mock Get-CIPPAzDataTableEntity { + [PSCustomObject]@{ + RowKey = 'readeronly' + Permissions = '{}' + PermissionRules = '{"Include":["Identity.User.Read"],"Exclude":[]}' + } + } + + (Get-CIPPRolePermissions -RoleName 'readeronly').Permissions | Should -Be @('Identity.User.Read') + } + + It 'lets an explicit exclude on the .Read win over an implied ReadWrite grant' { + Mock Get-CippHttpPermissions { @('Endpoint.Device.Read', 'Identity.User.Read') } + Mock Get-CIPPAzDataTableEntity { + [PSCustomObject]@{ + RowKey = 'excluded' + Permissions = '{}' + PermissionRules = '{"Include":["*.ReadWrite"],"Exclude":["Endpoint.Device.Read"]}' + } + } + + (Get-CIPPRolePermissions -RoleName 'excluded').Permissions | Should -Be @('Identity.User.Read') + } + Context 'legacy rows without PermissionRules' { BeforeEach { Mock Get-CIPPAzDataTableEntity { @@ -111,10 +153,11 @@ Describe 'Get-CIPPRolePermissions' { } } - It 'synthesizes rules in memory and returns the same set the old code path produced' { + It 'synthesizes rules in memory, filters to the universe, and surfaces the implied .Read' { $Result = Get-CIPPRolePermissions -RoleName 'legacy' - # Old path: stored values filtered to the valid universe, None entries inert. - $Result.Permissions | Sort-Object | Should -Be @('CIPP.Core.Read', 'Identity.User.ReadWrite') + # Stored values filtered to the valid universe (Removed.Endpoint.Read dropped, None + # inert), plus the Identity.User.Read implied by the stored Identity.User.ReadWrite. + $Result.Permissions | Sort-Object | Should -Be @('CIPP.Core.Read', 'Identity.User.Read', 'Identity.User.ReadWrite') $Result.PermissionRules.Include | Should -Contain 'Identity.User.ReadWrite' $Result.PermissionRules.Include | Should -Not -Contain 'Identity.Device.None' } From 8cdcb627c3bf3f098bb0757ce6b55ac05652d833 Mon Sep 17 00:00:00 2001 From: Brian Simpson <50429915+bmsimp@users.noreply.github.com> Date: Wed, 19 Aug 2026 08:55:56 -0500 Subject: [PATCH 188/226] docs(teams-share): update sharing report scan behaviour Reflect three sharing-links scan fixes on the Sharing Report page: - scanning fans out per library, and an oversized library resumes by itself instead of stalling the tenant scan (ef20e301) - a library whose permission reads were throttled keeps its existing links and is read in full on the next sync (ef20e301) - the Preservation Hold Library is not scanned (ef20e301) - empty charts read "No data to display" instead of sitting in a loading state (0930bded) - locked sites now have their links pruned rather than kept, since a lock blocks sharing-link redemption, and they return once the lock is lifted (94b9e736) Co-Authored-By: Claude Opus 5 (1M context) --- docs/user-documentation/teams-share/sharing-report.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/user-documentation/teams-share/sharing-report.md b/docs/user-documentation/teams-share/sharing-report.md index 3cee5560f7..7a1b196f1b 100644 --- a/docs/user-documentation/teams-share/sharing-report.md +++ b/docs/user-documentation/teams-share/sharing-report.md @@ -14,11 +14,13 @@ The report reads from cached scans rather than querying SharePoint and OneDrive The time of the last completed scan is shown as "Last data refresh." -Results are written as the scan works through the tenant, so the report fills in site by site rather than staying empty until every drive has been read. Refreshing part-way through shows what has been scanned so far, and the totals settle once the scan finishes. +Results are written as the scan works through the tenant, so the report fills in library by library rather than staying empty until every drive has been read. Refreshing part-way through shows what has been scanned so far, and the totals settle once the scan finishes. -The first scan of a tenant reads every drive in full. Later syncs collect only what has changed since the previous one, so they finish considerably faster, and a full rescan is run periodically to catch anything a change-only pass would miss. A scan interrupted before it finishes, by a timeout on a large tenant for example, picks up where it stopped rather than starting the tenant over. +The first scan of a tenant reads every drive in full. Later syncs collect only what has changed since the previous one, so they finish considerably faster, and a full rescan is run periodically to catch anything a change-only pass would miss. A scan interrupted before it finishes, by a timeout on a large tenant for example, picks up where it stopped rather than starting the tenant over, and a library too large to read in one pass resumes by itself until it is done. -Sites that could not be read keep the links an earlier scan found for them rather than dropping out of the report, so those rows may be out of date. Links belonging to sites or libraries that no longer exist in the tenant are removed once the scan finishes. +Sites that could not be read keep the links an earlier scan found for them rather than dropping out of the report, so those rows may be out of date. The same applies to a library where Microsoft throttled the scan before every item could be read: its existing links are kept, and the library is read in full on the next sync. + +Links belonging to sites or libraries that no longer exist in the tenant are removed once the scan finishes, as are the links on a locked site: a lock blocks all access to that site's content, sharing links included, so those links no longer work. Locked sites are most often former employees' OneDrive accounts, and their links reappear at the next sync after the lock is lifted. The Preservation Hold Library is not scanned, as it holds retained copies that cannot be shared. ## Summary @@ -51,7 +53,7 @@ A row of headline counts sits above two cards that break the environment down fu ## Charts -Once a scan has data, the report charts the sharing links from several angles. The last two charts are added once usage data has been synced. +Once a scan has data, the report charts the sharing links from several angles. A chart with nothing to show, such as Top External Recipients on a tenant that has no external shares, reads "No data to display" rather than sitting in a loading state. The last two charts are added once usage data has been synced. | Chart | Shows | | ------------------------------ | ------------------------------------------------------------- | From 8ba85f3273a86c2896a815e30ffcf68c5d185992 Mon Sep 17 00:00:00 2001 From: Brian Simpson <50429915+bmsimp@users.noreply.github.com> Date: Wed, 19 Aug 2026 09:22:53 -0500 Subject: [PATCH 189/226] docs(roles): document operations requiring unrestricted tenant access Commit 00729896 gates four estate-wide operations behind an unrestricted tenant scope: adding a tenant through the Setup Wizard, custom data mapping writes, integration tenant and field mapping writes, and tenant group management. Adds a central note to the Allowed Tenants step of the roles guide, and a hint on each page where the restriction is hit. Co-Authored-By: Claude Opus 5 (1M context) --- docs/setup/setting-up-cipp/roles.md | 4 ++++ docs/user-documentation/cipp/custom-data/mappings/README.md | 4 ++++ docs/user-documentation/cipp/integrations/halopsa.md | 4 ++++ docs/user-documentation/cipp/integrations/hudu.md | 4 ++++ docs/user-documentation/cipp/integrations/ninjaone.md | 4 ++++ docs/user-documentation/cipp/integrations/sherweb.md | 4 ++++ docs/user-documentation/cipp/sam-setup-wizard.md | 4 ++++ .../tenant/administration/tenants/groups/README.md | 4 ++++ 8 files changed, 32 insertions(+) diff --git a/docs/setup/setting-up-cipp/roles.md b/docs/setup/setting-up-cipp/roles.md index 62a018acac..30f48b2752 100644 --- a/docs/setup/setting-up-cipp/roles.md +++ b/docs/setup/setting-up-cipp/roles.md @@ -103,6 +103,10 @@ For Allowed Tenants select a subset of tenants to manage, tenant groups, or AllT {% hint style="info" %} If AllTenants is selected, you can block a subset of tenants or tenant groups using Blocked Tenants. {% endhint %} + +{% hint style="warning" %} +A handful of estate-wide operations are refused outright to a role that does not have unrestricted tenant access, meaning **Allowed Tenants** left as `AllTenants` with nothing in **Blocked Tenants**. These are adding a tenant through the Setup Wizard, creating, editing and deleting custom data mappings, saving an integration's tenant or field mapping, and creating, editing, deleting or re-running the rules of a tenant group. A restricted role can still open these pages, but is refused at the point it tries to save. +{% endhint %} {% endstep %} {% step %} diff --git a/docs/user-documentation/cipp/custom-data/mappings/README.md b/docs/user-documentation/cipp/custom-data/mappings/README.md index f95ea04804..a4f8d77e6b 100644 --- a/docs/user-documentation/cipp/custom-data/mappings/README.md +++ b/docs/user-documentation/cipp/custom-data/mappings/README.md @@ -4,6 +4,10 @@ Custom data mappings connect a source of information to a custom data attribute You need at least one directory extension or schema extension before a mapping can be created, because the mapping needs somewhere to write the value. +{% hint style="warning" %} +Creating, editing and deleting mappings requires a role with unrestricted tenant access, meaning **Allowed Tenants** left as `AllTenants` with nothing in **Blocked Tenants**. A role scoped to particular tenants or tenant groups is refused these actions, and the list it sees is narrowed to the mappings that target tenants within its scope. See [roles.md](../../../../setup/setting-up-cipp/roles.md "mention"). +{% endhint %} + ## Action Buttons {% content-ref url="add.md" %} diff --git a/docs/user-documentation/cipp/integrations/halopsa.md b/docs/user-documentation/cipp/integrations/halopsa.md index d4cd960859..e3c6bf7d2e 100644 --- a/docs/user-documentation/cipp/integrations/halopsa.md +++ b/docs/user-documentation/cipp/integrations/halopsa.md @@ -117,6 +117,10 @@ Some entries in the priority and outcome lists are guidance rows rather than rea The **Tenant Mapping** tab pairs each CIPP tenant with a Halo client. Alerts for an unmapped tenant have no client to be raised against, so mapping is required before the integration is useful. +{% hint style="warning" %} +Saving on the **Tenant Mapping** tab requires a role with unrestricted tenant access, meaning **Allowed Tenants** left as `AllTenants` with nothing in **Blocked Tenants**. A role scoped to particular tenants or tenant groups can read the existing mappings but is refused when it selects **Submit** or **Automap Companies**. See [roles.md](../../../setup/setting-up-cipp/roles.md "mention"). +{% endhint %} + To add a mapping manually, choose a tenant and a Halo client, then select the add button, and select **Submit** to save. Selecting **Automap Companies** matches automatically, and the refresh button reloads the client list from Halo. | Column | Description | diff --git a/docs/user-documentation/cipp/integrations/hudu.md b/docs/user-documentation/cipp/integrations/hudu.md index b3b90fd4c4..7d6e348116 100644 --- a/docs/user-documentation/cipp/integrations/hudu.md +++ b/docs/user-documentation/cipp/integrations/hudu.md @@ -95,6 +95,10 @@ Work through the **Tenant Mapping** and **Field Mapping** tabs described below. The **Tenant Mapping** tab pairs each CIPP tenant with a Hudu company. Only mapped tenants are synchronised, and mapping a tenant is what causes CIPP to schedule its daily synchronisation. +{% hint style="warning" %} +Saving on the **Tenant Mapping** and **Field Mapping** tabs requires a role with unrestricted tenant access, meaning **Allowed Tenants** left as `AllTenants` with nothing in **Blocked Tenants**. A role scoped to particular tenants or tenant groups can read the existing mappings but is refused when it selects **Submit** or **Automap Companies**. See [roles.md](../../../setup/setting-up-cipp/roles.md "mention"). +{% endhint %} + To map manually, choose a tenant, choose the matching entry under **Select Hudu Company**, and select the add button. **Automap Companies** fills in matches automatically, and the refresh button reloads the company list from Hudu. Mappings are only written when you select **Submit**. | Column | Description | diff --git a/docs/user-documentation/cipp/integrations/ninjaone.md b/docs/user-documentation/cipp/integrations/ninjaone.md index 47b76439d3..1c02cb5d0b 100644 --- a/docs/user-documentation/cipp/integrations/ninjaone.md +++ b/docs/user-documentation/cipp/integrations/ninjaone.md @@ -105,6 +105,10 @@ Work through the **Tenant Mapping** and **Field Mapping** tabs described below. The **Tenant Mapping** tab pairs each CIPP tenant with a NinjaOne organisation. Only mapped tenants are synchronised, so this is what determines the scope of the integration. +{% hint style="warning" %} +Saving on the **Tenant Mapping** and **Field Mapping** tabs requires a role with unrestricted tenant access, meaning **Allowed Tenants** left as `AllTenants` with nothing in **Blocked Tenants**. A role scoped to particular tenants or tenant groups can read the existing mappings but is refused when it selects **Submit** or **Automap Companies**. See [roles.md](../../../setup/setting-up-cipp/roles.md "mention"). +{% endhint %} + To map manually, choose a tenant, choose the NinjaOne organisation under **Select NinjaOne Company**, and select the add button. **Automap Companies** matches automatically. Mappings are only written when you select **Submit**. | Column | Description | diff --git a/docs/user-documentation/cipp/integrations/sherweb.md b/docs/user-documentation/cipp/integrations/sherweb.md index 120239adc7..9c7513f1e9 100644 --- a/docs/user-documentation/cipp/integrations/sherweb.md +++ b/docs/user-documentation/cipp/integrations/sherweb.md @@ -90,6 +90,10 @@ The role restriction applies to subscription changes made through CIPP by a sign The **Tenant Mapping** tab pairs each CIPP tenant with a Sherweb customer, so CIPP knows which Sherweb account to place orders against. Licence purchasing and automated migrations both depend on this, and an unmapped tenant is simply skipped. +{% hint style="warning" %} +Saving on the **Tenant Mapping** tab requires a role with unrestricted tenant access, meaning **Allowed Tenants** left as `AllTenants` with nothing in **Blocked Tenants**. A role scoped to particular tenants or tenant groups can read the existing mappings but is refused when it selects **Submit** or **Automap Companies**. See [roles.md](../../../setup/setting-up-cipp/roles.md "mention"). +{% endhint %} + To map manually, choose a tenant, choose the matching entry under **Select Sherweb Company**, and select the add button. **Automap Companies** fills in matches automatically, and the refresh button reloads the customer list from Sherweb. Mappings are only written when you select **Submit**. | Column | Description | diff --git a/docs/user-documentation/cipp/sam-setup-wizard.md b/docs/user-documentation/cipp/sam-setup-wizard.md index 4a8dd58b26..15f8534fad 100644 --- a/docs/user-documentation/cipp/sam-setup-wizard.md +++ b/docs/user-documentation/cipp/sam-setup-wizard.md @@ -18,6 +18,10 @@ Choose **Add a tenant** to bring a new tenant into an existing CIPP deployment. Detailed steps for the GDAP and direct paths are in [gdap-invite-wizard.md](../../setup/installation/gdap-invite-wizard.md "mention"). +{% hint style="warning" %} +Adding a tenant requires a role with unrestricted tenant access, meaning **Allowed Tenants** left as `AllTenants` with nothing in **Blocked Tenants**. A role scoped to particular tenants or tenant groups can reach the wizard but is refused at the point the tenant is saved. See [roles.md](../../setup/setting-up-cipp/roles.md "mention"). +{% endhint %} + ### Add GDAP Tenant For Microsoft CSP partners. This walks you through creating a GDAP relationship, selecting the admin roles to request, and generating an invite link to send to the customer. diff --git a/docs/user-documentation/tenant/administration/tenants/groups/README.md b/docs/user-documentation/tenant/administration/tenants/groups/README.md index 5ca89a0956..439ce171a9 100644 --- a/docs/user-documentation/tenant/administration/tenants/groups/README.md +++ b/docs/user-documentation/tenant/administration/tenants/groups/README.md @@ -2,6 +2,10 @@ Lists your custom tenant groups and gives you the tools to create and maintain them. Tenant groups are logical groupings of managed tenants that can be selected anywhere CIPP asks for a tenant filter, which saves you picking the same set of tenants by hand every time. A group is either static, where you choose its members explicitly, or dynamic, where CIPP evaluates a set of rules against your tenants and works out the membership for you. +{% hint style="warning" %} +Creating, editing and deleting a group, and re-running a dynamic group's rules, all require a role with unrestricted tenant access, meaning **Allowed Tenants** left as `AllTenants` with nothing in **Blocked Tenants**. A role scoped to particular tenants or tenant groups is refused these actions. See [roles.md](../../../../../setup/setting-up-cipp/roles.md "mention"). +{% endhint %} + ## Action Buttons
    From b09037d33ac2fe1eee0daaa5f38c05fd2a9eef74 Mon Sep 17 00:00:00 2001 From: Brian Simpson <50429915+bmsimp@users.noreply.github.com> Date: Wed, 19 Aug 2026 09:30:41 -0500 Subject: [PATCH 190/226] docs(users): document duplicate username warning on Add User Covers the warning added in ca0f6a4c, which appears below the domain selector when the username and primary domain match an existing user principal name or email alias. Notes that it does not block creation and that its absence is not confirmation the address is free. Co-Authored-By: Claude Opus 5 (1M context) --- .../identity/administration/users/README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/user-documentation/identity/administration/users/README.md b/docs/user-documentation/identity/administration/users/README.md index 80a0e21cbd..d7b3ad2043 100644 --- a/docs/user-documentation/identity/administration/users/README.md +++ b/docs/user-documentation/identity/administration/users/README.md @@ -32,6 +32,10 @@ Creates a single user in the selected tenant. **Create User** submits the form, | Primary Domain name | The domain used after the @ symbol, chosen from the tenant's verified domains. | | Add Aliases | Additional addresses, one per line, entered without the domain. | +{% hint style="warning" %} +If the username and Primary Domain name together match an existing account's user principal name or one of its email aliases, a warning appears below the domain selector naming that account. It does not block creation, and it is checked against the user list already loaded for the tenant, so a match can go unreported. No warning is not confirmation that the address is free. +{% endhint %} + **Settings** | Setting | Description | From aa53ad9d455ba6b2396b222a0d3d4556d7e777c9 Mon Sep 17 00:00:00 2001 From: Brian Simpson <50429915+bmsimp@users.noreply.github.com> Date: Wed, 19 Aug 2026 09:37:03 -0500 Subject: [PATCH 191/226] docs(offboarding-wizard): document Send As and Send on Behalf grants Add the two new mailbox access fields introduced in a0356d8a, correct the OneDrive label casing to match the UI, and record that selecting Delete user greys out the mailbox access, forwarding and out of office settings. Co-Authored-By: Claude Opus 5 (1M context) --- .../identity/administration/offboarding-wizard.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/docs/user-documentation/identity/administration/offboarding-wizard.md b/docs/user-documentation/identity/administration/offboarding-wizard.md index 8c9af4d17d..fb92bdc5ac 100644 --- a/docs/user-documentation/identity/administration/offboarding-wizard.md +++ b/docs/user-documentation/identity/administration/offboarding-wizard.md @@ -74,7 +74,9 @@ Converting a mailbox that is at or near 50 GB may fail, and a converted mailbox | ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------- | | Grant Full Access (no automap) | Gives the selected users full access to the mailbox without Outlook adding it automatically. | | Grant Full Access (automap) | Gives full access and lets Outlook add the mailbox on its own. | -| Grant Onedrive Full Access | Gives the selected users full access to the user's OneDrive. | +| Grant Send As Access | Lets the selected users send mail as the offboarded user, so it appears to come from them. | +| Grant Send on Behalf Access | Lets the selected users send mail on the offboarded user's behalf, which recipients see as sent by them on behalf of the leaver. | +| Grant OneDrive Full Access | Gives the selected users full access to the user's OneDrive. | | Disable Email Forwarding | Clears any forwarding already set on the mailbox. Turning this on empties the forwarding fields below, since the two work against each other. | | Forward Email To | The recipient the user's mail is forwarded to. | | Keep a copy of forwarded mail | Delivers the message to the offboarded mailbox as well as forwarding it. | @@ -84,6 +86,10 @@ Converting a mailbox that is at or near 50 GB may fail, and a converted mailbox When the account is being deleted, its OneDrive is retained for 30 days by default, so granting OneDrive access is still worth doing if the contents may be needed. {% endhint %} +{% hint style="info" %} +Selecting **Delete user** greys out the mailbox access, forwarding and out of office settings, since the mailbox goes with the account. OneDrive access stays available. +{% endhint %} + ## Scheduling & Notifications | Setting | Description | From 0e93789eb6de365f962cc3e2a5645a45a344e6c3 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Wed, 19 Aug 2026 22:44:52 +0800 Subject: [PATCH 192/226] fix(standards): guard group template against duplicate creation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When Graph or Exchange reads failed, the GroupTemplate standard treated the empty result as 'tenant has no groups' and recreated every templated group on each run. Entra permits duplicate displayNames, so each failed read silently produced twins (2→4→6…). Adds try/catch guards around both the Graph groups read and the Exchange dynamic distribution group read, logging an error and returning early on failure rather than proceeding with an empty baseline. Also adds Pester tests covering the happy path (existing group not recreated, genuinely empty tenant creates group) and the failure paths (Graph failure, Exchange failure, report mode). --- .../Invoke-CIPPStandardGroupTemplate.ps1 | 17 +- ...Invoke-CIPPStandardGroupTemplate.Tests.ps1 | 148 ++++++++++++++++++ 2 files changed, 162 insertions(+), 3 deletions(-) create mode 100644 backend/Tests/Standards/Invoke-CIPPStandardGroupTemplate.Tests.ps1 diff --git a/backend/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardGroupTemplate.ps1 b/backend/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardGroupTemplate.ps1 index de45554c3c..562bb79e29 100644 --- a/backend/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardGroupTemplate.ps1 +++ b/backend/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardGroupTemplate.ps1 @@ -35,7 +35,13 @@ function Invoke-CIPPStandardGroupTemplate { #> param($Tenant, $Settings) - $existingGroups = New-GraphGETRequest -uri 'https://graph.microsoft.com/beta/groups?$top=999&$select=id,displayName,description,membershipRule' -tenantid $tenant + try { + $existingGroups = New-GraphGETRequest -uri 'https://graph.microsoft.com/beta/groups?$top=999&$select=id,displayName,description,membershipRule' -tenantid $tenant -ErrorAction Stop + } catch { + $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message + Write-LogMessage -API 'Standards' -tenant $tenant -message "Group Template: could not read the tenant's existing groups, skipping this run to avoid creating duplicate groups. Error: $ErrorMessage" -sev 'Error' + return + } $Settings.groupTemplate ? ($Settings | Add-Member -NotePropertyName 'TemplateList' -NotePropertyValue $Settings.groupTemplate) : $null @@ -44,8 +50,13 @@ function Invoke-CIPPStandardGroupTemplate { $GroupTemplates = (Get-CIPPAzDataTableEntity @Table -Filter $Filter).JSON | ConvertFrom-Json if ('dynamicDistribution' -in $GroupTemplates.groupType) { - # Get dynamic distro list from exchange - $DynamicDistros = New-ExoRequest -cmdlet 'Get-DynamicDistributionGroup' -tenantid $tenant -Select 'Identity,Name,Alias,RecipientFilter,PrimarySmtpAddress' + try { + $DynamicDistros = New-ExoRequest -cmdlet 'Get-DynamicDistributionGroup' -tenantid $tenant -Select 'Identity,Name,Alias,RecipientFilter,PrimarySmtpAddress' -ErrorAction Stop + } catch { + $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message + Write-LogMessage -API 'Standards' -tenant $tenant -message "Group Template: could not read the tenant's existing dynamic distribution groups, skipping this run to avoid creating duplicate groups. Error: $ErrorMessage" -sev 'Error' + return + } } if ($Settings.remediate -eq $true) { diff --git a/backend/Tests/Standards/Invoke-CIPPStandardGroupTemplate.Tests.ps1 b/backend/Tests/Standards/Invoke-CIPPStandardGroupTemplate.Tests.ps1 new file mode 100644 index 0000000000..0d57c7b166 --- /dev/null +++ b/backend/Tests/Standards/Invoke-CIPPStandardGroupTemplate.Tests.ps1 @@ -0,0 +1,148 @@ +# Pester tests for Invoke-CIPPStandardGroupTemplate +# +# Covers the run-to-run duplication reported when the standard could not read the tenant's +# current groups: the existing-groups read returning nothing (unauthorised / transient +# wrong-tenant context) used to be indistinguishable from "the tenant has no groups", so the +# standard recreated every templated group on every run. Entra allows duplicate displayNames, +# so each missed match silently produced a twin (2 -> 4 -> 6 ...). The guard must create groups +# only when the read genuinely succeeds with no matching group present. + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + # Resolve by name under Modules/ so the test survives the function moving between modules. + $StandardPath = Get-ChildItem -Path (Join-Path $RepoRoot 'Modules') -Recurse -Filter 'Invoke-CIPPStandardGroupTemplate.ps1' -File -ErrorAction SilentlyContinue | + Select-Object -First 1 -ExpandProperty FullName + if (-not $StandardPath) { throw 'Could not locate Invoke-CIPPStandardGroupTemplate.ps1 under Modules/' } + + # Stubs mirror the real signatures and are advanced functions on purpose: strict parameter + # binding makes signature drift in the standard fail loudly here instead of silently landing + # in $args. + function New-GraphGetRequest { [CmdletBinding()] param($uri, $tenantid, $scope, $AsApp, $noPagination, $NoAuthCheck, $skipTokenCache, $Caller, [switch]$ComplexFilter, [switch]$CountOnly) } + function New-GraphPostRequest { [CmdletBinding()] param($uri, $tenantid, $type, $body, $scope, $AsApp, $NoAuthCheck, $skipTokenCache) } + function New-ExoRequest { [CmdletBinding()] param($tenantid, $cmdlet, $cmdParams, $Select, $Anchor, $useSystemMailbox) } + function New-CIPPGroup { [CmdletBinding()] param($GroupObject, $TenantFilter, $APIName, $ExecutingUser) } + function Test-CIPPStandardLicense { [CmdletBinding()] param($StandardName, $TenantFilter, $Preset, [switch]$SkipLog) } + function Get-CippTable { [CmdletBinding()] param($tablename) } + function Get-CIPPAzDataTableEntity { [CmdletBinding()] param($Filter, $Property, $First) } + function Set-CIPPStandardsCompareField { [CmdletBinding()] param($FieldName, $CurrentValue, $ExpectedValue, $TenantFilter) } + function Write-LogMessage { [CmdletBinding()] param($API, $tenant, $message, $sev, $headers, $LogData, $User) } + function Get-NormalizedError { [CmdletBinding()] param($Message) $Message } + + . $StandardPath + + # Script scope: Pester 5 evaluates the Describe body at discovery, so plain variables declared + # there are not in scope inside It blocks or mocks at run time. + $script:Tenant = 'contoso.onmicrosoft.com' + $script:GroupName = 'CIPP-Test-Group' + + # A single generic (Graph) group template, stored the way Invoke-AddGroupTemplate persists it. + function script:New-GenericTemplateEntity { + [pscustomobject]@{ + JSON = ([pscustomobject]@{ + displayName = $script:GroupName + description = 'Test description' + groupType = 'generic' + membershipRules = $null + GUID = '11111111-1111-1111-1111-111111111111' + } | ConvertTo-Json -Depth 10) + } + } + + # A dynamic distribution template - presence is checked against Exchange, not Graph. + function script:New-DynamicDistroTemplateEntity { + [pscustomobject]@{ + JSON = ([pscustomobject]@{ + displayName = $script:GroupName + description = 'Test description' + groupType = 'dynamicDistribution' + membershipRules = "Alias -ne `$null" + GUID = '22222222-2222-2222-2222-222222222222' + } | ConvertTo-Json -Depth 10) + } + } + + function script:New-Settings { + param([switch]$Remediate, [switch]$Report) + [pscustomobject]@{ + remediate = [bool]$Remediate + report = [bool]$Report + groupTemplate = [pscustomobject]@{ value = '11111111-1111-1111-1111-111111111111' } + } + } +} + +Describe 'Invoke-CIPPStandardGroupTemplate' { + BeforeEach { + $script:logs = [System.Collections.Generic.List[object]]::new() + + Mock -CommandName Get-CippTable -MockWith { @{} } + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { script:New-GenericTemplateEntity } + Mock -CommandName Test-CIPPStandardLicense -MockWith { $true } + Mock -CommandName New-GraphPostRequest -MockWith { $null } + Mock -CommandName New-CIPPGroup -MockWith { [pscustomobject]@{ Success = $true; GroupId = 'new-group-id' } } + Mock -CommandName Set-CIPPStandardsCompareField -MockWith { } + Mock -CommandName Write-LogMessage -MockWith { + param($API, $tenant, $message, $sev) + $script:logs.Add(@{ Message = $message; Sev = $sev }) + } + } + + Context 'existing groups can be read' { + It 'does not recreate a group that already exists in the tenant' { + Mock -CommandName New-GraphGetRequest -MockWith { + @([pscustomobject]@{ id = 'existing-id'; displayName = $script:GroupName; description = 'Test description'; membershipRule = $null }) + } + + Invoke-CIPPStandardGroupTemplate -Tenant $script:Tenant -Settings (script:New-Settings -Remediate) + + Should -Invoke -CommandName New-CIPPGroup -Times 0 -Exactly -Because 'the group already exists, so creating it would make a duplicate' + } + + It 'creates the group when the tenant genuinely has none' { + Mock -CommandName New-GraphGetRequest -MockWith { @() } + + Invoke-CIPPStandardGroupTemplate -Tenant $script:Tenant -Settings (script:New-Settings -Remediate) + + Should -Invoke -CommandName New-CIPPGroup -Times 1 -Exactly -Because 'an empty read with no error is a real empty tenant' + } + } + + Context 'existing groups cannot be read' { + It 'creates no groups when the Graph read fails, avoiding duplicate twins' { + Mock -CommandName New-GraphGetRequest -MockWith { throw 'Request not authorised for tenant' } + + { Invoke-CIPPStandardGroupTemplate -Tenant $script:Tenant -Settings (script:New-Settings -Remediate) } | + Should -Not -Throw + + Should -Invoke -CommandName New-CIPPGroup -Times 0 -Exactly -Because 'a failed read must not be treated as "no groups exist"' + + $Errors = @($script:logs | Where-Object { $_.Sev -eq 'Error' }) + $Errors.Count | Should -BeGreaterThan 0 + $Errors[0].Message | Should -Match 'skipping this run to avoid creating duplicate groups' + } + + It 'does not overwrite the compliance report as all-missing when the read fails' { + Mock -CommandName New-GraphGetRequest -MockWith { throw 'Request not authorised for tenant' } + + Invoke-CIPPStandardGroupTemplate -Tenant $script:Tenant -Settings (script:New-Settings -Report) + + Should -Invoke -CommandName Set-CIPPStandardsCompareField -Times 0 -Exactly -Because 'reporting every group as missing on a failed read produces false drift' + } + + It 'creates no dynamic distribution groups when the Exchange read fails' { + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { script:New-DynamicDistroTemplateEntity } + # The Graph read succeeds (empty) but the Exchange read for dynamic distros fails. + Mock -CommandName New-GraphGetRequest -MockWith { @() } + Mock -CommandName New-ExoRequest -MockWith { throw 'Exchange is unavailable' } + + { Invoke-CIPPStandardGroupTemplate -Tenant $script:Tenant -Settings (script:New-Settings -Remediate) } | + Should -Not -Throw + + Should -Invoke -CommandName New-CIPPGroup -Times 0 -Exactly -Because 'a failed Exchange read must not be treated as "no dynamic distribution groups exist"' + + $Errors = @($script:logs | Where-Object { $_.Sev -eq 'Error' }) + $Errors.Count | Should -BeGreaterThan 0 + $Errors[0].Message | Should -Match 'skipping this run to avoid creating duplicate groups' + } + } +} From 325b40a9befce1add1ccc6cd74085df3faa97dc0 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Wed, 19 Aug 2026 22:45:36 +0800 Subject: [PATCH 193/226] fix(standards): improve spam filter policy resolution Replace the legacy name-list policy lookup with a two-pass resolution: prefer an exact name match, then fall back to the built-in default policy (via IsDefault flag or 'Default' name) when the configured name is one of the known portal/cmdlet aliases. Also detect the built-in policy via IsDefault rather than relying solely on the 'Default' name string. --- backend/Config/openapi.json | 6 +++- .../Invoke-CIPPStandardSpamFilterPolicy.ps1 | 33 ++++++++++++------- 2 files changed, 27 insertions(+), 12 deletions(-) diff --git a/backend/Config/openapi.json b/backend/Config/openapi.json index cc8645126f..cc6c58470b 100644 --- a/backend/Config/openapi.json +++ b/backend/Config/openapi.json @@ -18563,7 +18563,8 @@ "enum": [ "createDeviceLogCollectionRequest", "setDeviceName", - "users" + "users", + "wipe" ], "description": "Interact with Body parameters or the body of the request." }, @@ -18577,6 +18578,9 @@ }, "description": "limit to 15 characters" }, + "macOsUnlockCode": { + "type": "string" + }, "tenantFilter": { "type": "string" }, diff --git a/backend/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardSpamFilterPolicy.ps1 b/backend/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardSpamFilterPolicy.ps1 index 6be274bd90..08ca43604c 100644 --- a/backend/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardSpamFilterPolicy.ps1 +++ b/backend/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardSpamFilterPolicy.ps1 @@ -93,21 +93,32 @@ function Invoke-CIPPStandardSpamFilterPolicy { return } - # Only match against legacy/default names when no custom name is provided. When a custom name is - # set, deploy it as a new policy instead of reusing an existing default-named one. 'Default' is - # Microsoft's built-in inbound anti-spam policy ("Anti-spam inbound policy" in the portal); it - # cannot be renamed and has no associated rule. - if ($PolicyName -eq $DefaultPolicyName) { - $PolicyList = @($PolicyName, 'Default Spam Filter Policy', 'Default') - $ExistingPolicy = $AllSpamFilterPolicies | Where-Object -Property Name -In $PolicyList | Select-Object -First 1 - if ($null -ne $ExistingPolicy.Name) { - # Use existing policy name if found - $PolicyName = $ExistingPolicy.Name + # Resolve which policy this standard manages. An exact name match always wins, so a tenant that + # already has a CIPP-created policy keeps using it. Otherwise, when the configured name is one of the + # aliases for Microsoft's built-in inbound anti-spam policy, adopt that built-in policy instead of + # creating a duplicate: Get-HostedContentFilterPolicy returns it named 'Default', while the Defender + # portal labels it "Anti-spam inbound policy" and older CIPP builds used "Default Spam Filter Policy". + # Customers targeting the built-in policy commonly enter any of these (the same rename workaround that + # works for the other Default* Defender standards, where the cmdlet name and portal name match). Any + # other value is a genuinely custom policy and is created as new. + $DefaultPolicyNames = @($DefaultPolicyName, 'Default Spam Filter Policy', 'Default', 'Anti-spam inbound policy') + $ExistingPolicy = $AllSpamFilterPolicies | Where-Object -Property Name -EQ $PolicyName | Select-Object -First 1 + if ($null -eq $ExistingPolicy -and $PolicyName -in $DefaultPolicyNames) { + # No policy is literally named e.g. "Anti-spam inbound policy" - that is only the portal label. + # Fall back to the built-in default policy, identified by its IsDefault flag (or its 'Default' + # name if the flag is unavailable). + $ExistingPolicy = $AllSpamFilterPolicies | Where-Object { $_.IsDefault -eq $true } | Select-Object -First 1 + if ($null -eq $ExistingPolicy) { + $ExistingPolicy = $AllSpamFilterPolicies | Where-Object -Property Name -EQ 'Default' | Select-Object -First 1 } } + if ($null -ne $ExistingPolicy.Name) { + # Adopt the existing policy's real name so state comparison and remediation target it. + $PolicyName = $ExistingPolicy.Name + } # The built-in default policy cannot have a HostedContentFilterRule, so rule remediation is skipped for it. - $IsDefaultPolicy = $PolicyName -eq 'Default' + $IsDefaultPolicy = ($ExistingPolicy.IsDefault -eq $true) -or ($PolicyName -eq 'Default') $CurrentState = $AllSpamFilterPolicies | Where-Object -Property Name -EQ $PolicyName From 1225d5293ef640d66457a09f71663b07d7d5d7af Mon Sep 17 00:00:00 2001 From: Brian Simpson <50429915+bmsimp@users.noreply.github.com> Date: Wed, 19 Aug 2026 09:46:48 -0500 Subject: [PATCH 194/226] docs(message-encryption): correct IRM field descriptions and align to house style Re-grounded the Current Configuration table against Set-IRMConfiguration: - Internal Licensing Enabled: in Exchange Online this setting covers external recipients as well, and is on by default. The old wording implied internal only. - External Licensing Enabled: the parameter is on-premises Exchange only, so it carries no meaning for a cloud-only tenant. Now says so. - Transport Decryption: explain what Disabled, Optional and Mandatory do, and that Optional is the default, instead of just listing the values. - Purview Message Encryption: describe it as the tenant connecting directly to Azure Rights Management. Corrected the Actions section: Sender and Recipient are mailbox pickers sourced from ListMailboxes, not free-text fields, and the button is greyed out rather than hidden. Documented the tenant-switch reset. House style: dropped the divider before the closing include, "licenses" to "licences" as a noun, converted the HTML table to the markdown Action/Description form used elsewhere for non-table pages, and put the headings into Title Case to match the corpus. Co-Authored-By: Claude Opus 5 (1M context) --- .../tools/email-tools/message-encryption.md | 37 +++++++++++-------- 1 file changed, 21 insertions(+), 16 deletions(-) diff --git a/docs/user-documentation/tools/email-tools/message-encryption.md b/docs/user-documentation/tools/email-tools/message-encryption.md index c9fe2198da..82b07d86c1 100644 --- a/docs/user-documentation/tools/email-tools/message-encryption.md +++ b/docs/user-documentation/tools/email-tools/message-encryption.md @@ -8,32 +8,37 @@ The only prerequisite for Purview Message Encryption is that Azure Rights Manage ## Current Configuration -| Field | Description | -| ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------ | -| Purview Message Encryption | Whether Azure RMS licensing is enabled. This is the switch that makes message encryption available to the tenant. | -| Internal Licensing Enabled | Whether IRM features are enabled for messages sent to internal recipients. | -| External Licensing Enabled | Whether Exchange tries to acquire licenses from clusters other than the one it is configured to use. | -| Protect Button in Outlook on the Web | Whether the Protect button is shown in Outlook on the web. Defaults to disabled. | -| Transport Decryption | Whether transport decryption is Disabled, Optional, or Mandatory. | -| Journal Report Decryption | Whether a decrypted copy of a protected message is attached to the journal report. | -| Licensing Location | The RMS licensing URLs for the tenant. Used to work out whether the tenant is on Azure RMS or still on on-premises AD RMS. | - -## AD RMS migration warning +| Field | Description | +| ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Purview Message Encryption | Whether the tenant can connect directly to Azure Rights Management. This is the switch that makes message encryption available. | +| Internal Licensing Enabled | Whether IRM features are enabled for messages sent to internal recipients. In Exchange Online this setting covers external recipients as well, and is on by default. | +| External Licensing Enabled | Whether Exchange tries to acquire licences from clusters other than the one it is configured to use. This applies to on-premises Exchange only, so it carries no meaning for a cloud-only tenant. | +| Protect Button in Outlook on the Web | Whether the Protect button is shown in Outlook on the web. Defaults to disabled. | +| Transport Decryption | How protected mail is treated in transit. Disabled leaves it encrypted, Optional decrypts it where possible and delivers either way, and Mandatory rejects anything it cannot decrypt. Optional is the default. | +| Journal Report Decryption | Whether a decrypted copy of a protected message is attached to the journal report. | +| Licensing Location | The RMS licensing URLs for the tenant. Used to work out whether the tenant is on Azure RMS or still on on-premises AD RMS. | + +The card itself is read-only. Purview Message Encryption is the only one of these settings you can change from this page, using the switch below the card. + +## AD RMS Migration Warning Purview Message Encryption is **not compatible with Active Directory Rights Management Services (AD RMS)**. When the licensing location points at something other than an Azure RMS URL, the page shows a warning: that tenant is still using an on-premises AD RMS cluster and has to be [migrated to Azure RMS](https://learn.microsoft.com/en-us/azure/information-protection/migrate-from-ad-rms-to-azure-rms) before message encryption can be used. -The warning does not block the toggle, so you can still act on a tenant you know has already been migrated. The `Enable Purview Message Encryption` standard is stricter: it skips remediation entirely for these tenants and logs a warning instead, because it runs unattended. +The warning does not block the toggle, so you can still act on a tenant you know has already been migrated. The **Enable Purview Message Encryption** standard is stricter: it skips remediation entirely for these tenants and logs a warning instead, because it runs unattended. ## Actions -
    ActionDetails
    Enable Purview Message EncryptionToggles Azure RMS licensing for the tenant, then Submit applies it. This is the only setting this page writes.
    Run TestRuns a test against the tenant that acquires the RMS templates and verifies that encryption and decryption both work. Enter any mailbox in the tenant as both the sender and the recipient. The button stays disabled until both addresses are filled in.
    +| Action | Description | +| ------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Enable Purview Message Encryption (Azure RMS licensing) | Turns Azure RMS licensing on or off for the tenant. **Submit** applies the change. This is the only setting this page writes. | +| Run Test | Checks that RMS templates can be acquired and that encryption and decryption both work. Pick any mailbox in the tenant for **Sender** and for **Recipient**. The button is greyed out until both are chosen. | -## Rolling this out across tenants +Switching tenants clears the switch and both test addresses, so a value set for one tenant is never submitted against another. + +## Rolling This Out Across Tenants This page configures one tenant at a time. To deploy message encryption to many tenants and keep it that way, use the **Enable Purview Message Encryption** standard under Exchange Standards. In report mode it records the licensing state per tenant, including whether AD RMS was detected, which gives you the same pre-check across the whole estate without changing anything. Encrypted message branding, one-time passcodes, and social ID sign-in are configured separately, through the **Configure Encrypted Message Branding (OME)** standard. -*** - {% include "../../../../.gitbook/includes/feature-request.md" %} From 98d45ca5008074338b6c8cd3b70b3381ec36cfdc Mon Sep 17 00:00:00 2001 From: Brian Simpson <50429915+bmsimp@users.noreply.github.com> Date: Wed, 19 Aug 2026 09:54:18 -0500 Subject: [PATCH 195/226] docs(endpoint): document MDE offboarding and macOS wipe device actions Adds the two new Intune device actions to the Devices page Table Actions table, in the order they appear in the actions array: - Offboard from Defender for Endpoint (76489ea), Windows only, covering the Entra device ID match and the cases that report an error instead - Wipe Device (7d6a6a9), macOS only, covering the optional Recovery PIN and the Intel Mac without T2 caveat Also mentions Defender for Endpoint offboarding in the page intro. Co-Authored-By: Claude Opus 5 (1M context) --- docs/user-documentation/endpoint/mem/devices/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/user-documentation/endpoint/mem/devices/README.md b/docs/user-documentation/endpoint/mem/devices/README.md index b148bd3ecc..7dbabdbc3c 100644 --- a/docs/user-documentation/endpoint/mem/devices/README.md +++ b/docs/user-documentation/endpoint/mem/devices/README.md @@ -4,7 +4,7 @@ description: Manage Intune devices across your Microsoft 365 tenants. # Devices -The Devices page lists the devices managed by Intune in the selected tenant, and is where most day-to-day device management is carried out. From here you can sync, rename, reboot, and locate devices, retrieve recovery keys, local admin passwords and BIOS passwords, run Defender scans, reset or wipe a device, add devices to groups, and remove devices from management. Which actions are available for a given device depends on its operating system. +The Devices page lists the devices managed by Intune in the selected tenant, and is where most day-to-day device management is carried out. From here you can sync, rename, reboot, and locate devices, retrieve recovery keys, local admin passwords and BIOS passwords, run Defender scans, offboard devices from Defender for Endpoint, reset or wipe a device, add devices to groups, and remove devices from management. Which actions are available for a given device depends on its operating system. ## Action Buttons @@ -24,6 +24,6 @@ Selecting a row opens a flyout showing the device name and its assigned user. ## Table Actions -
    ActionDescriptionBulk Action Available
    View DeviceOpens the device's device.md page in CIPP, with its full details, applications, and users.false
    View in IntuneOpens the device in the Microsoft Intune admin center in a new tab.false
    Change Primary UserSets a different user as the device's primary user.true
    Add to GroupAdds the device to one or more Entra ID groups. Groups are listed with their name and type, and several can be selected at once, with the device added to each. Devices cannot be added to Distribution List or Mail-Enabled Security groups, and selecting one returns an error for that group rather than failing the whole action.true
    Rename DeviceChanges the device's name to one you specify.true
    Sync DeviceAsks the device to check in with Intune, so that pending policies and applications are applied sooner than the next scheduled sync.true
    Reboot DeviceRestarts the device.true
    Locate DeviceRequests the device's current location.true
    Retrieve LAPS passwordRetrieves the local administrator password held for the device by Windows LAPS. Windows devices only.true
    Rotate Local Admin PasswordForces the local administrator password to be changed and a new one stored. Windows devices only.true
    Retrieve BIOS PasswordRetrieves the BIOS password Intune holds for the device. A password only exists where the device is targeted by a BIOS configuration profile that manages per-device passwords, otherwise the action reports that none was found. Windows devices only.true
    Retrieve BitLocker KeysRetrieves the BitLocker recovery keys escrowed for the device. Windows devices only.true
    Retrieve FileVault KeyRetrieves the FileVault recovery key escrowed for the device. macOS devices only.true
    Reset PasscodeResets the device's passcode. Android devices only.true
    Remove PasscodeRemoves the device's passcode. iOS devices only.true
    Windows Defender Full ScanStarts a full Microsoft Defender scan on the device.true
    Windows Defender Quick ScanStarts a quick Microsoft Defender scan on the device.true
    Update Windows DefenderUpdates the Microsoft Defender signatures on the device.true
    Fresh Start (Remove user data)Reinstalls Windows on the device and removes the user's data. Windows devices only.true
    Fresh Start (Do not remove user data)Reinstalls Windows on the device while retaining the user's data. Windows devices only.true
    Wipe Device, keep enrollment dataWipes the device but retains its enrolment data, so it remains managed. Windows devices only.true
    Wipe Device, remove enrollment dataWipes the device and removes its enrolment data, so it is no longer managed. Windows devices only.true
    Wipe Device, keep enrollment data, and continue at powerlossAs above, retaining enrolment data, but the wipe resumes if the device loses power part-way through. Windows devices only.true
    Wipe Device, remove enrollment data, and continue at powerlossAs above, removing enrolment data, but the wipe resumes if the device loses power part-way through. Windows devices only.true
    Autopilot ResetResets the device and re-runs the Autopilot provisioning process. Windows devices only.true
    Delete deviceDeletes the device record from Intune.true
    Retire deviceRemoves company data and management from the device while leaving the user's personal data in place.true
    More InfoOpens the Extended Info flyout with the full details for the selected row.false
    +
    ActionDescriptionBulk Action Available
    View DeviceOpens the device's device.md page in CIPP, with its full details, applications, and users.false
    View in IntuneOpens the device in the Microsoft Intune admin center in a new tab.false
    Change Primary UserSets a different user as the device's primary user.true
    Add to GroupAdds the device to one or more Entra ID groups. Groups are listed with their name and type, and several can be selected at once, with the device added to each. Devices cannot be added to Distribution List or Mail-Enabled Security groups, and selecting one returns an error for that group rather than failing the whole action.true
    Rename DeviceChanges the device's name to one you specify.true
    Sync DeviceAsks the device to check in with Intune, so that pending policies and applications are applied sooner than the next scheduled sync.true
    Reboot DeviceRestarts the device.true
    Locate DeviceRequests the device's current location.true
    Retrieve LAPS passwordRetrieves the local administrator password held for the device by Windows LAPS. Windows devices only.true
    Rotate Local Admin PasswordForces the local administrator password to be changed and a new one stored. Windows devices only.true
    Retrieve BIOS PasswordRetrieves the BIOS password Intune holds for the device. A password only exists where the device is targeted by a BIOS configuration profile that manages per-device passwords, otherwise the action reports that none was found. Windows devices only.true
    Retrieve BitLocker KeysRetrieves the BitLocker recovery keys escrowed for the device. Windows devices only.true
    Retrieve FileVault KeyRetrieves the FileVault recovery key escrowed for the device. macOS devices only.true
    Reset PasscodeResets the device's passcode. Android devices only.true
    Remove PasscodeRemoves the device's passcode. iOS devices only.true
    Windows Defender Full ScanStarts a full Microsoft Defender scan on the device.true
    Windows Defender Quick ScanStarts a quick Microsoft Defender scan on the device.true
    Update Windows DefenderUpdates the Microsoft Defender signatures on the device.true
    Offboard from Defender for EndpointOffboards the device from Microsoft Defender for Endpoint, so that it stops reporting to Defender. The device is matched to its Defender record by its Entra ID device ID, and every matching record that is still onboarded is offboarded. Where the device has no Entra ID device ID, or has no matching Defender record that is currently onboarded, the action reports an error and nothing is offboarded. Offboarding cannot be undone from CIPP, and the device has to be re-onboarded to return it to Defender. Windows devices only.true
    Fresh Start (Remove user data)Reinstalls Windows on the device and removes the user's data. Windows devices only.true
    Fresh Start (Do not remove user data)Reinstalls Windows on the device while retaining the user's data. Windows devices only.true
    Wipe Device, keep enrollment dataWipes the device but retains its enrolment data, so it remains managed. Windows devices only.true
    Wipe Device, remove enrollment dataWipes the device and removes its enrolment data, so it is no longer managed. Windows devices only.true
    Wipe Device, keep enrollment data, and continue at powerlossAs above, retaining enrolment data, but the wipe resumes if the device loses power part-way through. Windows devices only.true
    Wipe Device, remove enrollment data, and continue at powerlossAs above, removing enrolment data, but the wipe resumes if the device loses power part-way through. Windows devices only.true
    Wipe DeviceErases all content and settings on the device, which cannot be undone. A Recovery PIN (optional, 6 digits) can be supplied, and is needed to unlock Intel Macs that have no T2 security chip once the wipe has finished. macOS devices only.true
    Autopilot ResetResets the device and re-runs the Autopilot provisioning process. Windows devices only.true
    Delete deviceDeletes the device record from Intune.true
    Retire deviceRemoves company data and management from the device while leaving the user's personal data in place.true
    More InfoOpens the Extended Info flyout with the full details for the selected row.false
    {% include "../../../../../.gitbook/includes/feature-request.md" %} From 0752cc3efb90f1cb5130b172ce341dd98c9a0bc3 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Wed, 19 Aug 2026 23:00:58 +0800 Subject: [PATCH 196/226] docs(alerts): document rogue apps alert sources and CIPP curated list The Huntress Rogue Apps alert compares tenants against both the public Huntress RogueApps feed and a CIPP-curated list (Config/MaliciousApps.json), so it can flag applications that do not appear on the Huntress site. That second source was previously undocumented, which caused confusion when detections did not match the Huntress list. Adds a Rogue Apps docs page covering where the list comes from, what a detection means, and the applications on the CIPP curated list; links it from the Add Alert page and navigation; and points the in-app alert description at the new page. --- docs/SUMMARY.md | 1 + .../alert-configuration/alert.md | 4 ++ .../alert-configuration/rogue-apps.md | 53 +++++++++++++++++++ frontend/src/data/alerts.json | 2 +- 4 files changed, 59 insertions(+), 1 deletion(-) create mode 100644 docs/user-documentation/tenant/administration/alert-configuration/rogue-apps.md diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index 79a9cef040..6911eedbe2 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -146,6 +146,7 @@ * [Global Variables](user-documentation/tenant/administration/tenants/global-variables.md) * [Alert Configuration](user-documentation/tenant/administration/alert-configuration/README.md) * [Add Alert](user-documentation/tenant/administration/alert-configuration/alert.md) + * [Rogue Apps](user-documentation/tenant/administration/alert-configuration/rogue-apps.md) * [Snoozed Alerts](user-documentation/tenant/administration/alert-configuration/snoozed-alerts.md) * [Audit Logs](user-documentation/tenant/administration/audit-logs/README.md) * [View Audit Log](user-documentation/tenant/administration/audit-logs/log.md) diff --git a/docs/user-documentation/tenant/administration/alert-configuration/alert.md b/docs/user-documentation/tenant/administration/alert-configuration/alert.md index d52c96325d..a71f23ad6b 100644 --- a/docs/user-documentation/tenant/administration/alert-configuration/alert.md +++ b/docs/user-documentation/tenant/administration/alert-configuration/alert.md @@ -117,6 +117,10 @@ Once the criteria and notification settings are complete, **Save Alert** on the You can review the available alerts embedded below or navigate to [https://resources.cipp.app/?tab=alerts](https://resources.cipp.app/?tab=alerts). +{% hint style="info" %} +The **Alert on Huntress or CIPP Rogue Apps detected** alert checks tenants against both the public Huntress RogueApps feed and a list curated by CIPP, so it can report applications that do not appear on the Huntress website. See [rogue-apps.md](rogue-apps.md "mention") for how the list is built and which applications the CIPP list contains. +{% endhint %} + {% @cipp-external-webpage-block/cyberdrain url="https://resources.cipp.app/?tab=alerts" fullWidth="true" %} {% include "../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/tenant/administration/alert-configuration/rogue-apps.md b/docs/user-documentation/tenant/administration/alert-configuration/rogue-apps.md new file mode 100644 index 0000000000..0eb1ea223c --- /dev/null +++ b/docs/user-documentation/tenant/administration/alert-configuration/rogue-apps.md @@ -0,0 +1,53 @@ +--- +description: Where the Huntress or CIPP Rogue Apps alert gets its list of applications. +--- + +# Rogue Apps + +The scripted alert **Alert on Huntress or CIPP Rogue Apps detected** checks the enterprise applications (service principals) present in each selected tenant against a list of applications that have been observed being abused by threat actors against Microsoft 365 tenants. Because the alert draws from two lists, it can report applications that do not appear on the Huntress website. + +## Where the list comes from + +Each time the alert runs, the list is built from two sources: + +* **The Huntress RogueApps feed** - The community-maintained repository published by Huntress at [https://huntresslabs.github.io/rogueapps/](https://huntresslabs.github.io/rogueapps/). +* **The CIPP curated list** - Applications collected by the CIPP team and community from incident write-ups and threat intelligence. This list ships with CIPP and is updated with CIPP releases. + +The two lists overlap. When an application appears in both, it is reported once, with the details from the Huntress feed. The **Source** field on each alert result shows which list the application came from: `Huntress` or `CIPP`. + +{% hint style="info" %} +If the Huntress feed is temporarily unreachable, the check skips that run rather than alerting on partial data, and picks up again on the next scheduled run. +{% endhint %} + +## What a detection means + +A match means a service principal for the application exists in the tenant, which happens when a user or administrator has consented to it at some point. It does not automatically mean the tenant is compromised: several listed applications are legitimate products that threat actors abuse after gaining access to an account. Treat a detection as a prompt to verify whether the consent was expected, review the sign-in and audit logs for the application, and revoke the service principal if it was not. + +The alert's **Ignore Disabled Apps?** option skips service principals that are already disabled, so only applications that can still be used are reported. + +## Applications on the CIPP curated list + +| Application | App ID | Why it is listed | +| ------------------------------- | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | +| CloudSponge | `a43e5392-f48b-46a4-a0f1-098b5eeb4757` | Contact-import service abused to harvest address books. | +| CubeBackup | `412445a2-0794-487e-9dd6-d57d9593b249` | Microsoft 365 backup tool abused for mass mailbox, SharePoint and OneDrive exfiltration. | +| Edison Mail | `62db40a4-2c7e-4373-a609-eda138798962` | Email client with full mailbox synchronization, abused for mailbox exfiltration. | +| eM Client | `e9a7fea1-1cc0-4cd9-a31b-9137ca5deedd` | Desktop email client abused to bulk-synchronize compromised mailboxes and maintain access. | +| Fastmail | `77468577-4f6e-40e7-b745-11d3d0c28095` | Email service whose import feature can exfiltrate all mail to an attacker-controlled account. | +| Foxmail | `231575bc-9f6c-4539-9241-5cfae696b630` | Desktop email client observed in business email compromise with full legacy-protocol mailbox access. | +| Horizon Tech | `b1c4926a-5fb6-4aad-b920-709c957be148` | Pulls email and contacts and sends phishing from the compromised mailbox. | +| Jotform | `9af771d1-1288-43f0-91a6-adadcbd212b5` | Online form builder abused as a native phishing and spam vector after Microsoft 365 SSO consent. | +| Mail_Backup | `2ef68ccc-8a4d-42ff-ae88-2d7bb89ad139` | Mailbox export tool used to exfiltrate email. Renamed successor to PerfectData Software. | +| Newsletter Software Supermailer | `a245e8c0-b53c-4b67-9b45-751d1dff8e6b` | Bulk email tool abused to send phishing and spam from a compromised mailbox. | +| PerfectData Software | `ff8d92dc-3d82-41d6-bcbd-b9174d163620` | Mailbox export tool widely abused in business email compromise to bulk-export victim mailboxes. | +| PostBox | `179d5108-412b-4c95-8e34-06786784ab39` | Desktop email client abused for mailbox exfiltration and persistence. | +| rclone | `4761b959-9780-4c2d-87a3-512b4638f767` | Command-line cloud storage tool abused to bulk-download SharePoint and OneDrive content. | +| SigParser | `caffae8c-0882-4c81-9a27-d1803af53a40` | Email-scanning contact intelligence tool abused for address book harvesting. | +| Spike | `946c777c-bc85-489e-b034-392389ae23d6` | Conversational email client abused for mailbox exfiltration, persistence and phishing. | +| Teleforge Directory | `1a9b8d93-0d60-4835-896f-83016de95ff5` | Observed in business email compromise, collecting mailbox data before fraudulent email was sent. | +| ZoomInfo Communitiez Login | `497ac034-5120-4c1a-929a-0351f5c09918` | Created by ZoomInfo's My Connections feature, which extracts contacts for target discovery and phishing. | +| Zoominfo Login | `858d7e42-35f0-44b7-9033-df309239a47f` | ZoomInfo SSO sign-in service principal, abused for persistence and contact harvesting. | + +The authoritative copy of this list is the `Config/MaliciousApps.json` file shipped with your CIPP version, which also carries the permissions, references and detection guidance for each entry. New applications are added as they are observed in the wild, so the table above may trail the list in a recent release. + +{% include "../../../../../.gitbook/includes/feature-request.md" %} diff --git a/frontend/src/data/alerts.json b/frontend/src/data/alerts.json index c89491972f..adc8d26de3 100644 --- a/frontend/src/data/alerts.json +++ b/frontend/src/data/alerts.json @@ -523,7 +523,7 @@ "name": "HuntressRogueApps", "label": "Alert on Huntress or CIPP Rogue Apps detected", "recommendedRunInterval": "4h", - "description": "Huntress has provided a repository of known rogue apps that are commonly used in BEC, data exfiltration and other Microsoft 365 attacks. This alert will notify you if any of these apps are detected in the selected tenant(s). For more information, see https://huntresslabs.github.io/rogueapps/. CIPP also has a list of community collected rogue apps.", + "description": "Huntress has provided a repository of known rogue apps that are commonly used in BEC, data exfiltration and other Microsoft 365 attacks. This alert will notify you if any of these apps are detected in the selected tenant(s). For more information, see https://huntresslabs.github.io/rogueapps/. CIPP also maintains its own curated list of rogue apps, so detections may include applications that are not on the Huntress site. See the CIPP documentation for the full list.", "requiresInput": true, "inputType": "switch", "inputLabel": "Ignore Disabled Apps?", From 5acd4020cbc386525cd2e1a8d45f2e6df861d36b Mon Sep 17 00:00:00 2001 From: Brian Simpson <50429915+bmsimp@users.noreply.github.com> Date: Wed, 19 Aug 2026 10:05:41 -0500 Subject: [PATCH 197/226] docs(identity): document Add Member action on Groups page Adds the Add Member row action to the Groups page Table Actions, covering the user picker, the CSV bulk path and multi-group selection. Co-Authored-By: Claude Opus 5 (1M context) --- .../user-documentation/identity/administration/groups/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/user-documentation/identity/administration/groups/README.md b/docs/user-documentation/identity/administration/groups/README.md index e18c599106..bacdd24208 100644 --- a/docs/user-documentation/identity/administration/groups/README.md +++ b/docs/user-documentation/identity/administration/groups/README.md @@ -59,7 +59,7 @@ Group Type is composed by CIPP rather than returned by Graph, which reports the ## Table Actions -
    ActionDescriptionBulk Action Available
    View GroupOpens the group.md page for the group, covering its membership, owners and settings.false
    Edit GroupOpens the edit.md page, where membership, owners and group settings can be changed.false
    Set Global Address List VisibilityHides the group from the Global Address List or shows it again. Has no effect on a group synchronised from on-premises Active Directory.true
    Only allow messages from people inside the organisationRequires sender authentication, so the group only accepts mail from within the tenant. Has no effect on a group synchronised from on-premises Active Directory.true
    Allow messages from people inside and outside the organisationDrops the sender authentication requirement, so the group accepts mail from external senders as well. Has no effect on a group synchronised from on-premises Active Directory.true
    Set Source of AuthoritySwitches the group between Cloud Managed and On-Premises Managed. Greyed out for cloud-native groups that have never been synchronised, and a move back to on-premises takes until the next sync cycle to appear.true
    Create template based on groupCreates a reusable group template from this group, copying its name, description, type, membership rule, alias and external sender setting.true
    Create Team from GroupTurns the group into a Microsoft Teams team, with the member, messaging and fun settings set in the dialog. Greyed out for anything other than a Microsoft 365 group.true
    Delete GroupDeletes the group.true
    More InfoOpens the Extended Info flyout with the full details for the selected row.false
    +
    ActionDescriptionBulk Action Available
    View GroupOpens the group.md page for the group, covering its membership, owners and settings.false
    Edit GroupOpens the edit.md page, where membership, owners and group settings can be changed.false
    Add MemberAdds one or more users to the group. Pick them from the tenant user list, or drop a CSV file with a userPrincipalName column to add members in bulk. Selecting several groups adds the same users to each of them.true
    Set Global Address List VisibilityHides the group from the Global Address List or shows it again. Has no effect on a group synchronised from on-premises Active Directory.true
    Only allow messages from people inside the organisationRequires sender authentication, so the group only accepts mail from within the tenant. Has no effect on a group synchronised from on-premises Active Directory.true
    Allow messages from people inside and outside the organisationDrops the sender authentication requirement, so the group accepts mail from external senders as well. Has no effect on a group synchronised from on-premises Active Directory.true
    Set Source of AuthoritySwitches the group between Cloud Managed and On-Premises Managed. Greyed out for cloud-native groups that have never been synchronised, and a move back to on-premises takes until the next sync cycle to appear.true
    Create template based on groupCreates a reusable group template from this group, copying its name, description, type, membership rule, alias and external sender setting.true
    Create Team from GroupTurns the group into a Microsoft Teams team, with the member, messaging and fun settings set in the dialog. Greyed out for anything other than a Microsoft 365 group.true
    Delete GroupDeletes the group.true
    More InfoOpens the Extended Info flyout with the full details for the selected row.false
    {% hint style="info" %} A group has to be at least fifteen minutes old before **Create Team from Group** will work, as Microsoft needs the group to have finished provisioning first. From 01c15ff25dbd0b2ff4eaa2bca615ed708201ec4f Mon Sep 17 00:00:00 2001 From: Brian Simpson <50429915+bmsimp@users.noreply.github.com> Date: Wed, 19 Aug 2026 10:22:00 -0500 Subject: [PATCH 198/226] docs(report-builder): document database block value rendering Licence assignments render as product names and Cloud PCs with no reported encryption state show as Encrypted (platform-managed), in both the builder preview and the generated report. Co-Authored-By: Claude Opus 5 (1M context) --- docs/user-documentation/tools/report-builder/builder.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/user-documentation/tools/report-builder/builder.md b/docs/user-documentation/tools/report-builder/builder.md index 03d9efa2af..945e0a5ef7 100644 --- a/docs/user-documentation/tools/report-builder/builder.md +++ b/docs/user-documentation/tools/report-builder/builder.md @@ -114,6 +114,8 @@ Custom blocks use a rich text editor with headings, bold, italic, underline, str The chip beside the title switches the display between **Table (Text)**, **CSV** and **JSON**. Below it, a checkbox list controls which columns appear, with **Select All** and **Deselect All** for working quickly through a wide data source. +Some values are presented for readability rather than shown as the data source holds them. Licence assignments appear as product names, such as Microsoft 365 Business Premium, separated by commas, falling back to the licence's SKU name and then its identifier where the product name is not known. A Cloud PC that reports no encryption state is shown as **Encrypted (platform-managed)**, because Cloud PCs are encrypted by the platform rather than by BitLocker. Both apply in all three formats, and a generated report renders them the same way as the preview here. + ### Structured Block Editing Chart, Score Cards and Progress Bars blocks are edited as small tables of values. Add a row for each data point, giving it a label and a value, with an optional colour on chart data points. Charts also take a caption, and a donut chart takes a centre label and an optional maximum. From 1484dcd71ddb40629576e60e9a947d716be75e2b Mon Sep 17 00:00:00 2001 From: Brian Simpson <50429915+bmsimp@users.noreply.github.com> Date: Wed, 19 Aug 2026 10:28:06 -0500 Subject: [PATCH 199/226] docs(report-builder): correct licence rendering in generated reports A generated report now names licences from the tenant's own licence data with the instance-wide exclusions applied, so excluded and unrecognised SKUs no longer fall back to a SKU name or identifier. Separate the preview's behaviour from the report's and link the excluded licences page. Co-Authored-By: Claude Opus 5 (1M context) --- docs/user-documentation/tools/report-builder/builder.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/user-documentation/tools/report-builder/builder.md b/docs/user-documentation/tools/report-builder/builder.md index 945e0a5ef7..b12460a32d 100644 --- a/docs/user-documentation/tools/report-builder/builder.md +++ b/docs/user-documentation/tools/report-builder/builder.md @@ -114,7 +114,9 @@ Custom blocks use a rich text editor with headings, bold, italic, underline, str The chip beside the title switches the display between **Table (Text)**, **CSV** and **JSON**. Below it, a checkbox list controls which columns appear, with **Select All** and **Deselect All** for working quickly through a wide data source. -Some values are presented for readability rather than shown as the data source holds them. Licence assignments appear as product names, such as Microsoft 365 Business Premium, separated by commas, falling back to the licence's SKU name and then its identifier where the product name is not known. A Cloud PC that reports no encryption state is shown as **Encrypted (platform-managed)**, because Cloud PCs are encrypted by the platform rather than by BitLocker. Both apply in all three formats, and a generated report renders them the same way as the preview here. +Some values are presented for readability rather than shown as the data source holds them. Licence assignments appear as product names, such as Microsoft 365 Business Premium, separated by commas. A Cloud PC that reports no encryption state is shown as **Encrypted (platform-managed)**, because Cloud PCs are encrypted by the platform rather than by BitLocker. Both apply in all three formats. + +The preview lists every licence assigned, falling back to the licence's SKU name and then its identifier where the product name is not known. A generated report names licences the way the rest of CIPP does, so the licences you have excluded in [licenses.md](../../cipp/settings/licenses.md "mention") do not appear in it. Where CIPP holds no licence data for the tenant, the raw values are shown instead. ### Structured Block Editing From 71e7026d8ffc4b8341d5602b84999980dd0cafd0 Mon Sep 17 00:00:00 2001 From: Brian Simpson <50429915+bmsimp@users.noreply.github.com> Date: Wed, 19 Aug 2026 10:40:00 -0500 Subject: [PATCH 200/226] docs(shared-features): document self-service access refresh Adds a Refresh My Access page under Menu Bar covering the account popover item and the Access Denied page button, the three result states, and the 30-second cooldown. Also draws the line the paired backend fix creates: a refresh is for a change to your own group membership, such as a PIM activation, while role group mapping changes made in CIPP now apply immediately and need no refresh. Documents 8ac85709 and 6ddd24f2. Co-Authored-By: Claude Opus 5 (1M context) --- docs/SUMMARY.md | 1 + .../menu-bar/refresh-my-access.md | 37 +++++++++++++++++++ 2 files changed, 38 insertions(+) create mode 100644 docs/user-documentation/shared-features/menu-bar/refresh-my-access.md diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index 79a9cef040..03d4f2c33b 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -87,6 +87,7 @@ * [Search](user-documentation/shared-features/menu-bar/search.md) * [Bookmarks](user-documentation/shared-features/menu-bar/bookmarks.md) * [User Preferences](user-documentation/shared-features/menu-bar/user-settings.md) + * [Refresh My Access](user-documentation/shared-features/menu-bar/refresh-my-access.md) * [Table Features](user-documentation/shared-features/table-features.md) * [Mobile Layout](user-documentation/shared-features/mobile-layout.md) * [Speed Dial](user-documentation/shared-features/speed-dial.md) diff --git a/docs/user-documentation/shared-features/menu-bar/refresh-my-access.md b/docs/user-documentation/shared-features/menu-bar/refresh-my-access.md new file mode 100644 index 0000000000..2672f064af --- /dev/null +++ b/docs/user-documentation/shared-features/menu-bar/refresh-my-access.md @@ -0,0 +1,37 @@ +# Refresh My Access + +Where your CIPP role comes from membership of an Entra group, CIPP does not notice a change to that membership straight away. **Refresh my access** re-checks your group membership on demand and applies whatever roles it finds, so a role you have just picked up, typically by activating a group through Privileged Identity Management, takes effect without you having to wait or sign in again. + +It only ever refreshes your own access. It cannot be used to change anyone else's. + +{% hint style="info" %} +This is for a change to your own group membership. Changes made in CIPP itself apply straight away and need no refresh: assigning a different Entra group to a role, removing a role's group, or deleting a role that had one all reach everyone in the affected group immediately. Changing only a role's permissions does not alter who holds the role, so nothing needs to be refreshed there either. +{% endhint %} + +## Refreshing from the Menu Bar + +**Refresh my access** sits in the account menu, reached from your avatar at the right of the menu bar, directly above **Log out**. + +Selecting it asks you to confirm, then reports the outcome in the same dialog. The rest of CIPP picks up the new roles in place, so pages and actions that were previously unavailable become available without a reload. + +## Refreshing from the Access Denied Page + +The Access Denied page carries the same **Refresh my access** button, below the card. This is the more common route: an account with a standing read-only role that elevates to an admin role through PIM lands on Access Denied when it opens a page the standing role cannot reach. + +A refresh that grants the role you were missing takes you into the page you were trying to reach, with no second sign-in. + +## What the Result Tells You + +| Result | Meaning | +| ------ | ------- | +| Access refreshed, with your roles listed | Your group memberships were re-checked and the roles listed are now in force. | +| Access refreshed, but no group maps to a role | Your memberships were re-checked and none of the groups you belong to is mapped to a CIPP role. If you activated a group moments ago, the activation may not have reached Microsoft yet, so wait a moment and try again. | +| A warning message | The refresh did not complete. The message explains why. | + +{% hint style="info" %} +A refresh can only be run once every 30 seconds. Running it again sooner reports how long is left to wait rather than re-checking. +{% endhint %} + +Only roles that come from Entra group membership are affected. A role assigned to you directly on [cipp-users.md](../../cipp/advanced/authentication/cipp-users.md "mention") is already in force and needs no refresh, and refreshing never removes it. If a refresh reports roles you did not expect, or none where you expected some, check which group each role is mapped to on [cipp-roles](../../cipp/advanced/authentication/cipp-roles/ "mention"), and see [how-cipp-evaluates-roles.md](../../../setup/resources/how-cipp-evaluates-roles.md "mention") for how several roles combine. + +{% include "../../../../.gitbook/includes/feature-request.md" %} From 14a4a1068b3e3981ea98c726b894cd3719dcb819 Mon Sep 17 00:00:00 2001 From: Brian Simpson <50429915+bmsimp@users.noreply.github.com> Date: Wed, 19 Aug 2026 10:46:02 -0500 Subject: [PATCH 201/226] docs(halopsa): correct test ticket scope and drop em dash The Create Test Ticket row still described the button as raising a ticket using only the ticket type and default priority. New-HaloPSATicket now stamps a source on the payload, and the test ticket calls that function directly, so the configured request source applies there too. Also aligns the Request Source row's closing sentence with the Ticket Type row above it ("Halo's default"), and replaces the pre-existing em dash in How Alert Tickets Are Raised with a following sentence, so the page passes the docs linter clean. Co-Authored-By: Claude Opus 5 (1M context) --- docs/user-documentation/cipp/integrations/halopsa.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/user-documentation/cipp/integrations/halopsa.md b/docs/user-documentation/cipp/integrations/halopsa.md index e3c6bf7d2e..cef50d9b56 100644 --- a/docs/user-documentation/cipp/integrations/halopsa.md +++ b/docs/user-documentation/cipp/integrations/halopsa.md @@ -103,7 +103,7 @@ Move to the **Tenant Mapping** tab and map each CIPP tenant to its Halo client, | HaloPSA Client ID | The Client ID of the API application created in Halo. | | HaloPSA Client Secret | The Client Secret of the API application. Stored securely and masked once saved; leave blank on subsequent saves to keep the existing value. | | HaloPSA Ticket Type | The ticket type used for CIPP alert tickets. Sets the workflow, and determines which priorities and outcomes are offered below. Leave blank to use Halo's default. | -| HaloPSA Request Source | Optional. Sets the request source recorded on every CIPP-generated ticket, so they can be told apart from manually logged tickets when reporting on ticket origin. Halo records tickets raised over the API as Manual unless one is set. Create the source in Halo first. Leave blank to use Halo default. | +| HaloPSA Request Source | Optional. Sets the request source recorded on every CIPP-generated ticket, so they can be told apart from manually logged tickets when reporting on ticket origin. Halo records tickets raised over the API as Manual unless one is set. Create the source in Halo first. Leave blank to use Halo's default. | | HaloPSA Default Priority | Optional. Sets the priority on every CIPP-generated ticket. Only priorities on the ticket type's SLA are listed. Leave blank to use the SLA default. Appears once a ticket type is selected. | | Consolidate Tickets | Adds repeat alerts with the same title to the existing open ticket as a private note rather than raising a new ticket. Appears once a ticket type is selected. | | HaloPSA Outcome | The action applied when a duplicate alert is added to an existing ticket. Only outcomes from the selected ticket type's workflow are listed, and the action must be one the Halo API user can run. Leave blank to use Halo's built-in Internal Note action. Appears once **Consolidate Tickets** is enabled. | @@ -153,7 +153,7 @@ Two buttons at the top of the page verify different things. | Button | Description | | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Test | Authenticates against Halo using the saved credentials and reports whether the connection succeeded. It does not create anything in Halo. | -| Create Test Ticket | Raises a real ticket in Halo using the configured ticket type and default priority, confirming end-to-end delivery. It is safe to close the resulting ticket. | +| Create Test Ticket | Raises a real ticket in Halo using the configured ticket type, request source and default priority, confirming end-to-end delivery. It is safe to close the resulting ticket. | {% hint style="warning" %} The test ticket is raised against the first mapped Halo client. If no usable mapping exists it falls back to client ID 1, which may not be a client you expect. Map your tenants before using this button. @@ -165,7 +165,7 @@ The test ticket is created directly rather than through the alert pipeline, so i Ticket titles are prefixed with `[CIPP]` so that CIPP-generated tickets are easy to identify and filter in Halo. -When **Consolidate Tickets** is enabled, CIPP records the ticket it raised for each combination of client and alert title. A later alert with the same title is added to that ticket as a private note, using the configured outcome. If the ticket has since been closed, or the note cannot be added — most often because the Halo API user is not permitted to run the chosen outcome — a new ticket is created instead, so the alert is never lost. +When **Consolidate Tickets** is enabled, CIPP records the ticket it raised for each combination of client and alert title. A later alert with the same title is added to that ticket as a private note, using the configured outcome. If the ticket has since been closed, or the note cannot be added, a new ticket is created instead, so the alert is never lost. The usual reason a note cannot be added is that the Halo API user is not permitted to run the chosen outcome. When **Link Tickets to affected Users** is enabled, CIPP raises a separate ticket per affected user and matches them to a Halo contact within the mapped client, first on Microsoft Entra Object ID and then on email address or network login. Where no contact matches, the ticket is assigned to the client's General User and the affected user's UPN is included in the ticket body. From 2929741d323ddd3bb73ce8db794241fc751aee2a Mon Sep 17 00:00:00 2001 From: Brian Simpson <50429915+bmsimp@users.noreply.github.com> Date: Wed, 19 Aug 2026 10:51:04 -0500 Subject: [PATCH 202/226] docs(mobile-layout): correct account menu breakpoints PR #316 moved Universal Search and the Light/Dark Mode entry in the account popover from mdDown to useIsMobileLayout, so they now appear as soon as the navigation collapses rather than only on a phone. Update the window-width table and the universal search page to match, and qualify the help and support row, which still moves only below 900px. Co-Authored-By: Claude Opus 5 (1M context) --- .../shared-features/menu-bar/universal-search.md | 2 +- docs/user-documentation/shared-features/mobile-layout.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/user-documentation/shared-features/menu-bar/universal-search.md b/docs/user-documentation/shared-features/menu-bar/universal-search.md index 6c86166676..c9d2292f88 100644 --- a/docs/user-documentation/shared-features/menu-bar/universal-search.md +++ b/docs/user-documentation/shared-features/menu-bar/universal-search.md @@ -12,7 +12,7 @@ Two icons in the menu bar open the search dialog, each starting on a different s | Ctrl/Cmd + Shift + F | Opens search on **Users**. | | Ctrl/Cmd + Alt + K | Moves the cursor to the tenant selector. | -On a phone the two icons are not shown, and search is opened from the **Universal Search** entry in your account menu. It fills the screen, the search types are offered as chips beneath the box so any of them is one tap away, and the results appear in the page rather than in a dropdown. Before you have typed anything, your bookmarks are listed instead. See [mobile-layout.md](../mobile-layout.md "mention"). +Where the navigation has collapsed behind the menu button, the two icons are not shown, and search is opened from the **Universal Search** entry in your account menu. It fills the screen, the search types are offered as chips beneath the box so any of them is one tap away, and the results appear in the page rather than in a dropdown. Before you have typed anything, your bookmarks are listed instead. See [mobile-layout.md](../mobile-layout.md "mention"). ## Search Types diff --git a/docs/user-documentation/shared-features/mobile-layout.md b/docs/user-documentation/shared-features/mobile-layout.md index edd16c5166..57b6c69f89 100644 --- a/docs/user-documentation/shared-features/mobile-layout.md +++ b/docs/user-documentation/shared-features/mobile-layout.md @@ -6,7 +6,7 @@ The layout is chosen from the width of the browser window rather than from the d | Window width | What changes | | -------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | -| Below roughly 1200px | The left-hand navigation collapses behind a menu button, and the tenant selector becomes a chip in the menu bar. | +| Below roughly 1200px | The left-hand navigation collapses behind a menu button, the tenant selector becomes a chip in the menu bar, and the search and display mode icons move into your account menu. | | Below roughly 900px | Tables become card lists, dialogs and flyouts take the full screen, and page actions move to a button in the bottom right corner. | Pages are laid out to fit the width of the screen, so scrolling is vertical. Where content genuinely cannot be made narrower, such as a marketing email built around a fixed-width layout, it scrolls sideways within its own card rather than moving the page beneath it. @@ -21,7 +21,7 @@ On a narrow window the menu bar carries the menu button, the current tenant, not | Tenant selector | A chip in the menu bar showing the current tenant, which opens a full-screen picker. See [tenant-select.md](menu-bar/tenant-select.md "mention"). | | Universal search | The **Universal Search** entry in your account menu. See [universal-search.md](menu-bar/universal-search.md "mention"). | | Light/dark mode | The **Light Mode** or **Dark Mode** entry in your account menu. | -| Help and support | The help links and **Clear Cache and Reload** move into your account menu, because the speed dial's corner is given to page actions. See [speed-dial.md](speed-dial.md "mention"). | +| Help and support | On a phone, the help links and **Clear Cache and Reload** move into your account menu, because the speed dial's corner is given to page actions. See [speed-dial.md](speed-dial.md "mention"). | The navigation drawer has a search box at the top. Typing in it narrows the menu to matching entries and opens the sections they sit in, so a page several levels down can be reached without expanding each level by hand. From e94f951a84fcc680bbf7c730aa9f0b186313e076 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Wed, 19 Aug 2026 23:58:03 +0800 Subject: [PATCH 203/226] fix(graph-requests): keep split cache rows in the queue pre-write cleanup The pre-write cleanup read in Push-ListGraphRequestQueue projected PartitionKey, RowKey and OriginalEntityId - a subset of the split-entity markers. Get-AzDataTableLargeEntity then recognised the rows of a split entity as parts, could not reassemble them without PartIndex/PartCount, and dropped the whole entity, so tenants whose cached blob was split across rows were never removed before the rewrite and every refresh logged a false 'corrupt table entity' error. Project keys only, so the raw physical rows come back and every row reaches the delete; the delete already skips part rows it was handed directly. --- .../Push-ListGraphRequestQueue.ps1 | 10 +- .../Push-ListGraphRequestQueue.Tests.ps1 | 106 ++++++++++++++++++ 2 files changed, 115 insertions(+), 1 deletion(-) create mode 100644 backend/Tests/ActivityTriggers/Push-ListGraphRequestQueue.Tests.ps1 diff --git a/backend/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Graph Requests/Push-ListGraphRequestQueue.ps1 b/backend/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Graph Requests/Push-ListGraphRequestQueue.ps1 index f7792a14f4..acaf391492 100644 --- a/backend/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Graph Requests/Push-ListGraphRequestQueue.ps1 +++ b/backend/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Graph Requests/Push-ListGraphRequestQueue.ps1 @@ -23,7 +23,15 @@ function Push-ListGraphRequestQueue { $Filter = "PartitionKey eq '{0}' and (RowKey eq '{1}' or OriginalEntityId eq '{1}')" -f $PartitionKey, $Item.TenantFilter Write-Information "Filter: $Filter" - $Existing = Get-CIPPAzDataTableEntity @Table -Filter $Filter -Property PartitionKey, RowKey, OriginalEntityId + # Project NONE of the split-entity markers (OriginalEntityId, PartIndex, PartCount, + # SplitOverProps, chunk properties): excluding them all makes Get-AzDataTableLargeEntity + # skip reassembly and return raw physical rows, part rows named '{RowKey}-part'. + # Projecting a SUBSET (e.g. OriginalEntityId alone) is poison - the module then + # recognizes a split entity, fails to reassemble it from the truncated rows, and drops + # it, silently losing exactly the tenants whose cached blob was split across rows. + # Handing the raw part rows to Remove-CIPPAzDataTableEntity is safe: its own part-row + # lookup skips rows already in the delete batch. + $Existing = Get-CIPPAzDataTableEntity @Table -Filter $Filter -Property PartitionKey, RowKey if ($Existing) { $null = Remove-CIPPAzDataTableEntity -Force @Table -Entity $Existing } diff --git a/backend/Tests/ActivityTriggers/Push-ListGraphRequestQueue.Tests.ps1 b/backend/Tests/ActivityTriggers/Push-ListGraphRequestQueue.Tests.ps1 new file mode 100644 index 0000000000..9281c16a7c --- /dev/null +++ b/backend/Tests/ActivityTriggers/Push-ListGraphRequestQueue.Tests.ps1 @@ -0,0 +1,106 @@ +# The pre-write cleanup in Push-ListGraphRequestQueue must see every existing row for the +# tenant, including the physical part rows of a cache blob that was split for size. +# Projecting a subset of the split-entity markers (the old PartitionKey, RowKey, +# OriginalEntityId read) made the table module attempt reassembly, fail on the stripped +# rows, and drop split tenants from $Existing entirely, so their stale rows were never +# removed before the rewrite. + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $FunctionPath = Get-ChildItem -Path (Join-Path $RepoRoot 'Modules') -Recurse -Filter 'Push-ListGraphRequestQueue.ps1' -File -ErrorAction SilentlyContinue | + Select-Object -First 1 -ExpandProperty FullName + if (-not $FunctionPath) { throw 'Could not locate Push-ListGraphRequestQueue.ps1 under Modules/' } + + # Stubs so Mock has commands to replace. + function Get-CIPPTable { param($TableName) } + function Get-CIPPAzDataTableEntity { param($Context, $Filter, $Property) } + function Remove-CIPPAzDataTableEntity { param($Context, $Entity, [switch]$Force) } + function Add-CIPPAzDataTableEntity { param($Context, $Entity, [switch]$Force) } + function Get-GraphRequestList { param($TenantFilter, $Endpoint, $Parameters, $NoPagination, $ReverseTenantLookupProperty, $ReverseTenantLookup, $AsApp, $Caller, $SkipCache) } + function Get-CippException { param($Exception) @{ NormalizedError = "$Exception" } } + + . $FunctionPath + + # Keep the CacheBridge invalidation branch out of the exercised path. + $script:OriginalCippNg = $env:CIPPNG + $env:CIPPNG = 'false' + + $script:Item = [pscustomobject]@{ + Endpoint = 'users' + TenantFilter = 'contoso.com' + Parameters = @{ '$select' = 'id,displayName' } + PartitionKey = 'PKHASH' + QueueId = 'queue-1' + QueueType = 'AllTenants' + NoPagination = $false + ReverseTenantLookupProperty = 'tenantId' + ReverseTenantLookup = $false + AsApp = $false + } +} + +AfterAll { + $env:CIPPNG = $script:OriginalCippNg +} + +Describe 'Push-ListGraphRequestQueue pre-write cleanup' { + BeforeEach { + $script:Removed = $null + Mock -CommandName Get-CIPPTable -MockWith { @{ Context = 'stub' } } + Mock -CommandName Get-GraphRequestList -MockWith { @([pscustomobject]@{ id = '1' }) } + Mock -CommandName Add-CIPPAzDataTableEntity -MockWith { } + Mock -CommandName Remove-CIPPAzDataTableEntity -MockWith { $script:Removed = $Entity } + } + + It 'reads existing rows without projecting a subset of the split-entity markers' { + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { @() } + + Push-ListGraphRequestQueue -Item $script:Item + + # Either no projection at all (full rows reassemble normally) or one that excludes + # every marker (raw physical rows come back). A partial marker projection makes the + # module fail reassembly and silently drop split tenants from the cleanup. + Should -Invoke Get-CIPPAzDataTableEntity -Times 1 -ParameterFilter { + ($null -eq $Property) -or ( + $Property -notcontains 'OriginalEntityId' -and + $Property -notcontains 'PartIndex' -and + $Property -notcontains 'PartCount' -and + $Property -notcontains 'SplitOverProps' + ) + } + } + + It 'passes every raw row of a split tenant to the delete, part rows included' { + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { + @( + [pscustomobject]@{ PartitionKey = 'PKHASH'; RowKey = 'contoso.com' } + [pscustomobject]@{ PartitionKey = 'PKHASH'; RowKey = 'contoso.com-part1' } + [pscustomobject]@{ PartitionKey = 'PKHASH'; RowKey = 'contoso.com-part2' } + ) + } + + Push-ListGraphRequestQueue -Item $script:Item + + Should -Invoke Remove-CIPPAzDataTableEntity -Times 1 + @($script:Removed).Count | Should -Be 3 + @($script:Removed).RowKey | Should -Contain 'contoso.com-part2' + } + + It 'skips the delete when no rows exist for the tenant' { + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { @() } + + Push-ListGraphRequestQueue -Item $script:Item + + Should -Invoke Remove-CIPPAzDataTableEntity -Times 0 + } + + It 'still writes the fresh cache row after the cleanup' { + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { @() } + + Push-ListGraphRequestQueue -Item $script:Item + + Should -Invoke Add-CIPPAzDataTableEntity -Times 1 -ParameterFilter { + $Entity.RowKey -eq 'contoso.com' -and $Force + } + } +} From 715b0bae96e6814860b0cc21acb22a8d1c8b2565 Mon Sep 17 00:00:00 2001 From: Roel van der Wegen Date: Wed, 19 Aug 2026 21:54:19 +0200 Subject: [PATCH 204/226] feat(gdap): enhance onboarding URL resolution in GDAP invite functions Updated the Invoke-ExecGDAPInvite and Invoke-ListGDAPInvite scripts to improve the generation of onboarding URLs. The new implementation retrieves the hostname dynamically, ensuring that the onboarding URL is correctly formed based on the current environment. This change enhances the user experience by providing accurate links for onboarding processes. --- .../Tenant/GDAP/Invoke-ExecGDAPInvite.ps1 | 8 ++++---- .../Tenant/GDAP/Invoke-ListGDAPInvite.ps1 | 20 +++++++++++++++++-- 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/GDAP/Invoke-ExecGDAPInvite.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/GDAP/Invoke-ExecGDAPInvite.ps1 index 865ca6e692..feebb1ad05 100644 --- a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/GDAP/Invoke-ExecGDAPInvite.ps1 +++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/GDAP/Invoke-ExecGDAPInvite.ps1 @@ -78,10 +78,10 @@ function Invoke-ExecGDAPInvite { if ($NewRelationshipRequest.action -eq 'lockForApproval') { $InviteUrl = "https://admin.microsoft.com/AdminPortal/Home#/partners/invitation/granularAdminRelationships/$($NewRelationship.id)" - try { - $Uri = ([System.Uri]$TriggerMetadata.Headers.Referer) - $OnboardingUrl = $Uri.AbsoluteUri.Replace($Uri.PathAndQuery, "/tenant/gdap-management/onboarding/start?id=$($NewRelationship.id)") - } catch { + $Hostname = Get-CIPPHostname -Headers $Headers -PreferCustomDomain + if ($Hostname) { + $OnboardingUrl = "https://$Hostname/tenant/gdap-management/onboarding/start?id=$($NewRelationship.id)" + } else { $OnboardingUrl = $null } diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/GDAP/Invoke-ListGDAPInvite.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/GDAP/Invoke-ListGDAPInvite.ps1 index 662c6d68bc..4db94126d4 100644 --- a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/GDAP/Invoke-ListGDAPInvite.ps1 +++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/GDAP/Invoke-ListGDAPInvite.ps1 @@ -13,13 +13,29 @@ function Invoke-ListGDAPInvite { $RelationshipId = $Request.Query.RelationshipId $Table = Get-CIPPTable -TableName 'GDAPInvites' + + $ResolveOnboardingUrl = { + param($InviteRow) + if (![string]::IsNullOrWhiteSpace($InviteRow.OnboardingUrl)) { + return $InviteRow + } + if ([string]::IsNullOrWhiteSpace($InviteRow.RowKey)) { + return $InviteRow + } + $Hostname = Get-CIPPHostname -Headers $Request.Headers -PreferCustomDomain + if ($Hostname) { + $InviteRow.OnboardingUrl = "https://$Hostname/tenant/gdap-management/onboarding/start?id=$($InviteRow.RowKey)" + } + return $InviteRow + } + if (![string]::IsNullOrEmpty($RelationshipId)) { $SafeRelationshipId = ConvertTo-CIPPODataFilterValue -Value $RelationshipId -Type String - $Invite = Get-CIPPAzDataTableEntity @Table -Filter "RowKey eq '$SafeRelationshipId'" + $Invite = Get-CIPPAzDataTableEntity @Table -Filter "RowKey eq '$SafeRelationshipId'" | ForEach-Object { & $ResolveOnboardingUrl $_ } } else { $Invite = Get-CIPPAzDataTableEntity @Table | ForEach-Object { $_.RoleMappings = @(try { $_.RoleMappings | ConvertFrom-Json } catch { $_.RoleMappings }) - $_ + & $ResolveOnboardingUrl $_ } } return ([HttpResponseContext]@{ From 082356211b410bc1b16fc3d0158ca60e20aec3fa Mon Sep 17 00:00:00 2001 From: Roel van der Wegen Date: Thu, 20 Aug 2026 00:25:19 +0200 Subject: [PATCH 205/226] feat(cipp): implement application secret verification in Test-CIPPAccessPermissions Added functionality to verify the application secret stored in Key Vault, including checks for expiration and warning thresholds. This enhancement ensures that the application secret is valid and up-to-date, improving security and reliability in the CIPP access permissions testing process. --- .../Public/Test-CIPPAccessPermissions.ps1 | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/backend/Modules/CIPPCore/Public/Test-CIPPAccessPermissions.ps1 b/backend/Modules/CIPPCore/Public/Test-CIPPAccessPermissions.ps1 index 1dc4048459..b71b3360b0 100644 --- a/backend/Modules/CIPPCore/Public/Test-CIPPAccessPermissions.ps1 +++ b/backend/Modules/CIPPCore/Public/Test-CIPPAccessPermissions.ps1 @@ -162,6 +162,55 @@ function Test-CIPPAccessPermissions { $ApplicationToken = Get-GraphToken -returnRefresh $true -SkipCache $true -AsApp $true $ApplicationTokenDetails = Read-JwtAccessDetails -Token $ApplicationToken.access_token -erroraction SilentlyContinue | Select-Object + # CIPP auto-rotates the SAM app secret within 30 days of expiry (Start-UpdateTokensTimer). + # Only warn when the credential stored in Key Vault (or DevSecrets) is inside that window -5 days. This should not happen. But sometimes it does. + $RotationThresholdDays = 25 + $RotationCutoffUtc = (Get-Date).ToUniversalTime().AddDays($RotationThresholdDays) + $NowUtc = (Get-Date).ToUniversalTime() + $PlaceholderPattern = '^(LongApplicationId|AppSecret|RefreshToken|tenantId)$' + + try { + $KvApplicationSecret = $null + if ($env:MSI_SECRET) { + $KV = Get-CippKeyVaultName + $KvApplicationSecret = Get-CippKeyVaultSecret -VaultName $KV -Name 'ApplicationSecret' -AsPlainText + if ($env:ApplicationSecret -and $KvApplicationSecret -and $env:ApplicationSecret -ne $KvApplicationSecret) { + $ErrorMessages.Add('Your application secret in memory does not match Key Vault, wait 30 minutes for the function app to update.') | Out-Null + $Success = $false + } + } elseif ($env:AzureWebJobsStorage -eq 'UseDevelopmentStorage=true' -or $env:NonLocalHostAzurite -eq 'true') { + $DevSecretsTable = Get-CIPPTable -tablename 'DevSecrets' + $DevSecret = Get-CIPPAzDataTableEntity @DevSecretsTable -Filter "PartitionKey eq 'Secret' and RowKey eq 'Secret'" + $KvApplicationSecret = $DevSecret.ApplicationSecret + } else { + $KvApplicationSecret = $env:ApplicationSecret + } + + if ($env:ApplicationID -and $KvApplicationSecret -and $KvApplicationSecret -notmatch $PlaceholderPattern) { + $AppRegistration = New-GraphGetRequest -uri "https://graph.microsoft.com/v1.0/applications(appId='$($env:ApplicationID)')?`$select=passwordCredentials" -NoAuthCheck $true -AsApp $true -ErrorAction Stop + $PasswordCredentials = @($AppRegistration.passwordCredentials) + + # Graph hint is the first three characters of the secret value. + $StoredCredential = $PasswordCredentials | Where-Object { + $_.hint -and $KvApplicationSecret.StartsWith($_.hint, [System.StringComparison]::OrdinalIgnoreCase) + } | Select-Object -First 1 + + if ($StoredCredential) { + $SecretExpiryUtc = [DateTime]::SpecifyKind([DateTime]$StoredCredential.endDateTime, [DateTimeKind]::Utc) + if ($SecretExpiryUtc -lt $NowUtc) { + $ErrorMessages.Add("The application secret stored in Key Vault expired on $($SecretExpiryUtc.ToString('yyyy-MM-dd')).") | Out-Null + $Success = $false + } elseif ($SecretExpiryUtc -lt $RotationCutoffUtc) { + $DaysRemaining = [Math]::Ceiling(($SecretExpiryUtc - $NowUtc).TotalDays) + $ErrorMessages.Add("The application secret stored in Key Vault expires in $DaysRemaining days ($($SecretExpiryUtc.ToString('yyyy-MM-dd'))).") | Out-Null + $Success = $false + } + } + } + } catch { + $Messages.Add('Could not verify the application secret stored in Key Vault.') | Out-Null + } + $LastUpdate = [DateTime]::SpecifyKind($GraphPermissions.Timestamp.ToString('yyyy-MM-ddTHH:mm:ssZ'), [DateTimeKind]::Utc) $CpvTable = Get-CippTable -tablename 'cpvtenants' $CpvRefresh = Get-CippAzDataTableEntity @CpvTable -Filter "PartitionKey eq 'Tenant'" From 9dfab85d12f1fe685ec0a60848c55317813a4bcb Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 20 Aug 2026 06:09:11 +0000 Subject: [PATCH 206/226] chore(licenses): update Microsoft license SKU data --- backend/Config/ConversionTable.csv | 20 ++++ frontend/src/data/M365Licenses.json | 160 ++++++++++++++++++++++++++++ 2 files changed, 180 insertions(+) diff --git a/backend/Config/ConversionTable.csv b/backend/Config/ConversionTable.csv index 74131ea425..f15bf16f1f 100644 --- a/backend/Config/ConversionTable.csv +++ b/backend/Config/ConversionTable.csv @@ -1864,6 +1864,7 @@ Microsoft 365 E3,SPE_E3,05e9a617-0261-4cee-bb44-138d3ef5d965,KAIZALA_O365_P3,aeb Microsoft 365 E3,SPE_E3,05e9a617-0261-4cee-bb44-138d3ef5d965,FORMS_PLAN_E3,2789c901-c14e-48ab-a76a-be334d9d793a,Microsoft Forms (Plan E3) Microsoft 365 E3,SPE_E3,05e9a617-0261-4cee-bb44-138d3ef5d965,MDE_LITE,292cc034-7b7c-4950-aaf5-943befd3f1d4,Microsoft Defender for Endpoint Plan 1 Microsoft 365 E3,SPE_E3,05e9a617-0261-4cee-bb44-138d3ef5d965,MICROSOFT_SEARCH,94065c59-bc8e-4e8b-89e5-5138d471eaff,Microsoft Search +Microsoft 365 E3,SPE_E3,05e9a617-0261-4cee-bb44-138d3ef5d965,ATP_ENTERPRISE,f20fedf3-f3c3-43c3-8267-2bfdd51c0939,Microsoft Defender for Office 365 (Plan 1) Microsoft 365 E3 - Unattended License,SPE_E3_RPA1,c2ac2ee4-9bb1-47e4-8541-d689c7e83371,SHAREPOINTENTERPRISE,5dbe027f-2339-4123-9542-606e4d348a72,SharePoint (Plan 2) Microsoft 365 E3 - Unattended License,SPE_E3_RPA1,c2ac2ee4-9bb1-47e4-8541-d689c7e83371,PROJECT_O365_P2,31b4e2fc-4cd6-4e7d-9c1b-41407303bd66,Project for Office (Plan E3) Microsoft 365 E3 - Unattended License,SPE_E3_RPA1,c2ac2ee4-9bb1-47e4-8541-d689c7e83371,MCOSTANDARD,0feaeb32-d00e-4d66-bd5a-43b5b83db82c,Skype for Business Online (Plan 2) @@ -2147,6 +2148,7 @@ Microsoft 365 E3_USGOV_DOD,SPE_E3_USGOV_DOD,d61d61cc-f992-433f-a577-5bd016037eeb Microsoft 365 E3_USGOV_DOD,SPE_E3_USGOV_DOD,d61d61cc-f992-433f-a577-5bd016037eeb,AAD_PREMIUM,41781fb2-bc02-4b7c-bd55-b576c07bb09d,Microsoft Entra ID P1 Microsoft 365 E3_USGOV_DOD,SPE_E3_USGOV_DOD,d61d61cc-f992-433f-a577-5bd016037eeb,SHAREPOINTENTERPRISE,5dbe027f-2339-4123-9542-606e4d348a72,SharePoint Online (Plan 2) Microsoft 365 E3_USGOV_DOD,SPE_E3_USGOV_DOD,d61d61cc-f992-433f-a577-5bd016037eeb,RMS_S_ENTERPRISE,bea4c11e-220a-4e6d-8eb8-8ea15d019f90,Microsoft Microsoft Entra Rights +Microsoft 365 E3_USGOV_DOD,SPE_E3_USGOV_DOD,d61d61cc-f992-433f-a577-5bd016037eeb,ATP_ENTERPRISE,f20fedf3-f3c3-43c3-8267-2bfdd51c0939,Microsoft Defender for Office 365 (Plan 1) Microsoft 365 E3_USGOV_GCCHIGH,SPE_E3_USGOV_GCCHIGH,ca9d1dd9-dfe9-4fef-b97c-9bc1ea3c3658,OFFICESUBSCRIPTION,43de0ff5-c92c-492b-9116-175376d08c38,Office 365 ProPlus Microsoft 365 E3_USGOV_GCCHIGH,SPE_E3_USGOV_GCCHIGH,ca9d1dd9-dfe9-4fef-b97c-9bc1ea3c3658,STREAM_O365_E3,9e700747-8b1d-45e5-ab8d-ef187ceec156,Microsoft Stream for O365 E3 SKU Microsoft 365 E3_USGOV_GCCHIGH,SPE_E3_USGOV_GCCHIGH,ca9d1dd9-dfe9-4fef-b97c-9bc1ea3c3658,TEAMS_AR_GCCHIGH,9953b155-8aef-4c56-92f3-72b0487fce41,Microsoft Teams for GCCHigh (AR) @@ -2161,6 +2163,7 @@ Microsoft 365 E3_USGOV_GCCHIGH,SPE_E3_USGOV_GCCHIGH,ca9d1dd9-dfe9-4fef-b97c-9bc1 Microsoft 365 E3_USGOV_GCCHIGH,SPE_E3_USGOV_GCCHIGH,ca9d1dd9-dfe9-4fef-b97c-9bc1ea3c3658,RMS_S_PREMIUM,6c57d4b6-3b23-47a5-9bc9-69f17b4947b3,Azure Information Protection Premium P Microsoft 365 E3_USGOV_GCCHIGH,SPE_E3_USGOV_GCCHIGH,ca9d1dd9-dfe9-4fef-b97c-9bc1ea3c3658,RMS_S_ENTERPRISE,bea4c11e-220a-4e6d-8eb8-8ea15d019f90,Microsoft Microsoft Entra Rights Microsoft 365 E3_USGOV_GCCHIGH,SPE_E3_USGOV_GCCHIGH,ca9d1dd9-dfe9-4fef-b97c-9bc1ea3c3658,ADALLOM_S_DISCOVERY,932ad362-64a8-4783-9106-97849a1a30b9,Cloud App Security Discovery +Microsoft 365 E3_USGOV_GCCHIGH,SPE_E3_USGOV_GCCHIGH,ca9d1dd9-dfe9-4fef-b97c-9bc1ea3c3658,ATP_ENTERPRISE,f20fedf3-f3c3-43c3-8267-2bfdd51c0939,Microsoft Defender for Office 365 (Plan 1) Microsoft 365 E5,SPE_E5,06ebc4ee-1bb5-47dd-8120-11324bc54e06,Deskless,8c7d2df8-86f0-4902-b2ed-a0458298f3b3,Microsoft StaffHub Microsoft 365 E5,SPE_E5,06ebc4ee-1bb5-47dd-8120-11324bc54e06,MCOSTANDARD,0feaeb32-d00e-4d66-bd5a-43b5b83db82c,Skype for Business Online (Plan 2) Microsoft 365 E5,SPE_E5,06ebc4ee-1bb5-47dd-8120-11324bc54e06,SHAREPOINTENTERPRISE,5dbe027f-2339-4123-9542-606e4d348a72,SharePoint (Plan 2) @@ -2925,6 +2928,8 @@ Microsoft 365 E5 Suite features,M365_E5_SUITE_COMPONENTS,99cc8282-2f74-4954-83b7 Microsoft 365 E5 Suite features,M365_E5_SUITE_COMPONENTS,99cc8282-2f74-4954-83b7-c6a9a1999067,WINDOWSUPDATEFORBUSINESS_DEPLOYMENTSERVICE,7bf960f6-2cd9-443a-8046-5dbff9558365,Windows Update for Business Deployment Service Microsoft 365 E5 Suite features,M365_E5_SUITE_COMPONENTS,99cc8282-2f74-4954-83b7-c6a9a1999067,Defender_for_Iot_Enterprise,99cd49a9-0e54-4e07-aea1-d8d9f5f704f5,Defender for IoT - Enterprise IoT Security Microsoft 365 E5 Suite features,M365_E5_SUITE_COMPONENTS,99cc8282-2f74-4954-83b7-c6a9a1999067,MESH_AVATARS_ADDITIONAL_FOR_TEAMS,3efbd4ed-8958-4824-8389-1321f8730af8,Avatars for Teams (additional) +Microsoft 365 E5 Suite features,M365_E5_SUITE_COMPONENTS,99cc8282-2f74-4954-83b7-c6a9a1999067,CLOUD_PKI,795aec3a-93a2-45be-92c4-47b9a76340ca,Microsoft Cloud PKI +Microsoft 365 E5 Suite features,M365_E5_SUITE_COMPONENTS,99cc8282-2f74-4954-83b7-c6a9a1999067,3_PARTY_APP_PATCH,3afa0b92-83ef-41c1-8d64-586ab882a951,Intune Enterprise Application Management Microsoft 365 E5 with Calling Minutes,SPE_E5_CALLINGMINUTES,a91fc4e0-65e5-4266-aa76-4037509c1626,PREMIUM_ENCRYPTION,617b097b-4b93-4ede-83de-5f075bb5fb2f,Premium Encryption in Office 365 Microsoft 365 E5 with Calling Minutes,SPE_E5_CALLINGMINUTES,a91fc4e0-65e5-4266-aa76-4037509c1626,BI_AZURE_P2,70d33638-9c74-4d01-bfd3-562de28bd4ba,Power BI Pro Microsoft 365 E5 with Calling Minutes,SPE_E5_CALLINGMINUTES,a91fc4e0-65e5-4266-aa76-4037509c1626,POWERAPPS_O365_P3,9c0dab89-a30c-4117-86e7-97bda240acd2,Power Apps for Office 365 (Plan 3) @@ -3535,6 +3540,7 @@ Microsoft 365 G3 GCC,M365_G3_GOV,e823ca47-49c4-46b3-b38d-ca11d5abe3d2,POWERAPPS_ Microsoft 365 G3 GCC,M365_G3_GOV,e823ca47-49c4-46b3-b38d-ca11d5abe3d2,INTUNE_A,c1ec4a95-1f05-45b3-a911-aa3fa01094f5,Microsoft Intune Plan 1 Microsoft 365 G3 GCC,M365_G3_GOV,e823ca47-49c4-46b3-b38d-ca11d5abe3d2,CDS_O365_P2_GCC,a70bbf38-cdda-470d-adb8-5804b8770f41,Common Data Service for Teams Microsoft 365 G3 GCC,M365_G3_GOV,e823ca47-49c4-46b3-b38d-ca11d5abe3d2,FLOW_O365_P2_GOV,c537f360-6a00-4ace-a7f5-9128d0ac1e4b,Power Automate for Office 365 for Government +Microsoft 365 G3 GCC,M365_G3_GOV,e823ca47-49c4-46b3-b38d-ca11d5abe3d2,ATP_ENTERPRISE,f20fedf3-f3c3-43c3-8267-2bfdd51c0939,Microsoft Defender for Office 365 (Plan 1) Microsoft 365 GCC G5,M365_G5_GCC,e2be619b-b125-455f-8660-fb503e431a5d,FORMS_GOV_E5,843da3a8-d2cc-4e7a-9e90-dc46019f964c,Microsoft Forms for Government (Plan E5) Microsoft 365 GCC G5,M365_G5_GCC,e2be619b-b125-455f-8660-fb503e431a5d,CDS_O365_P3_GCC,bce5e5ca-c2fd-4d53-8ee2-58dfffed4c10,Common Data Service for Teams Microsoft 365 GCC G5,M365_G5_GCC,e2be619b-b125-455f-8660-fb503e431a5d,LOCKBOX_ENTERPRISE_GOV,89b5d3b1-3855-49fe-b46c-87c66dbc1526,Customer Lockbox for Government @@ -3999,6 +4005,12 @@ Microsoft Teams Premium Introductory Pricing,Microsoft_Teams_Premium,36a0f3b3-ad Microsoft Teams Premium Introductory Pricing,Microsoft_Teams_Premium,36a0f3b3-adb5-49ea-bf66-762134cf063a,MCO_VIRTUAL_APPT,711413d0-b36e-4cd4-93db-0a50a4ab7ea3,Microsoft Teams Premium Virtual Appointments Microsoft Teams Premium Introductory Pricing,Microsoft_Teams_Premium,36a0f3b3-adb5-49ea-bf66-762134cf063a,TEAMSPRO_PROTECTION,f8b44f54-18bb-46a3-9658-44ab58712968,Microsoft Teams Premium Secure Microsoft Teams Premium Introductory Pricing,Microsoft_Teams_Premium,36a0f3b3-adb5-49ea-bf66-762134cf063a,TEAMSPRO_WEBINAR,78b58230-ec7e-4309-913c-93a45cc4735b,Microsoft Teams Premium Webinar +Microsoft Teams Premium,M365_TEAMS_PREMIUM,6432c818-bcef-43b6-9290-aec052964950,TEAMSPRO_MGMT,0504111f-feb8-4a3c-992a-70280f9a2869,Microsoft Teams Premium Intelligent +Microsoft Teams Premium,M365_TEAMS_PREMIUM,6432c818-bcef-43b6-9290-aec052964950,TEAMSPRO_CUST,cc8c0802-a325-43df-8cba-995d0c6cb373,Microsoft Teams Premium Personalized +Microsoft Teams Premium,M365_TEAMS_PREMIUM,6432c818-bcef-43b6-9290-aec052964950,TEAMSPRO_PROTECTION,f8b44f54-18bb-46a3-9658-44ab58712968,Microsoft Teams Premium Secure +Microsoft Teams Premium,M365_TEAMS_PREMIUM,6432c818-bcef-43b6-9290-aec052964950,TEAMSPRO_VIRTUALAPPT,9104f592-f2a7-4f77-904c-ca5a5715883f,Microsoft Teams Premium Virtual Appointment +Microsoft Teams Premium,M365_TEAMS_PREMIUM,6432c818-bcef-43b6-9290-aec052964950,MCO_VIRTUAL_APPT,711413d0-b36e-4cd4-93db-0a50a4ab7ea3,Microsoft Teams Premium Virtual Appointments +Microsoft Teams Premium,M365_TEAMS_PREMIUM,6432c818-bcef-43b6-9290-aec052964950,QUEUES_APP,ab2d4fb5-f80a-4bf1-a11d-7f1da254041b,Queues app for Microsoft Teams Microsoft Teams Rooms Basic,Microsoft_Teams_Rooms_Basic,6af4b3d6-14bb-4a2a-960c-6c902aad34f3,MCOMEETADV,3e26ee1f-8a5f-4d52-aee2-b81ce45c8f40,Microsoft 365 Audio Conferencing Microsoft Teams Rooms Basic,Microsoft_Teams_Rooms_Basic,6af4b3d6-14bb-4a2a-960c-6c902aad34f3,TEAMS1,57ff2da0-773e-42df-b2af-ffb7a2317929,Microsoft Teams Microsoft Teams Rooms Basic,Microsoft_Teams_Rooms_Basic,6af4b3d6-14bb-4a2a-960c-6c902aad34f3,Teams_Rooms_Basic,c8529366-cffd-4415-ab8f-be0144a33ab1,Teams Rooms Basic @@ -4020,6 +4032,7 @@ Microsoft Teams Rooms Pro,Microsoft_Teams_Rooms_Pro,4cde982a-ede4-4409-9ae6-b003 Microsoft Teams Rooms Pro,Microsoft_Teams_Rooms_Pro,4cde982a-ede4-4409-9ae6-b003453c8ea6,TEAMS1,57ff2da0-773e-42df-b2af-ffb7a2317929,Microsoft Teams Microsoft Teams Rooms Pro,Microsoft_Teams_Rooms_Pro,4cde982a-ede4-4409-9ae6-b003453c8ea6,MCOSTANDARD,0feaeb32-d00e-4d66-bd5a-43b5b83db82c,Skype for Business Online (Plan 2) Microsoft Teams Rooms Pro,Microsoft_Teams_Rooms_Pro,4cde982a-ede4-4409-9ae6-b003453c8ea6,WHITEBOARD_PLAN3,4a51bca5-1eff-43f5-878c-177680f191af,Whiteboard (Plan 3) +Microsoft Teams Rooms Pro,Microsoft_Teams_Rooms_Pro,4cde982a-ede4-4409-9ae6-b003453c8ea6,MICROSOFT_TEAMS_EVENTS,29c62f1c-8ffc-4304-9cb9-398a6aa1852b,Microsoft Teams Events Microsoft Teams Rooms Pro for EDU,Microsoft_Teams_Rooms_Pro_FAC,c25e2b36-e161-4946-bef2-69239729f690,AAD_BASIC_EDU,1d0f309f-fdf9-4b2a-9ae7-9c48b91f1426,Azure Active Directory Basic for Education Microsoft Teams Rooms Pro for EDU,Microsoft_Teams_Rooms_Pro_FAC,c25e2b36-e161-4946-bef2-69239729f690,MCOMEETADV,3e26ee1f-8a5f-4d52-aee2-b81ce45c8f40,Microsoft 365 Audio Conferencing Microsoft Teams Rooms Pro for EDU,Microsoft_Teams_Rooms_Pro_FAC,c25e2b36-e161-4946-bef2-69239729f690,MCOEV,4828c8ec-dc2e-4779-b502-87ac9ce28ab7,Microsoft 365 Phone System @@ -4508,6 +4521,7 @@ Office 365 E1 (no Teams),Office_365_E1_(no_Teams),f8ced641-8e17-4dc5-b014-f5a2d5 Office 365 E1 (no Teams),Office_365_E1_(no_Teams),f8ced641-8e17-4dc5-b014-f5a2d53f6ac8,DYN365_CDS_O365_P1,40b010bb-0b69-4654-ac5e-ba161433f4b4,Common Data Service Office 365 E1 (no Teams),Office_365_E1_(no_Teams),f8ced641-8e17-4dc5-b014-f5a2d53f6ac8,MICROSOFTBOOKINGS,199a5c09-e0ca-4e37-8f7c-b05d533e1ea2,Microsoft Bookings Office 365 E1 (no Teams),Office_365_E1_(no_Teams),f8ced641-8e17-4dc5-b014-f5a2d53f6ac8,SHAREPOINTWAC,e95bec33-7c88-4a70-8e19-b10bd9d0c014,Office for the Web +Office 365 E1 (no Teams),Office_365_E1_(no_Teams),f8ced641-8e17-4dc5-b014-f5a2d53f6ac8,MDOLITE_ENTERPRISE,c6675fa4-68fe-415f-aec1-a44520f0c3a3,Microsoft 365 built-in email and collaboration security Office 365 E1 EEA (no Teams),Office_365_w/o_Teams_Bundle_E1,b57282e3-65bd-4252-9502-c0eae1e5ab7f,SHAREPOINTWAC,e95bec33-7c88-4a70-8e19-b10bd9d0c014,Office for the Web Office 365 E1 EEA (no Teams),Office_365_w/o_Teams_Bundle_E1,b57282e3-65bd-4252-9502-c0eae1e5ab7f,YAMMER_ENTERPRISE,7547a3fe-08ee-4ccb-b430-5077c5041653,Yammer Enterprise Office 365 E1 EEA (no Teams),Office_365_w/o_Teams_Bundle_E1,b57282e3-65bd-4252-9502-c0eae1e5ab7f,VIVAENGAGE_CORE,a82fbf69-b4d7-49f4-83a6-915b2cf354f4,Viva Engage Core @@ -4604,6 +4618,7 @@ Office 365 E3,ENTERPRISEPACK,6fd2c87f-b296-42f0-b197-1e91e994b900,FLOW_O365_P2,7 Office 365 E3,ENTERPRISEPACK,6fd2c87f-b296-42f0-b197-1e91e994b900,POWERAPPS_O365_P2,c68f8d98-5534-41c8-bf36-22fa496fa792,Power Apps for Office 365 Office 365 E3,ENTERPRISEPACK,6fd2c87f-b296-42f0-b197-1e91e994b900,YAMMER_ENTERPRISE,7547a3fe-08ee-4ccb-b430-5077c5041653,Yammer Enterprise Office 365 E3,ENTERPRISEPACK,6fd2c87f-b296-42f0-b197-1e91e994b900,POWER_VIRTUAL_AGENTS_O365_P2,041fe683-03e4-45b6-b1af-c0cdc516daee,Power Virtual Agents for Office 365 +Office 365 E3,ENTERPRISEPACK,6fd2c87f-b296-42f0-b197-1e91e994b900,ATP_ENTERPRISE,f20fedf3-f3c3-43c3-8267-2bfdd51c0939,Microsoft Defender for Office 365 (Plan 1) Office 365 E3 (no Teams),Office_365_E3_(no_Teams),46c3a859-c90d-40b3-9551-6178a48d5c18,MESH_AVATARS_FOR_TEAMS,dcf9d2f4-772e-4434-b757-77a453cfbc02,Avatars for Teams Office 365 E3 (no Teams),Office_365_E3_(no_Teams),46c3a859-c90d-40b3-9551-6178a48d5c18,KAIZALA_O365_P3,aebd3021-9f8f-4bf8-bbe3-0ed2f4f047a1,Microsoft Kaizala Pro Office 365 E3 (no Teams),Office_365_E3_(no_Teams),46c3a859-c90d-40b3-9551-6178a48d5c18,FORMS_PLAN_E3,2789c901-c14e-48ab-a76a-be334d9d793a,Microsoft Forms (Plan E3) @@ -4694,6 +4709,7 @@ Office 365 E3_USGOV_DOD,ENTERPRISEPACK_USGOV_DOD,b107e5a3-3e60-4c0d-a184-a7e4395 Office 365 E3_USGOV_DOD,ENTERPRISEPACK_USGOV_DOD,b107e5a3-3e60-4c0d-a184-a7e4395eb44c,SHAREPOINTENTERPRISE,5dbe027f-2339-4123-9542-606e4d348a72,SharePoint Online (Plan 2) Office 365 E3_USGOV_DOD,ENTERPRISEPACK_USGOV_DOD,b107e5a3-3e60-4c0d-a184-a7e4395eb44c,MCOSTANDARD,0feaeb32-d00e-4d66-bd5a-43b5b83db82c,Skype for Business Online (Plan 2) Office 365 E3_USGOV_DOD,ENTERPRISEPACK_USGOV_DOD,b107e5a3-3e60-4c0d-a184-a7e4395eb44c,OFFICESUBSCRIPTION,43de0ff5-c92c-492b-9116-175376d08c38,Office 365 ProPlus +Office 365 E3_USGOV_DOD,ENTERPRISEPACK_USGOV_DOD,b107e5a3-3e60-4c0d-a184-a7e4395eb44c,ATP_ENTERPRISE,f20fedf3-f3c3-43c3-8267-2bfdd51c0939,Microsoft Defender for Office 365 (Plan 1) Office 365 E3_USGOV_GCCHIGH,ENTERPRISEPACK_USGOV_GCCHIGH,aea38a85-9bd5-4981-aa00-616b411205bf,MCOSTANDARD,0feaeb32-d00e-4d66-bd5a-43b5b83db82c,Skype for Business Online (Plan 2) Office 365 E3_USGOV_GCCHIGH,ENTERPRISEPACK_USGOV_GCCHIGH,aea38a85-9bd5-4981-aa00-616b411205bf,SHAREPOINTENTERPRISE,5dbe027f-2339-4123-9542-606e4d348a72,SharePoint Online (Plan 2) Office 365 E3_USGOV_GCCHIGH,ENTERPRISEPACK_USGOV_GCCHIGH,aea38a85-9bd5-4981-aa00-616b411205bf,SHAREPOINTWAC,e95bec33-7c88-4a70-8e19-b10bd9d0c014,Office Online @@ -4703,6 +4719,7 @@ Office 365 E3_USGOV_GCCHIGH,ENTERPRISEPACK_USGOV_GCCHIGH,aea38a85-9bd5-4981-aa00 Office 365 E3_USGOV_GCCHIGH,ENTERPRISEPACK_USGOV_GCCHIGH,aea38a85-9bd5-4981-aa00-616b411205bf,RMS_S_ENTERPRISE,bea4c11e-220a-4e6d-8eb8-8ea15d019f90,Microsoft Microsoft Entra Rights Office 365 E3_USGOV_GCCHIGH,ENTERPRISEPACK_USGOV_GCCHIGH,aea38a85-9bd5-4981-aa00-616b411205bf,EXCHANGE_S_ENTERPRISE,efb87545-963c-4e0d-99df-69c6916d9eb0,Exchange Online (Plan 2) Office 365 E3_USGOV_GCCHIGH,ENTERPRISEPACK_USGOV_GCCHIGH,aea38a85-9bd5-4981-aa00-616b411205bf,TEAMS_AR_GCCHIGH,9953b155-8aef-4c56-92f3-72b0487fce41,Microsoft Teams for GCCHigh (AR) +Office 365 E3_USGOV_GCCHIGH,ENTERPRISEPACK_USGOV_GCCHIGH,aea38a85-9bd5-4981-aa00-616b411205bf,ATP_ENTERPRISE,f20fedf3-f3c3-43c3-8267-2bfdd51c0939,Microsoft Defender for Office 365 (Plan 1) Office 365 E4,ENTERPRISEWITHSCAL,1392051d-0cb9-4b7a-88d5-621fee5e8711,BPOS_S_TODO_2,c87f142c-d1e9-4363-8630-aaea9c4d9ae5,BPOS_S_TODO_2 Office 365 E4,ENTERPRISEWITHSCAL,1392051d-0cb9-4b7a-88d5-621fee5e8711,Deskless,8c7d2df8-86f0-4902-b2ed-a0458298f3b3,MICROSOFT STAFFHUB Office 365 E4,ENTERPRISEWITHSCAL,1392051d-0cb9-4b7a-88d5-621fee5e8711,FLOW_O365_P2,76846ad7-7776-4c40-a281-a386362dd1b9,FLOW FOR OFFICE 365 @@ -4776,6 +4793,7 @@ Office 365 E5,ENTERPRISEPREMIUM,c7df2760-2c81-4ef7-b578-5b5392b571df,Deskless,8c Office 365 E5,ENTERPRISEPREMIUM,c7df2760-2c81-4ef7-b578-5b5392b571df,STREAM_O365_E5,6c6042f5-6f01-4d67-b8c1-eb99d36eed3e,Microsoft Stream for O365 E5 SKU Office 365 E5,ENTERPRISEPREMIUM,c7df2760-2c81-4ef7-b578-5b5392b571df,TEAMS1,57ff2da0-773e-42df-b2af-ffb7a2317929,Microsoft Teams Office 365 E5,ENTERPRISEPREMIUM,c7df2760-2c81-4ef7-b578-5b5392b571df,RECORDS_MANAGEMENT,65cc641f-cccd-4643-97e0-a17e3045e541,Microsoft Records Management +Office 365 E5,ENTERPRISEPREMIUM,c7df2760-2c81-4ef7-b578-5b5392b571df,MICROSOFT_TEAMS_EVENTS,29c62f1c-8ffc-4304-9cb9-398a6aa1852b,Microsoft Teams Events Office 365 E5 EEA (no Teams),Office_365_w/o_Teams_Bundle_E5,cf50bae9-29e8-4775-b07c-56ee10e3776d,DYN365_CDS_O365_P3,28b0fa46-c39a-4188-89e2-58e979a6b014,Common Data Service Office 365 E5 EEA (no Teams),Office_365_w/o_Teams_Bundle_E5,cf50bae9-29e8-4775-b07c-56ee10e3776d,POWER_VIRTUAL_AGENTS_O365_P3,ded3d325-1bdc-453e-8432-5bac26d7a014,Power Virtual Agents for Office 365 Office 365 E5 EEA (no Teams),Office_365_w/o_Teams_Bundle_E5,cf50bae9-29e8-4775-b07c-56ee10e3776d,BI_AZURE_P2,70d33638-9c74-4d01-bfd3-562de28bd4ba,Power BI Pro @@ -5059,6 +5077,7 @@ Office 365 G3 GCC,ENTERPRISEPACK_GOV,535a3a29-c5f0-42fe-8215-d3b9e1f38c4a,MIP_S_ Office 365 G3 GCC,ENTERPRISEPACK_GOV,535a3a29-c5f0-42fe-8215-d3b9e1f38c4a,ContentExplorer_Standard,2b815d45-56e4-4e3a-b65c-66cb9175b560,Information Protection and Governance Analytics – Standard Office 365 G3 GCC,ENTERPRISEPACK_GOV,535a3a29-c5f0-42fe-8215-d3b9e1f38c4a,PROJECT_O365_P2_GOV,e7d09ae4-099a-4c34-a2a2-3e166e95c44a,Project for Government (Plan E3) Office 365 G3 GCC,ENTERPRISEPACK_GOV,535a3a29-c5f0-42fe-8215-d3b9e1f38c4a,MYANALYTICS_P2_GOV,6e5b7995-bd4f-4cbd-9d19-0e32010c72f0,Insights by MyAnalytics for Government +Office 365 G3 GCC,ENTERPRISEPACK_GOV,535a3a29-c5f0-42fe-8215-d3b9e1f38c4a,ATP_ENTERPRISE,f20fedf3-f3c3-43c3-8267-2bfdd51c0939,Microsoft Defender for Office 365 (Plan 1) Office 365 G3 without Microsoft 365 Apps GCC,ENTERPRISEPACKWITHOUTPROPLUS_GOV,24aebea8-7fac-48d0-8750-de4ee1fde205,CDS_O365_P2_GCC,a70bbf38-cdda-470d-adb8-5804b8770f41,Common Data Service for Teams Office 365 G3 without Microsoft 365 Apps GCC,ENTERPRISEPACKWITHOUTPROPLUS_GOV,24aebea8-7fac-48d0-8750-de4ee1fde205,EXCHANGE_S_ENTERPRISE_GOV,8c3069c0-ccdb-44be-ab77-986203a67df2,Exchange Online (Plan 2) for Government Office 365 G3 without Microsoft 365 Apps GCC,ENTERPRISEPACKWITHOUTPROPLUS_GOV,24aebea8-7fac-48d0-8750-de4ee1fde205,MIP_S_CLP1,5136a095-5cf0-4aff-bec3-e84448b38ea5,Information Protection for Office 365 - Standard @@ -5654,6 +5673,7 @@ Skype for Business Online (Plan 1),MCOIMP,b8b749f8-a4ef-4887-9539-c95b1eaa5db7,M Skype for Business Online (Plan 2),MCOSTANDARD,d42c793f-6c78-4f43-92ca-e8f6a02b035f,MCOSTANDARD,0feaeb32-d00e-4d66-bd5a-43b5b83db82c,SKYPE FOR BUSINESS ONLINE (PLAN 2) Skype for Business PSTN Calling Domestic Small,MCOPSTN5,d43177b5-475b-4880-92d4-d54c27b5efbd,Skype for Business PSTN Calling Domestic Small,9a0125a5-c8f8-4526-b231-49e2abe0ebce,Skype for Business PSTN Calling Domestic Small Skype for Business PSTN Domestic and International Calling,MCOPSTN2,d3b4fe1f-9992-4930-8acb-ca6ec609365e,MCOPSTN2,5a10155d-f5c1-411a-a8ec-e99aae125390,DOMESTIC AND INTERNATIONAL CALLING PLAN +Skype for Business PSTN Domestic and International Calling,MCOPSTN2,d3b4fe1f-9992-4930-8acb-ca6ec609365e,MCOSMS2,d4009785-b899-4cab-97b6-d06a7c799507,DOMESTIC AND INTERNATIONAL CALLING PLAN Skype for Business PSTN Domestic Calling,MCOPSTN1,0dab259f-bf13-4952-b7f8-7db8f131b28d,MCOPSTN1,4ed3ff63-69d7-4fb7-b984-5aec7f605ca8,DOMESTIC CALLING PLAN Skype for Business PSTN Domestic Calling (120 Minutes),MCOPSTN5,54a152dc-90de-4996-93d2-bc47e670fc06,MCOPSTN5,54a152dc-90de-4996-93d2-bc47e670fc06,DOMESTIC CALLING PLAN Skype for Business PSTN Usage Calling Plan,MCOPSTNPP,06b48c5f-01d9-4b18-9015-03b52040f51a,MCOPSTN3,6b340437-d6f9-4dc5-8cc2-99163f7f83d6,MCOPSTN3 diff --git a/frontend/src/data/M365Licenses.json b/frontend/src/data/M365Licenses.json index e83914500e..12bf255142 100644 --- a/frontend/src/data/M365Licenses.json +++ b/frontend/src/data/M365Licenses.json @@ -14919,6 +14919,14 @@ "Service_Plan_Id": "94065c59-bc8e-4e8b-89e5-5138d471eaff", "Service_Plans_Included_Friendly_Names": "Microsoft Search" }, + { + "Product_Display_Name": "Microsoft 365 E3", + "String_Id": "SPE_E3", + "GUID": "05e9a617-0261-4cee-bb44-138d3ef5d965", + "Service_Plan_Name": "ATP_ENTERPRISE", + "Service_Plan_Id": "f20fedf3-f3c3-43c3-8267-2bfdd51c0939", + "Service_Plans_Included_Friendly_Names": "Microsoft Defender for Office 365 (Plan 1)" + }, { "Product_Display_Name": "Microsoft 365 E3 - Unattended License", "String_Id": "SPE_E3_RPA1", @@ -17183,6 +17191,14 @@ "Service_Plan_Id": "bea4c11e-220a-4e6d-8eb8-8ea15d019f90", "Service_Plans_Included_Friendly_Names": "Microsoft Microsoft Entra Rights" }, + { + "Product_Display_Name": "Microsoft 365 E3_USGOV_DOD", + "String_Id": "SPE_E3_USGOV_DOD", + "GUID": "d61d61cc-f992-433f-a577-5bd016037eeb", + "Service_Plan_Name": "ATP_ENTERPRISE", + "Service_Plan_Id": "f20fedf3-f3c3-43c3-8267-2bfdd51c0939", + "Service_Plans_Included_Friendly_Names": "Microsoft Defender for Office 365 (Plan 1)" + }, { "Product_Display_Name": "Microsoft 365 E3_USGOV_GCCHIGH", "String_Id": "SPE_E3_USGOV_GCCHIGH", @@ -17295,6 +17311,14 @@ "Service_Plan_Id": "932ad362-64a8-4783-9106-97849a1a30b9", "Service_Plans_Included_Friendly_Names": "Cloud App Security Discovery" }, + { + "Product_Display_Name": "Microsoft 365 E3_USGOV_GCCHIGH", + "String_Id": "SPE_E3_USGOV_GCCHIGH", + "GUID": "ca9d1dd9-dfe9-4fef-b97c-9bc1ea3c3658", + "Service_Plan_Name": "ATP_ENTERPRISE", + "Service_Plan_Id": "f20fedf3-f3c3-43c3-8267-2bfdd51c0939", + "Service_Plans_Included_Friendly_Names": "Microsoft Defender for Office 365 (Plan 1)" + }, { "Product_Display_Name": "Microsoft 365 E5", "String_Id": "SPE_E5", @@ -23407,6 +23431,22 @@ "Service_Plan_Id": "3efbd4ed-8958-4824-8389-1321f8730af8", "Service_Plans_Included_Friendly_Names": "Avatars for Teams (additional)" }, + { + "Product_Display_Name": "Microsoft 365 E5 Suite features", + "String_Id": "M365_E5_SUITE_COMPONENTS", + "GUID": "99cc8282-2f74-4954-83b7-c6a9a1999067", + "Service_Plan_Name": "CLOUD_PKI", + "Service_Plan_Id": "795aec3a-93a2-45be-92c4-47b9a76340ca", + "Service_Plans_Included_Friendly_Names": "Microsoft Cloud PKI" + }, + { + "Product_Display_Name": "Microsoft 365 E5 Suite features", + "String_Id": "M365_E5_SUITE_COMPONENTS", + "GUID": "99cc8282-2f74-4954-83b7-c6a9a1999067", + "Service_Plan_Name": "3_PARTY_APP_PATCH", + "Service_Plan_Id": "3afa0b92-83ef-41c1-8d64-586ab882a951", + "Service_Plans_Included_Friendly_Names": "Intune Enterprise Application Management" + }, { "Product_Display_Name": "Microsoft 365 E5 with Calling Minutes", "String_Id": "SPE_E5_CALLINGMINUTES", @@ -28287,6 +28327,14 @@ "Service_Plan_Id": "c537f360-6a00-4ace-a7f5-9128d0ac1e4b", "Service_Plans_Included_Friendly_Names": "Power Automate for Office 365 for Government" }, + { + "Product_Display_Name": "Microsoft 365 G3 GCC", + "String_Id": "M365_G3_GOV", + "GUID": "e823ca47-49c4-46b3-b38d-ca11d5abe3d2", + "Service_Plan_Name": "ATP_ENTERPRISE", + "Service_Plan_Id": "f20fedf3-f3c3-43c3-8267-2bfdd51c0939", + "Service_Plans_Included_Friendly_Names": "Microsoft Defender for Office 365 (Plan 1)" + }, { "Product_Display_Name": "Microsoft 365 GCC G5", "String_Id": "M365_G5_GCC", @@ -31999,6 +32047,54 @@ "Service_Plan_Id": "78b58230-ec7e-4309-913c-93a45cc4735b", "Service_Plans_Included_Friendly_Names": "Microsoft Teams Premium Webinar" }, + { + "Product_Display_Name": "Microsoft Teams Premium", + "String_Id": "M365_TEAMS_PREMIUM", + "GUID": "6432c818-bcef-43b6-9290-aec052964950", + "Service_Plan_Name": "TEAMSPRO_MGMT", + "Service_Plan_Id": "0504111f-feb8-4a3c-992a-70280f9a2869", + "Service_Plans_Included_Friendly_Names": "Microsoft Teams Premium Intelligent" + }, + { + "Product_Display_Name": "Microsoft Teams Premium", + "String_Id": "M365_TEAMS_PREMIUM", + "GUID": "6432c818-bcef-43b6-9290-aec052964950", + "Service_Plan_Name": "TEAMSPRO_CUST", + "Service_Plan_Id": "cc8c0802-a325-43df-8cba-995d0c6cb373", + "Service_Plans_Included_Friendly_Names": "Microsoft Teams Premium Personalized" + }, + { + "Product_Display_Name": "Microsoft Teams Premium", + "String_Id": "M365_TEAMS_PREMIUM", + "GUID": "6432c818-bcef-43b6-9290-aec052964950", + "Service_Plan_Name": "TEAMSPRO_PROTECTION", + "Service_Plan_Id": "f8b44f54-18bb-46a3-9658-44ab58712968", + "Service_Plans_Included_Friendly_Names": "Microsoft Teams Premium Secure" + }, + { + "Product_Display_Name": "Microsoft Teams Premium", + "String_Id": "M365_TEAMS_PREMIUM", + "GUID": "6432c818-bcef-43b6-9290-aec052964950", + "Service_Plan_Name": "TEAMSPRO_VIRTUALAPPT", + "Service_Plan_Id": "9104f592-f2a7-4f77-904c-ca5a5715883f", + "Service_Plans_Included_Friendly_Names": "Microsoft Teams Premium Virtual Appointment" + }, + { + "Product_Display_Name": "Microsoft Teams Premium", + "String_Id": "M365_TEAMS_PREMIUM", + "GUID": "6432c818-bcef-43b6-9290-aec052964950", + "Service_Plan_Name": "MCO_VIRTUAL_APPT", + "Service_Plan_Id": "711413d0-b36e-4cd4-93db-0a50a4ab7ea3", + "Service_Plans_Included_Friendly_Names": "Microsoft Teams Premium Virtual Appointments" + }, + { + "Product_Display_Name": "Microsoft Teams Premium", + "String_Id": "M365_TEAMS_PREMIUM", + "GUID": "6432c818-bcef-43b6-9290-aec052964950", + "Service_Plan_Name": "QUEUES_APP", + "Service_Plan_Id": "ab2d4fb5-f80a-4bf1-a11d-7f1da254041b", + "Service_Plans_Included_Friendly_Names": "Queues app for Microsoft Teams" + }, { "Product_Display_Name": "Microsoft Teams Rooms Basic", "String_Id": "Microsoft_Teams_Rooms_Basic", @@ -32167,6 +32263,14 @@ "Service_Plan_Id": "4a51bca5-1eff-43f5-878c-177680f191af", "Service_Plans_Included_Friendly_Names": "Whiteboard (Plan 3)" }, + { + "Product_Display_Name": "Microsoft Teams Rooms Pro", + "String_Id": "Microsoft_Teams_Rooms_Pro", + "GUID": "4cde982a-ede4-4409-9ae6-b003453c8ea6", + "Service_Plan_Name": "MICROSOFT_TEAMS_EVENTS", + "Service_Plan_Id": "29c62f1c-8ffc-4304-9cb9-398a6aa1852b", + "Service_Plans_Included_Friendly_Names": "Microsoft Teams Events" + }, { "Product_Display_Name": "Microsoft Teams Rooms Pro for EDU", "String_Id": "Microsoft_Teams_Rooms_Pro_FAC", @@ -36071,6 +36175,14 @@ "Service_Plan_Id": "e95bec33-7c88-4a70-8e19-b10bd9d0c014", "Service_Plans_Included_Friendly_Names": "Office for the Web" }, + { + "Product_Display_Name": "Office 365 E1 (no Teams)", + "String_Id": "Office_365_E1_(no_Teams)", + "GUID": "f8ced641-8e17-4dc5-b014-f5a2d53f6ac8", + "Service_Plan_Name": "MDOLITE_ENTERPRISE", + "Service_Plan_Id": "c6675fa4-68fe-415f-aec1-a44520f0c3a3", + "Service_Plans_Included_Friendly_Names": "Microsoft 365 built-in email and collaboration security" + }, { "Product_Display_Name": "Office 365 E1 EEA (no Teams)", "String_Id": "Office_365_w/o_Teams_Bundle_E1", @@ -36839,6 +36951,14 @@ "Service_Plan_Id": "041fe683-03e4-45b6-b1af-c0cdc516daee", "Service_Plans_Included_Friendly_Names": "Power Virtual Agents for Office 365" }, + { + "Product_Display_Name": "Office 365 E3", + "String_Id": "ENTERPRISEPACK", + "GUID": "6fd2c87f-b296-42f0-b197-1e91e994b900", + "Service_Plan_Name": "ATP_ENTERPRISE", + "Service_Plan_Id": "f20fedf3-f3c3-43c3-8267-2bfdd51c0939", + "Service_Plans_Included_Friendly_Names": "Microsoft Defender for Office 365 (Plan 1)" + }, { "Product_Display_Name": "Office 365 E3 (no Teams)", "String_Id": "Office_365_E3_(no_Teams)", @@ -37559,6 +37679,14 @@ "Service_Plan_Id": "43de0ff5-c92c-492b-9116-175376d08c38", "Service_Plans_Included_Friendly_Names": "Office 365 ProPlus" }, + { + "Product_Display_Name": "Office 365 E3_USGOV_DOD", + "String_Id": "ENTERPRISEPACK_USGOV_DOD", + "GUID": "b107e5a3-3e60-4c0d-a184-a7e4395eb44c", + "Service_Plan_Name": "ATP_ENTERPRISE", + "Service_Plan_Id": "f20fedf3-f3c3-43c3-8267-2bfdd51c0939", + "Service_Plans_Included_Friendly_Names": "Microsoft Defender for Office 365 (Plan 1)" + }, { "Product_Display_Name": "Office 365 E3_USGOV_GCCHIGH", "String_Id": "ENTERPRISEPACK_USGOV_GCCHIGH", @@ -37631,6 +37759,14 @@ "Service_Plan_Id": "9953b155-8aef-4c56-92f3-72b0487fce41", "Service_Plans_Included_Friendly_Names": "Microsoft Teams for GCCHigh (AR)" }, + { + "Product_Display_Name": "Office 365 E3_USGOV_GCCHIGH", + "String_Id": "ENTERPRISEPACK_USGOV_GCCHIGH", + "GUID": "aea38a85-9bd5-4981-aa00-616b411205bf", + "Service_Plan_Name": "ATP_ENTERPRISE", + "Service_Plan_Id": "f20fedf3-f3c3-43c3-8267-2bfdd51c0939", + "Service_Plans_Included_Friendly_Names": "Microsoft Defender for Office 365 (Plan 1)" + }, { "Product_Display_Name": "Office 365 E4", "String_Id": "ENTERPRISEWITHSCAL", @@ -38215,6 +38351,14 @@ "Service_Plan_Id": "65cc641f-cccd-4643-97e0-a17e3045e541", "Service_Plans_Included_Friendly_Names": "Microsoft Records Management" }, + { + "Product_Display_Name": "Office 365 E5", + "String_Id": "ENTERPRISEPREMIUM", + "GUID": "c7df2760-2c81-4ef7-b578-5b5392b571df", + "Service_Plan_Name": "MICROSOFT_TEAMS_EVENTS", + "Service_Plan_Id": "29c62f1c-8ffc-4304-9cb9-398a6aa1852b", + "Service_Plans_Included_Friendly_Names": "Microsoft Teams Events" + }, { "Product_Display_Name": "Office 365 E5 EEA (no Teams)", "String_Id": "Office_365_w/o_Teams_Bundle_E5", @@ -40479,6 +40623,14 @@ "Service_Plan_Id": "6e5b7995-bd4f-4cbd-9d19-0e32010c72f0", "Service_Plans_Included_Friendly_Names": "Insights by MyAnalytics for Government" }, + { + "Product_Display_Name": "Office 365 G3 GCC", + "String_Id": "ENTERPRISEPACK_GOV", + "GUID": "535a3a29-c5f0-42fe-8215-d3b9e1f38c4a", + "Service_Plan_Name": "ATP_ENTERPRISE", + "Service_Plan_Id": "f20fedf3-f3c3-43c3-8267-2bfdd51c0939", + "Service_Plans_Included_Friendly_Names": "Microsoft Defender for Office 365 (Plan 1)" + }, { "Product_Display_Name": "Office 365 G3 without Microsoft 365 Apps GCC", "String_Id": "ENTERPRISEPACKWITHOUTPROPLUS_GOV", @@ -45239,6 +45391,14 @@ "Service_Plan_Id": "5a10155d-f5c1-411a-a8ec-e99aae125390", "Service_Plans_Included_Friendly_Names": "DOMESTIC AND INTERNATIONAL CALLING PLAN" }, + { + "Product_Display_Name": "Skype for Business PSTN Domestic and International Calling", + "String_Id": "MCOPSTN2", + "GUID": "d3b4fe1f-9992-4930-8acb-ca6ec609365e", + "Service_Plan_Name": "MCOSMS2", + "Service_Plan_Id": "d4009785-b899-4cab-97b6-d06a7c799507", + "Service_Plans_Included_Friendly_Names": "DOMESTIC AND INTERNATIONAL CALLING PLAN" + }, { "Product_Display_Name": "Skype for Business PSTN Domestic Calling", "String_Id": "MCOPSTN1", From 28e7abff02d489378dad1eae0c3c03b233e2ff6f Mon Sep 17 00:00:00 2001 From: Roel van der Wegen Date: Thu, 20 Aug 2026 14:22:18 +0200 Subject: [PATCH 207/226] fix(gdap): ensure OnboardingUrl is set correctly in GDAP invite function (backfill on badly created invites) Updated the Invoke-ListGDAPInvite script to explicitly add the OnboardingUrl property to the InviteRow object. This change addresses the issue where the OnboardingUrl could be null after writing, ensuring that the URL is always correctly formed and available for the onboarding process. --- .../HTTP Functions/Tenant/GDAP/Invoke-ListGDAPInvite.ps1 | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/GDAP/Invoke-ListGDAPInvite.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/GDAP/Invoke-ListGDAPInvite.ps1 index 4db94126d4..f37e4da5be 100644 --- a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/GDAP/Invoke-ListGDAPInvite.ps1 +++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/GDAP/Invoke-ListGDAPInvite.ps1 @@ -24,7 +24,9 @@ function Invoke-ListGDAPInvite { } $Hostname = Get-CIPPHostname -Headers $Request.Headers -PreferCustomDomain if ($Hostname) { - $InviteRow.OnboardingUrl = "https://$Hostname/tenant/gdap-management/onboarding/start?id=$($InviteRow.RowKey)" + $Url = "https://$Hostname/tenant/gdap-management/onboarding/start?id=$($InviteRow.RowKey)" + # Null OnboardingUrl was stripped on write, so the property may not exist on the entity. + $InviteRow | Add-Member -NotePropertyName OnboardingUrl -NotePropertyValue $Url -Force } return $InviteRow } From 4700275efebe73e2d964977dd35696b9f58d79c8 Mon Sep 17 00:00:00 2001 From: Brian Simpson <50429915+bmsimp@users.noreply.github.com> Date: Thu, 20 Aug 2026 08:41:34 -0500 Subject: [PATCH 208/226] docs(cipp): document application secret verification on Permissions page The permissions check now verifies the SAM application secret as well as the application registration's permissions, reporting expiry, imminent expiry and an in-memory mismatch on the Permissions Check card. Update that section to match, and split the intro to remove an em dash. Co-Authored-By: Claude Opus 5 (1M context) --- docs/user-documentation/cipp/settings/permissions.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/user-documentation/cipp/settings/permissions.md b/docs/user-documentation/cipp/settings/permissions.md index 27a26b92d5..95128296ed 100644 --- a/docs/user-documentation/cipp/settings/permissions.md +++ b/docs/user-documentation/cipp/settings/permissions.md @@ -1,6 +1,6 @@ # Permissions -The Permissions page verifies that CIPP has the access it needs to manage your tenants. It runs three checks — one covering the permissions on CIPP's own application registration, one covering your GDAP relationships, and one that tests access to each tenant individually — and lets you export the results as a diagnostic report for troubleshooting or support. Where your tenants are added directly rather than through Microsoft Partner Center relationships, the GDAP check does not apply and access is confirmed per tenant by the Tenants check instead. +The Permissions page verifies that CIPP has the access it needs to manage your tenants. It runs three checks: one covering CIPP's own application registration, one covering your GDAP relationships, and one that tests access to each tenant individually. The results can be exported as a diagnostic report for troubleshooting or support. Where your tenants are added directly rather than through Microsoft Partner Center relationships, the GDAP check does not apply and access is confirmed per tenant by the Tenants check instead. ## Diagnostic Report @@ -19,6 +19,10 @@ While an imported report is being viewed, each affected check is marked with an Checks the permissions granted to CIPP's application registration and reports anything missing. Select **Refresh** to run the check again without using cached results, or **Details** to open a flyout with the full breakdown. The time of the last run is shown beside the buttons. +The check also confirms that the secret CIPP authenticates with as its own application is still valid. CIPP renews that secret automatically ahead of its expiry date, so a problem is only reported where the stored secret has already expired, or is within a few weeks of expiring and has not been renewed. Both are reported on the card with the expiry date, and both need attention, because CIPP uses that secret to obtain access to your tenants. + +Shortly after a renewal, the check can report that the secret CIPP is currently using does not match the one that is stored. That resolves itself once the new value is picked up, which takes up to thirty minutes. Where the secret cannot be checked at all, the card reports that rather than treating it as a failure. + {% hint style="info" %} When this check flags missing permissions or required CPV refreshes, the Details flyout provides buttons to handle these tasks easily. {% endhint %} From 1e5bb2004ac56cf7d68221dbeb0e2264fef8e40a Mon Sep 17 00:00:00 2001 From: jonwbstr Date: Thu, 20 Aug 2026 11:10:52 -0400 Subject: [PATCH 209/226] Update Compliance Portal URL to Purview link Signed-off-by: jonwbstr --- .../CippExtensions/Public/Hudu/Invoke-HuduExtensionSync.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/Modules/CippExtensions/Public/Hudu/Invoke-HuduExtensionSync.ps1 b/backend/Modules/CippExtensions/Public/Hudu/Invoke-HuduExtensionSync.ps1 index b2ff5e4133..87e6d2e76d 100644 --- a/backend/Modules/CippExtensions/Public/Hudu/Invoke-HuduExtensionSync.ps1 +++ b/backend/Modules/CippExtensions/Public/Hudu/Invoke-HuduExtensionSync.ps1 @@ -223,7 +223,7 @@ function Invoke-HuduExtensionSync { if ($Configuration.IncludeComplianceLink) { $Links.Add(@{ Title = 'Compliance Portal' - URL = 'https://compliance.microsoft.com/?tid={0}' -f $Tenant.customerId + URL = 'https://purview.microsoft.com/home?tid={0}' -f $Tenant.customerId Icon = 'fas fa-caret-up' }) } From 86c7aaf3bdc714737bb751dfc21c498f31b46e2f Mon Sep 17 00:00:00 2001 From: jonwbstr Date: Thu, 20 Aug 2026 11:42:55 -0400 Subject: [PATCH 210/226] Add SharePoint Portal link to Hudu Magic Dash Signed-off-by: jonwbstr --- .../CippExtensions/Public/Hudu/Invoke-HuduExtensionSync.ps1 | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/backend/Modules/CippExtensions/Public/Hudu/Invoke-HuduExtensionSync.ps1 b/backend/Modules/CippExtensions/Public/Hudu/Invoke-HuduExtensionSync.ps1 index b2ff5e4133..29d0b66791 100644 --- a/backend/Modules/CippExtensions/Public/Hudu/Invoke-HuduExtensionSync.ps1 +++ b/backend/Modules/CippExtensions/Public/Hudu/Invoke-HuduExtensionSync.ps1 @@ -206,6 +206,11 @@ function Invoke-HuduExtensionSync { Title = 'Teams Portal' URL = 'https://admin.teams.microsoft.com/?delegatedOrg={0}' -f $Tenant.defaultDomainName Icon = 'fas fa-users' + } + @{ + Title = 'SharePoint Portal' + URL = 'https://admin.cloud.microsoft/Partner/beginclientsession.aspx?CTID={0}&CSDEST=SharePoint' -f $Tenant.customerId + Icon = 'fas fa-sitemap' } @{ Title = 'Azure Portal' From 1d374ecfa9a45cd573d6b509313ac13a38a410ad Mon Sep 17 00:00:00 2001 From: Roel van der Wegen Date: Thu, 20 Aug 2026 19:25:37 +0200 Subject: [PATCH 211/226] feat(cipp): enhance group management functions with new capabilities - Added `Add-CIPPGroupOwner` and `Remove-CIPPGroupOwner` functions for managing group ownership. - Introduced `Get-CIPPGroupType` function to classify groups based on their type. - Updated `Add-CIPPGroupMember` and `Remove-CIPPGroupMember` functions to improve identity resolution and error handling. - Implemented `Resolve-CIPPDirectoryId` for resolving directory identities to Graph object IDs. - Created `Invoke-ExecGroupMembers` as an entry point for managing group members and owners through a unified API. These changes enhance the overall functionality and usability of group management within the CIPP module. --- backend/Config/openapi.json | 80 +++++- .../CIPPCore/Public/Add-CIPPGroupMember.ps1 | 127 ++++----- .../CIPPCore/Public/Add-CIPPGroupOwner.ps1 | 167 ++++++++++++ .../CIPPCore/Public/Get-CIPPGroupType.ps1 | 107 ++++++++ .../Public/Remove-CIPPGroupMember.ps1 | 128 ++++----- .../CIPPCore/Public/Remove-CIPPGroupOwner.ps1 | 179 +++++++++++++ .../Public/Resolve-CIPPDirectoryId.ps1 | 188 ++++++++++++++ .../Groups/Invoke-ExecGroupMembers.ps1 | 67 +++++ .../Tests/Endpoint/Invoke-EditGroup.Tests.ps1 | 48 +++- .../Invoke-ExecGroupMembers.Tests.ps1 | 159 ++++++++++++ .../Private/Add-CIPPGroupMember.Tests.ps1 | 245 +++++++++--------- .../Private/Add-CIPPGroupOwner.Tests.ps1 | 245 ++++++++++++++++++ .../Tests/Private/Get-CIPPGroupType.Tests.ps1 | 79 ++++++ .../Private/Remove-CIPPGroupMember.Tests.ps1 | 114 ++++---- .../Private/Remove-CIPPGroupOwner.Tests.ps1 | 182 +++++++++++++ .../Private/Resolve-CIPPDirectoryId.Tests.ps1 | 145 +++++++++++ 16 files changed, 1932 insertions(+), 328 deletions(-) create mode 100644 backend/Modules/CIPPCore/Public/Add-CIPPGroupOwner.ps1 create mode 100644 backend/Modules/CIPPCore/Public/Get-CIPPGroupType.ps1 create mode 100644 backend/Modules/CIPPCore/Public/Remove-CIPPGroupOwner.ps1 create mode 100644 backend/Modules/CIPPCore/Public/Resolve-CIPPDirectoryId.ps1 create mode 100644 backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Groups/Invoke-ExecGroupMembers.ps1 create mode 100644 backend/Tests/Endpoint/Invoke-ExecGroupMembers.Tests.ps1 create mode 100644 backend/Tests/Private/Add-CIPPGroupOwner.Tests.ps1 create mode 100644 backend/Tests/Private/Get-CIPPGroupType.Tests.ps1 create mode 100644 backend/Tests/Private/Remove-CIPPGroupOwner.Tests.ps1 create mode 100644 backend/Tests/Private/Resolve-CIPPDirectoryId.Tests.ps1 diff --git a/backend/Config/openapi.json b/backend/Config/openapi.json index cc6c58470b..e5e7d7e0a5 100644 --- a/backend/Config/openapi.json +++ b/backend/Config/openapi.json @@ -8589,9 +8589,6 @@ } } }, - "label": { - "type": "string" - }, "value": { "type": "string" } @@ -8712,7 +8709,8 @@ "type": "array", "items": { "type": "string" - } + }, + "description": "Keep unresolved ManagedBy entries so a failed lookup cannot strip an owner." } } } @@ -22378,6 +22376,80 @@ "x-cipp-role": "CIPP.Core.Read" } }, + "/api/ExecGroupMembers": { + "post": { + "summary": "ExecGroupMembers", + "operationId": "ExecGroupMembers", + "tags": [ + "Identity > Administration > Groups" + ], + "description": "Manages group membership (members and owners) via a switch-style action parameter.\nAccepts one or more directory object IDs, UPNs, or mail addresses (users, groups, etc.).\nAutomatically resolves the group type from Graph to route through the correct API (Graph or Exchange).\n\nSupported actions: addMember, removeMember, addOwner, removeOwner", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": [ + "addMember", + "addOwner", + "removeMember", + "removeOwner" + ] + }, + "groupId": { + "type": "string" + }, + "tenantFilter": { + "type": "string" + }, + "users": { + "type": "string", + "description": "Accept a single string or an array of strings (IDs, UPNs, or mail)" + } + }, + "required": [ + "tenantFilter" + ] + } + } + } + }, + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StandardResults" + } + } + } + }, + "400": { + "description": "Bad request - missing required field or invalid input" + }, + "401": { + "description": "Unauthorized - invalid or missing bearer token" + }, + "403": { + "description": "Forbidden - caller lacks the required RBAC role" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "x-cipp-role": "Identity.Group.ReadWrite" + } + }, "/api/ExecGroupsDelete": { "post": { "summary": "ExecGroupsDelete", diff --git a/backend/Modules/CIPPCore/Public/Add-CIPPGroupMember.ps1 b/backend/Modules/CIPPCore/Public/Add-CIPPGroupMember.ps1 index 18a90974b6..2e32a97118 100644 --- a/backend/Modules/CIPPCore/Public/Add-CIPPGroupMember.ps1 +++ b/backend/Modules/CIPPCore/Public/Add-CIPPGroupMember.ps1 @@ -1,22 +1,24 @@ function Add-CIPPGroupMember { <# .SYNOPSIS - Adds one or more members to a specified group in Microsoft Graph. + Adds one or more members to a specified group. .DESCRIPTION - This function adds one or more members to a specified group in Microsoft Graph, supporting different group types such as Distribution lists and Mail-Enabled Security groups. + Adds directory objects (users, groups, etc.) to a group. Routes through Exchange for + distribution lists and mail-enabled security groups, Graph for everything else. + Resolves identities via Resolve-CIPPDirectoryId so callers can pass ids or UPNs/mail. .PARAMETER Headers The headers to include in the request, typically containing authentication tokens. This is supplied automatically by the API .PARAMETER GroupType - The type of group to which the member is being added, such as Security, Distribution list or Mail-Enabled Security. + Optional fallback type when Graph/Exchange cannot classify the target group. .PARAMETER GroupId The unique identifier of the group to which the member will be added. .PARAMETER Member - An array of members to add to the group. + An array of member identifiers (object ids, UPNs, or mail addresses). .PARAMETER TenantFilter The tenant identifier to filter the request. @@ -35,53 +37,28 @@ function Add-CIPPGroupMember { ) try { $ODataBindString = 'https://graph.microsoft.com/v1.0/directoryObjects/{0}' - $Requests = @( - foreach ($m in $Member) { - if ($m -like '*#EXT#*') { $m = [System.Web.HttpUtility]::UrlEncode($m) } - @{ - id = "users-$m" - url = "users/$($m)?`$select=id,userPrincipalName" - method = 'GET' - } - } - @{ - id = 'group' - url = "groups/$($GroupId)?`$select=id,displayName,groupTypes,mailEnabled,securityEnabled" - method = 'GET' + $Group = Get-CIPPGroupType -GroupId $GroupId -TenantFilter $TenantFilter -FallbackGroupType $GroupType + $GroupName = $Group.DisplayName + $ResolvedMembers = @(Resolve-CIPPDirectoryId -Identity $Member -TenantFilter $TenantFilter) + + $SuccessfulMembers = [System.Collections.Generic.List[string]]::new() + $FailedMembers = [System.Collections.Generic.List[string]]::new() + + foreach ($Entry in $ResolvedMembers) { + if (-not $Entry.Resolved -or -not $Entry.Id) { + $FailedMembers.Add("$($Entry.Label) (directory object not found)") } - ) - $BulkResults = New-GraphBulkRequest -Requests @($Requests) -tenantid $TenantFilter - $Users = @($BulkResults | Where-Object { $_.id -like 'users-*' }) - $GroupObject = ($BulkResults | Where-Object { $_.id -eq 'group' }).body - # Group display name for logging; falls back to the id if the lookup failed - # (e.g. the group was addressed by mail rather than GUID). - $GroupName = $GroupObject.displayName ?? $GroupId - # Graph cannot write membership to Exchange-backed groups: a classic distribution list or a - # mail-enabled security group rejects members/$ref with "Cannot Update a mail-enabled - # security groups and or distribution list". Callers pass a group type from the UI, but - # templates and stored autocomplete options routinely carry none (or a stale one), so - # prefer what Graph says the group actually is and only fall back to the caller's value - # when the lookup told us nothing. - $ResolvedGroupType = if ($null -ne $GroupObject.mailEnabled -or $null -ne $GroupObject.securityEnabled) { - if ($GroupObject.groupTypes -contains 'Unified') { 'Microsoft 365' } - elseif ($GroupObject.mailEnabled -and $GroupObject.securityEnabled) { 'Mail-Enabled Security' } - elseif ($GroupObject.mailEnabled) { 'Distribution list' } - else { 'Security' } - } else { - $GroupType } - $SuccessfulUsers = [System.Collections.Generic.List[string]]::new() - $FailedUsers = [System.Collections.Generic.List[string]]::new() + $ValidMembers = @($ResolvedMembers | Where-Object { $_.Resolved -and $_.Id }) - if ($ResolvedGroupType -eq 'Distribution list' -or $ResolvedGroupType -eq 'Mail-Enabled Security') { + if ($Group.IsExchangeBacked) { $ExoBulkRequests = [System.Collections.Generic.List[object]]::new() $ExoLogs = [System.Collections.Generic.List[object]]::new() - foreach ($User in $Users) { - # Tag each operation so its result can be matched back exactly. New-ExoBulkRequest - # stamps the OperationGuid onto both the error and the success record it returns. + foreach ($Entry in $ValidMembers) { $OperationGuid = [Guid]::NewGuid().ToString() - $Params = @{ Identity = $GroupId; Member = $User.body.userPrincipalName; BypassSecurityGroupManagerCheck = $true } + $ExoMember = $Entry.ExchangeIdentity ?? $Entry.Id + $Params = @{ Identity = $GroupId; Member = $ExoMember; BypassSecurityGroupManagerCheck = $true } $ExoBulkRequests.Add(@{ CmdletInput = @{ CmdletName = 'Add-DistributionGroupMember' @@ -90,8 +67,8 @@ function Add-CIPPGroupMember { OperationGuid = $OperationGuid }) $ExoLogs.Add(@{ - message = "Added member $($User.body.userPrincipalName) to group $($GroupName)" - target = $User.body.userPrincipalName + message = "Added member $($Entry.Label) to group $($GroupName)" + target = $ExoMember OperationGuid = $OperationGuid }) } @@ -101,57 +78,65 @@ function Add-CIPPGroupMember { $ExoResults = Resolve-CippExoBulkResult -Response $RawExoRequest -Operations $ExoLogs foreach ($ExoResult in $ExoResults) { + $Entry = $ValidMembers | Where-Object { + ($_.ExchangeIdentity ?? $_.Id) -eq $ExoResult.Operation.target + } | Select-Object -First 1 + $Label = $Entry.Label ?? $ExoResult.Operation.target if ($ExoResult.Success) { Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message $ExoResult.Operation.message -Sev 'Info' - $SuccessfulUsers.Add($ExoResult.Operation.target) + $SuccessfulMembers.Add($Label) } else { - Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message "Failed to add member $($ExoResult.Operation.target) to group $($GroupName): $($ExoResult.ErrorMessage)" -Sev 'Error' - $FailedUsers.Add("$($ExoResult.Operation.target) ($($ExoResult.ErrorMessage))") + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message "Failed to add member $Label to group $($GroupName): $($ExoResult.ErrorMessage)" -Sev 'Error' + $FailedMembers.Add("$Label ($($ExoResult.ErrorMessage))") } } } } else { - # Build one bulk request list; New-GraphBulkRequest handles internal chunking - $AddRequests = foreach ($User in $Users) { + $AddRequests = foreach ($Entry in $ValidMembers) { @{ - id = $User.body.id + id = $Entry.Id method = 'POST' url = "/groups/$($GroupId)/members/`$ref" - body = @{ '@odata.id' = ($ODataBindString -f $User.body.id) } + body = @{ '@odata.id' = ($ODataBindString -f $Entry.Id) } headers = @{ 'Content-Type' = 'application/json' } } } - $AddResults = New-GraphBulkRequest -tenantid $TenantFilter -Requests @($AddRequests) - foreach ($Result in $AddResults) { - $UserPrincipalName = ($Users | Where-Object { $_.body.id -eq $Result.id }).body.userPrincipalName - if ($Result.status -lt 200 -or $Result.status -gt 299) { - # Select-Object -First 1: Get-NormalizedError can return multiple strings - # when a message matches more than one of its translation patterns. - $ErrorText = Get-NormalizedError -message ($Result.body.error.message ?? "Request failed with status $($Result.status)") | Select-Object -First 1 - Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message "Failed to add member $UserPrincipalName to group $($GroupName): $ErrorText" -Sev 'Error' - $FailedUsers.Add("$UserPrincipalName ($ErrorText)") - } else { - $SuccessfulUsers.Add($UserPrincipalName) + if (@($AddRequests).Count -gt 0) { + $AddResults = New-GraphBulkRequest -tenantid $TenantFilter -Requests @($AddRequests) + foreach ($Result in $AddResults) { + $Entry = $ValidMembers | Where-Object { $_.Id -eq $Result.id } | Select-Object -First 1 + $Label = $Entry.Label ?? $Result.id + if ($Result.status -lt 200 -or $Result.status -gt 299) { + $ErrorText = Get-NormalizedError -message ($Result.body.error.message ?? "Request failed with status $($Result.status)") | Select-Object -First 1 + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message "Failed to add member $Label to group $($GroupName): $ErrorText" -Sev 'Error' + $FailedMembers.Add("$Label ($ErrorText)") + } else { + $SuccessfulMembers.Add($Label) + } } } } $Messages = [System.Collections.Generic.List[string]]::new() - if ($SuccessfulUsers.Count -gt 0) { - $Messages.Add("Successfully added user $($SuccessfulUsers -join ', ') to group $($GroupName).") + if ($SuccessfulMembers.Count -gt 0) { + $Messages.Add("Successfully added $($SuccessfulMembers -join ', ') to group $($GroupName).") } - if ($FailedUsers.Count -gt 0) { - $Messages.Add("Failed to add $($FailedUsers -join '; ').") + if ($FailedMembers.Count -gt 0) { + $Messages.Add("Failed to add $($FailedMembers -join '; ').") } $Results = $Messages -join ' ' - if ($SuccessfulUsers.Count -eq 0 -and $FailedUsers.Count -gt 0) { + if ($SuccessfulMembers.Count -eq 0 -and $FailedMembers.Count -gt 0) { throw $Results } Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message $Results -Sev 'Info' return $Results } catch { $ErrorMessage = Get-CippException -Exception $_ - $UserList = if ($Users) { ($Users.body.userPrincipalName -join ', ') } else { ($Member -join ', ') } - $Results = "Failed to add user $UserList to group $($GroupName ?? $GroupId) - $($ErrorMessage.NormalizedError)" + $MemberList = if ($ResolvedMembers) { + ($ResolvedMembers | ForEach-Object { $_.Label ?? $_.Input }) -join ', ' + } else { + ($Member -join ', ') + } + $Results = "Failed to add $MemberList to group $($GroupName ?? $GroupId) - $($ErrorMessage.NormalizedError)" Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message $Results -Sev 'error' -LogData $ErrorMessage throw $Results } diff --git a/backend/Modules/CIPPCore/Public/Add-CIPPGroupOwner.ps1 b/backend/Modules/CIPPCore/Public/Add-CIPPGroupOwner.ps1 new file mode 100644 index 0000000000..02cf330f9e --- /dev/null +++ b/backend/Modules/CIPPCore/Public/Add-CIPPGroupOwner.ps1 @@ -0,0 +1,167 @@ +function Add-CIPPGroupOwner { + <# + .SYNOPSIS + Adds one or more owners to a specified group. + + .DESCRIPTION + Adds owners via Graph for Microsoft 365 and Security groups, or updates the + ManagedBy list via Exchange for Distribution Lists and Mail-Enabled Security groups. + Resolves identities to Graph object ids so ManagedBy compare/write matches ListGroups/EditGroup. + + .PARAMETER Headers + Request headers for logging. Supplied automatically by the API. + + .PARAMETER GroupId + The unique identifier of the group. + + .PARAMETER Owner + An array of owner identifiers (user GUIDs or UPNs) to add. + + .PARAMETER TenantFilter + The tenant identifier. + + .PARAMETER APIName + The API operation name for logging. Default: 'Add Group Owner'. + #> + [CmdletBinding()] + param( + $Headers, + [Parameter(Mandatory = $true)] + [string]$GroupId, + [Parameter(Mandatory = $true)] + [string[]]$Owner, + [Parameter(Mandatory = $true)] + [string]$TenantFilter, + [string]$APIName = 'Add Group Owner' + ) + + try { + $ODataBindString = 'https://graph.microsoft.com/v1.0/directoryObjects/{0}' + $Group = Get-CIPPGroupType -GroupId $GroupId -TenantFilter $TenantFilter + $GroupName = $Group.DisplayName + $ResolvedOwners = @(Resolve-CIPPDirectoryId -Identity $Owner -TenantFilter $TenantFilter) + + $SuccessfulUsers = [System.Collections.Generic.List[string]]::new() + $FailedUsers = [System.Collections.Generic.List[string]]::new() + + if ($Group.IsExchangeBacked) { + $CurrentOwnersRaw = @( + New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-DistributionGroup' -cmdParams @{ Identity = $GroupId } -UseSystemMailbox $true | + Select-Object -ExpandProperty ManagedBy + ) + $CurrentResolved = @(Resolve-CIPPDirectoryId -Identity $CurrentOwnersRaw -TenantFilter $TenantFilter) + # Keep unresolved ManagedBy entries as-is so a failed lookup cannot strip an owner. + $NewManagedBy = [System.Collections.Generic.List[string]]::new() + $CurrentIdSet = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) + foreach ($Entry in $CurrentResolved) { + if ($Entry.Resolved -and $Entry.Id) { + $null = $CurrentIdSet.Add($Entry.Id) + $NewManagedBy.Add($Entry.Id) + } else { + $NewManagedBy.Add($Entry.Input) + } + } + + foreach ($OwnerInfo in $ResolvedOwners) { + $Label = $OwnerInfo.UserPrincipalName ?? $OwnerInfo.DisplayName ?? $OwnerInfo.Input + if (-not $OwnerInfo.Resolved -or -not $OwnerInfo.Id) { + $FailedUsers.Add("$Label (user not found)") + continue + } + if ($CurrentIdSet.Contains($OwnerInfo.Id)) { + $FailedUsers.Add("$Label (already an owner)") + continue + } + $NewManagedBy.Add($OwnerInfo.Id) + $null = $CurrentIdSet.Add($OwnerInfo.Id) + $SuccessfulUsers.Add($Label) + } + + if ($SuccessfulUsers.Count -gt 0) { + $OperationGuid = [Guid]::NewGuid().ToString() + $ExoBulkRequests = @(@{ + CmdletInput = @{ + CmdletName = 'Set-DistributionGroup' + Parameters = @{ Identity = $GroupId; ManagedBy = @($NewManagedBy | Sort-Object -Unique); BypassSecurityGroupManagerCheck = $true } + } + OperationGuid = $OperationGuid + }) + $ExoLogs = @(@{ + message = "Added owners $($SuccessfulUsers -join ', ') to group $($GroupName)" + target = $GroupId + OperationGuid = $OperationGuid + }) + $RawExoRequest = New-ExoBulkRequest -tenantid $TenantFilter -cmdletArray @($ExoBulkRequests) + $ExoResults = Resolve-CippExoBulkResult -Response $RawExoRequest -Operations $ExoLogs + + foreach ($ExoResult in $ExoResults) { + if ($ExoResult.Success) { + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message $ExoResult.Operation.message -Sev 'Info' + } else { + $SuccessfulUsers.Clear() + foreach ($OwnerInfo in $ResolvedOwners) { + $Label = $OwnerInfo.UserPrincipalName ?? $OwnerInfo.DisplayName ?? $OwnerInfo.Input + $FailedUsers.Add("$Label ($($ExoResult.ErrorMessage))") + } + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message "Failed to add owners to group $($GroupName): $($ExoResult.ErrorMessage)" -Sev 'Error' + } + } + } + } else { + $AddRequests = foreach ($OwnerInfo in $ResolvedOwners) { + if (-not $OwnerInfo.Resolved -or -not $OwnerInfo.Id) { continue } + @{ + id = $OwnerInfo.Id + method = 'POST' + url = "/groups/$($GroupId)/owners/`$ref" + body = @{ '@odata.id' = ($ODataBindString -f $OwnerInfo.Id) } + headers = @{ 'Content-Type' = 'application/json' } + } + } + foreach ($OwnerInfo in $ResolvedOwners) { + if (-not $OwnerInfo.Resolved -or -not $OwnerInfo.Id) { + $Label = $OwnerInfo.UserPrincipalName ?? $OwnerInfo.DisplayName ?? $OwnerInfo.Input + $FailedUsers.Add("$Label (user not found)") + } + } + if (@($AddRequests).Count -gt 0) { + $AddResults = New-GraphBulkRequest -tenantid $TenantFilter -Requests @($AddRequests) + foreach ($Result in $AddResults) { + $OwnerInfo = $ResolvedOwners | Where-Object { $_.Id -eq $Result.id } | Select-Object -First 1 + $Label = $OwnerInfo.UserPrincipalName ?? $OwnerInfo.DisplayName ?? $OwnerInfo.Input ?? $Result.id + if ($Result.status -lt 200 -or $Result.status -gt 299) { + $ErrorText = Get-NormalizedError -message ($Result.body.error.message ?? "Request failed with status $($Result.status)") | Select-Object -First 1 + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message "Failed to add owner $Label to group $($GroupName): $ErrorText" -Sev 'Error' + $FailedUsers.Add("$Label ($ErrorText)") + } else { + $SuccessfulUsers.Add($Label) + } + } + } + } + + $Messages = [System.Collections.Generic.List[string]]::new() + if ($SuccessfulUsers.Count -gt 0) { + $Messages.Add("Successfully added owner $($SuccessfulUsers -join ', ') to group $($GroupName).") + } + if ($FailedUsers.Count -gt 0) { + $Messages.Add("Failed to add $($FailedUsers -join '; ').") + } + $Results = $Messages -join ' ' + if ($SuccessfulUsers.Count -eq 0 -and $FailedUsers.Count -gt 0) { + throw $Results + } + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message $Results -Sev 'Info' + return $Results + } catch { + $ErrorMessage = Get-CippException -Exception $_ + $UserList = if ($ResolvedOwners) { + ($ResolvedOwners | ForEach-Object { $_.UserPrincipalName ?? $_.Input }) -join ', ' + } else { + ($Owner -join ', ') + } + $Results = "Failed to add owner $UserList to group $($GroupName ?? $GroupId) - $($ErrorMessage.NormalizedError)" + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message $Results -Sev 'error' -LogData $ErrorMessage + throw $Results + } +} diff --git a/backend/Modules/CIPPCore/Public/Get-CIPPGroupType.ps1 b/backend/Modules/CIPPCore/Public/Get-CIPPGroupType.ps1 new file mode 100644 index 0000000000..509a705851 --- /dev/null +++ b/backend/Modules/CIPPCore/Public/Get-CIPPGroupType.ps1 @@ -0,0 +1,107 @@ +function Get-CIPPGroupType { + <# + .SYNOPSIS + Resolves the Microsoft group type for a group by looking it up. + + .DESCRIPTION + Fetches the group from Graph and classifies it into one of the canonical types: + Microsoft 365, Mail-Enabled Security, Distribution List, or Security. + + Graph cannot write membership/ownership on classic distribution lists or mail-enabled + security groups, so callers use IsExchangeBacked to pick Exchange vs Graph. + + When Graph returns nothing usable (404, addressed by mail/display name, etc.), falls + back to Get-DistributionGroup in Exchange, then to -FallbackGroupType if supplied. + + .PARAMETER GroupId + Group object id, mail, or Exchange identity. + + .PARAMETER TenantFilter + Tenant id or default domain. + + .PARAMETER FallbackGroupType + Used only when Graph and Exchange both fail to classify the group. Accepts common + casing variants (e.g. 'Distribution list' / 'Distribution List'). + + .OUTPUTS + PSCustomObject with GroupId, DisplayName, GroupType, IsExchangeBacked, GroupObject. + + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$GroupId, + + [Parameter(Mandatory = $true)] + [string]$TenantFilter, + + [string]$FallbackGroupType + ) + + $GroupObject = $null + try { + $GroupObject = New-GraphGetRequest -uri "https://graph.microsoft.com/v1.0/groups/$GroupId`?`$select=id,displayName,groupTypes,mailEnabled,securityEnabled" -tenantid $TenantFilter + } catch { + Write-Information "Get-CIPPGroupType: Graph lookup failed for '$GroupId': $($_.Exception.Message)" + } + + $GroupType = $null + $DisplayName = $null + $ResolvedId = $GroupId + + if ($null -ne $GroupObject -and ($null -ne $GroupObject.mailEnabled -or $null -ne $GroupObject.securityEnabled)) { + if ($GroupObject.groupTypes -contains 'Unified') { + $GroupType = 'Microsoft 365' + } elseif ($GroupObject.mailEnabled -and $GroupObject.securityEnabled) { + $GroupType = 'Mail-Enabled Security' + } elseif ($GroupObject.mailEnabled) { + $GroupType = 'Distribution List' + } else { + $GroupType = 'Security' + } + $DisplayName = $GroupObject.displayName + if ($GroupObject.id) { $ResolvedId = $GroupObject.id } + } + + # Graph missed (wrong id shape, mail nickname, etc.) — Exchange still knows classic DLs / MES. + if (-not $GroupType) { + try { + $ExoGroup = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-DistributionGroup' -cmdParams @{ Identity = $GroupId } -Select 'Guid,DisplayName,RecipientTypeDetails' -UseSystemMailbox $true + if ($ExoGroup) { + $GroupType = if ($ExoGroup.RecipientTypeDetails -eq 'MailUniversalSecurityGroup') { + 'Mail-Enabled Security' + } else { + 'Distribution List' + } + $DisplayName = $ExoGroup.DisplayName ?? $DisplayName + if ($ExoGroup.Guid) { $ResolvedId = [string]$ExoGroup.Guid } + } + } catch { + Write-Information "Get-CIPPGroupType: Exchange lookup failed for '$GroupId': $($_.Exception.Message)" + } + } + + if (-not $GroupType) { + $GroupType = switch -Regex ($FallbackGroupType) { + '^(?i)microsoft\s*365$|^(?i)unified$' { 'Microsoft 365'; break } + '^(?i)mail-enabled\s*security$' { 'Mail-Enabled Security'; break } + '^(?i)distribution\s*list$' { 'Distribution List'; break } + '^(?i)security$' { 'Security'; break } + default { if ($FallbackGroupType) { $FallbackGroupType } else { 'Security' } } + } + } + + if (-not $DisplayName) { + $DisplayName = $GroupObject.displayName ?? $GroupId + } + + return [pscustomobject]@{ + GroupId = $ResolvedId + DisplayName = $DisplayName + GroupType = $GroupType + IsExchangeBacked = ($GroupType -eq 'Distribution List' -or $GroupType -eq 'Mail-Enabled Security') + GroupObject = $GroupObject + } +} diff --git a/backend/Modules/CIPPCore/Public/Remove-CIPPGroupMember.ps1 b/backend/Modules/CIPPCore/Public/Remove-CIPPGroupMember.ps1 index 4ebf2ff4f9..e6d02dcc13 100644 --- a/backend/Modules/CIPPCore/Public/Remove-CIPPGroupMember.ps1 +++ b/backend/Modules/CIPPCore/Public/Remove-CIPPGroupMember.ps1 @@ -4,8 +4,8 @@ function Remove-CIPPGroupMember { Removes members from a Microsoft 365 group. .DESCRIPTION - Removes one or more members from Security Groups, Distribution Groups, or Mail-Enabled Security Groups. - Uses bulk request operations for Exchange groups to improve performance. + Removes directory objects (users, groups, etc.) from Security Groups, Distribution + Groups, or Mail-Enabled Security Groups. Resolves identities via Resolve-CIPPDirectoryId. .PARAMETER Headers The headers for the API request, typically containing authentication information. @@ -14,29 +14,22 @@ function Remove-CIPPGroupMember { The tenant identifier for the target tenant. .PARAMETER GroupType - The type of group. Valid values: 'Distribution list', 'Mail-Enabled Security', or standard security groups. + Optional fallback type when Graph/Exchange cannot classify the target group. .PARAMETER GroupId The unique identifier (GUID or name) of the group. .PARAMETER Member - An array of member identifiers (user GUIDs or UPNs) to remove from the group. + An array of member identifiers (object ids, UPNs, or mail addresses). .PARAMETER APIName The API operation name for logging purposes. Default: 'Remove Group Member'. - - .EXAMPLE - Remove-CIPPGroupMember -Headers $Headers -TenantFilter 'contoso.onmicrosoft.com' -GroupType 'Distribution list' -GroupId 'Sales-DL' -Member @('user1@contoso.com', 'user2@contoso.com') -APIName 'Remove DL Members' - - .EXAMPLE - Remove-CIPPGroupMember -Headers $Headers -TenantFilter 'contoso.onmicrosoft.com' -GroupType 'Security' -GroupId '12345-guid' -Member @('user1-guid') #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] [string]$TenantFilter, - [Parameter(Mandatory = $true)] [string]$GroupType, [Parameter(Mandatory = $true)] @@ -52,50 +45,28 @@ function Remove-CIPPGroupMember { ) try { - $Requests = @( - foreach ($m in $Member) { - if ($m -like '*#EXT#*') { $m = [System.Web.HttpUtility]::UrlEncode($m) } - @{ - id = "users-$m" - url = "users/$($m)?`$select=id,userPrincipalName" - method = 'GET' - } - } - @{ - id = 'group' - url = "groups/$($GroupId)?`$select=id,displayName,groupTypes,mailEnabled,securityEnabled" - method = 'GET' + $Group = Get-CIPPGroupType -GroupId $GroupId -TenantFilter $TenantFilter -FallbackGroupType $GroupType + $GroupName = $Group.DisplayName + $ResolvedMembers = @(Resolve-CIPPDirectoryId -Identity $Member -TenantFilter $TenantFilter) + + $SuccessfulMembers = [System.Collections.Generic.List[string]]::new() + $FailedMembers = [System.Collections.Generic.List[string]]::new() + + foreach ($Entry in $ResolvedMembers) { + if (-not $Entry.Resolved -or -not $Entry.Id) { + $FailedMembers.Add("$($Entry.Label) (directory object not found)") } - ) - $BulkResults = New-GraphBulkRequest -Requests @($Requests) -tenantid $TenantFilter - $Users = @($BulkResults | Where-Object { $_.id -like 'users-*' }) - $GroupObject = ($BulkResults | Where-Object { $_.id -eq 'group' }).body - # Group display name for logging; falls back to the id if the lookup failed - # (e.g. the group was addressed by mail rather than GUID). - $GroupName = $GroupObject.displayName ?? $GroupId - # Same routing rule as Add-CIPPGroupMember: Graph cannot change membership on a classic - # distribution list or a mail-enabled security group, so trust what Graph says the group is - # rather than the type the caller happened to pass in. - $ResolvedGroupType = if ($null -ne $GroupObject.mailEnabled -or $null -ne $GroupObject.securityEnabled) { - if ($GroupObject.groupTypes -contains 'Unified') { 'Microsoft 365' } - elseif ($GroupObject.mailEnabled -and $GroupObject.securityEnabled) { 'Mail-Enabled Security' } - elseif ($GroupObject.mailEnabled) { 'Distribution list' } - else { 'Security' } - } else { - $GroupType } - $SuccessfulUsers = [System.Collections.Generic.List[string]]::new() - $FailedUsers = [System.Collections.Generic.List[string]]::new() + $ValidMembers = @($ResolvedMembers | Where-Object { $_.Resolved -and $_.Id }) - if ($ResolvedGroupType -eq 'Distribution list' -or $ResolvedGroupType -eq 'Mail-Enabled Security') { + if ($Group.IsExchangeBacked) { $ExoBulkRequests = [System.Collections.Generic.List[object]]::new() $ExoLogs = [System.Collections.Generic.List[object]]::new() - foreach ($User in $Users) { - # Tag each operation so its result can be matched back exactly. New-ExoBulkRequest - # stamps the OperationGuid onto both the error and the success record it returns. + foreach ($Entry in $ValidMembers) { $OperationGuid = [Guid]::NewGuid().ToString() - $Params = @{ Identity = $GroupId; Member = $User.body.userPrincipalName; BypassSecurityGroupManagerCheck = $true } + $ExoMember = $Entry.ExchangeIdentity ?? $Entry.Id + $Params = @{ Identity = $GroupId; Member = $ExoMember; BypassSecurityGroupManagerCheck = $true } $ExoBulkRequests.Add(@{ CmdletInput = @{ CmdletName = 'Remove-DistributionGroupMember' @@ -104,8 +75,8 @@ function Remove-CIPPGroupMember { OperationGuid = $OperationGuid }) $ExoLogs.Add(@{ - message = "Removed member $($User.body.userPrincipalName) from group $($GroupName)" - target = $User.body.userPrincipalName + message = "Removed member $($Entry.Label) from group $($GroupName)" + target = $ExoMember OperationGuid = $OperationGuid }) } @@ -115,46 +86,51 @@ function Remove-CIPPGroupMember { $ExoResults = Resolve-CippExoBulkResult -Response $RawExoRequest -Operations $ExoLogs foreach ($ExoResult in $ExoResults) { + $Entry = $ValidMembers | Where-Object { + ($_.ExchangeIdentity ?? $_.Id) -eq $ExoResult.Operation.target + } | Select-Object -First 1 + $Label = $Entry.Label ?? $ExoResult.Operation.target if ($ExoResult.Success) { Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message $ExoResult.Operation.message -Sev 'Info' - $SuccessfulUsers.Add($ExoResult.Operation.target) + $SuccessfulMembers.Add($Label) } else { - Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message "Failed to remove member $($ExoResult.Operation.target) from group $($GroupName): $($ExoResult.ErrorMessage)" -Sev 'Error' - $FailedUsers.Add("$($ExoResult.Operation.target) ($($ExoResult.ErrorMessage))") + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message "Failed to remove member $Label from group $($GroupName): $($ExoResult.ErrorMessage)" -Sev 'Error' + $FailedMembers.Add("$Label ($($ExoResult.ErrorMessage))") } } } } else { - $RemovalRequests = foreach ($User in $Users) { + $RemovalRequests = foreach ($Entry in $ValidMembers) { @{ - id = $User.body.id + id = $Entry.Id method = 'DELETE' - url = "/groups/$($GroupId)/members/$($User.body.id)/`$ref" + url = "/groups/$($GroupId)/members/$($Entry.Id)/`$ref" } } - $RemovalResults = New-GraphBulkRequest -tenantid $TenantFilter -Requests @($RemovalRequests) - foreach ($Result in $RemovalResults) { - $UserPrincipalName = ($Users | Where-Object { $_.body.id -eq $Result.id }).body.userPrincipalName - if ($Result.status -lt 200 -or $Result.status -gt 299) { - # Select-Object -First 1: Get-NormalizedError can return multiple strings - # when a message matches more than one of its translation patterns. - $ErrorText = Get-NormalizedError -message ($Result.body.error.message ?? "Request failed with status $($Result.status)") | Select-Object -First 1 - Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message "Failed to remove member $UserPrincipalName from group $($GroupName): $ErrorText" -Sev 'Error' - $FailedUsers.Add("$UserPrincipalName ($ErrorText)") - } else { - $SuccessfulUsers.Add($UserPrincipalName) + if (@($RemovalRequests).Count -gt 0) { + $RemovalResults = New-GraphBulkRequest -tenantid $TenantFilter -Requests @($RemovalRequests) + foreach ($Result in $RemovalResults) { + $Entry = $ValidMembers | Where-Object { $_.Id -eq $Result.id } | Select-Object -First 1 + $Label = $Entry.Label ?? $Result.id + if ($Result.status -lt 200 -or $Result.status -gt 299) { + $ErrorText = Get-NormalizedError -message ($Result.body.error.message ?? "Request failed with status $($Result.status)") | Select-Object -First 1 + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message "Failed to remove member $Label from group $($GroupName): $ErrorText" -Sev 'Error' + $FailedMembers.Add("$Label ($ErrorText)") + } else { + $SuccessfulMembers.Add($Label) + } } } } $Messages = [System.Collections.Generic.List[string]]::new() - if ($SuccessfulUsers.Count -gt 0) { - $Messages.Add("Successfully removed user $($SuccessfulUsers -join ', ') from group $($GroupName).") + if ($SuccessfulMembers.Count -gt 0) { + $Messages.Add("Successfully removed $($SuccessfulMembers -join ', ') from group $($GroupName).") } - if ($FailedUsers.Count -gt 0) { - $Messages.Add("Failed to remove $($FailedUsers -join '; ').") + if ($FailedMembers.Count -gt 0) { + $Messages.Add("Failed to remove $($FailedMembers -join '; ').") } $Results = $Messages -join ' ' - if ($SuccessfulUsers.Count -eq 0 -and $FailedUsers.Count -gt 0) { + if ($SuccessfulMembers.Count -eq 0 -and $FailedMembers.Count -gt 0) { throw $Results } Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message $Results -Sev Info @@ -162,8 +138,12 @@ function Remove-CIPPGroupMember { } catch { $ErrorMessage = Get-CippException -Exception $_ - $UserList = if ($Users) { ($Users.body.userPrincipalName -join ', ') } else { ($Member -join ', ') } - $Results = "Failed to remove user $UserList from group $($GroupName ?? $GroupId): $($ErrorMessage.NormalizedError)" + $MemberList = if ($ResolvedMembers) { + ($ResolvedMembers | ForEach-Object { $_.Label ?? $_.Input }) -join ', ' + } else { + ($Member -join ', ') + } + $Results = "Failed to remove $MemberList from group $($GroupName ?? $GroupId): $($ErrorMessage.NormalizedError)" Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message $Results -Sev Error -LogData $ErrorMessage throw $Results } diff --git a/backend/Modules/CIPPCore/Public/Remove-CIPPGroupOwner.ps1 b/backend/Modules/CIPPCore/Public/Remove-CIPPGroupOwner.ps1 new file mode 100644 index 0000000000..97433e15cc --- /dev/null +++ b/backend/Modules/CIPPCore/Public/Remove-CIPPGroupOwner.ps1 @@ -0,0 +1,179 @@ +function Remove-CIPPGroupOwner { + <# + .SYNOPSIS + Removes one or more owners from a specified group. + + .DESCRIPTION + Removes owners via Graph for Microsoft 365 and Security groups, or updates the + ManagedBy list via Exchange for Distribution Lists and Mail-Enabled Security groups. + Resolves identities to Graph object ids so ManagedBy compare/write matches ListGroups/EditGroup. + + .PARAMETER Headers + Request headers for logging. Supplied automatically by the API. + + .PARAMETER GroupId + The unique identifier of the group. + + .PARAMETER Owner + An array of owner identifiers (user GUIDs or UPNs) to remove. + + .PARAMETER TenantFilter + The tenant identifier. + + .PARAMETER APIName + The API operation name for logging. Default: 'Remove Group Owner'. + #> + [CmdletBinding()] + param( + $Headers, + [Parameter(Mandatory = $true)] + [string]$GroupId, + [Parameter(Mandatory = $true)] + [string[]]$Owner, + [Parameter(Mandatory = $true)] + [string]$TenantFilter, + [string]$APIName = 'Remove Group Owner' + ) + + try { + $Group = Get-CIPPGroupType -GroupId $GroupId -TenantFilter $TenantFilter + $GroupName = $Group.DisplayName + $ResolvedOwners = @(Resolve-CIPPDirectoryId -Identity $Owner -TenantFilter $TenantFilter) + + $SuccessfulUsers = [System.Collections.Generic.List[string]]::new() + $FailedUsers = [System.Collections.Generic.List[string]]::new() + + if ($Group.IsExchangeBacked) { + $CurrentOwnersRaw = @( + New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-DistributionGroup' -cmdParams @{ Identity = $GroupId } -UseSystemMailbox $true | + Select-Object -ExpandProperty ManagedBy + ) + $CurrentResolved = @(Resolve-CIPPDirectoryId -Identity $CurrentOwnersRaw -TenantFilter $TenantFilter) + + $RemoveIdSet = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) + foreach ($OwnerInfo in $ResolvedOwners) { + $Label = $OwnerInfo.UserPrincipalName ?? $OwnerInfo.DisplayName ?? $OwnerInfo.Input + if (-not $OwnerInfo.Resolved -or -not $OwnerInfo.Id) { + $FailedUsers.Add("$Label (user not found)") + continue + } + $null = $RemoveIdSet.Add($OwnerInfo.Id) + } + + $NewManagedBy = [System.Collections.Generic.List[string]]::new() + $RemovedLabels = [System.Collections.Generic.List[string]]::new() + $CurrentIdSet = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) + + foreach ($Entry in $CurrentResolved) { + if ($Entry.Resolved -and $Entry.Id) { + $null = $CurrentIdSet.Add($Entry.Id) + if ($RemoveIdSet.Contains($Entry.Id)) { + $Label = $Entry.UserPrincipalName ?? $Entry.DisplayName ?? $Entry.Input + $RemovedLabels.Add($Label) + continue + } + $NewManagedBy.Add($Entry.Id) + } else { + # Unresolved current owner: only drop if the raw ManagedBy string was requested. + if ($Owner -contains $Entry.Input) { + $RemovedLabels.Add($Entry.Input) + } else { + $NewManagedBy.Add($Entry.Input) + } + } + } + + foreach ($OwnerInfo in $ResolvedOwners) { + if ($OwnerInfo.Resolved -and $OwnerInfo.Id -and -not $CurrentIdSet.Contains($OwnerInfo.Id)) { + $Label = $OwnerInfo.UserPrincipalName ?? $OwnerInfo.DisplayName ?? $OwnerInfo.Input + $FailedUsers.Add("$Label (not an owner)") + } + } + + foreach ($Label in $RemovedLabels) { $SuccessfulUsers.Add($Label) } + + if ($SuccessfulUsers.Count -gt 0) { + $OperationGuid = [Guid]::NewGuid().ToString() + $ExoBulkRequests = @(@{ + CmdletInput = @{ + CmdletName = 'Set-DistributionGroup' + Parameters = @{ Identity = $GroupId; ManagedBy = @($NewManagedBy); BypassSecurityGroupManagerCheck = $true } + } + OperationGuid = $OperationGuid + }) + $ExoLogs = @(@{ + message = "Removed owners $($SuccessfulUsers -join ', ') from group $($GroupName)" + target = $GroupId + OperationGuid = $OperationGuid + }) + $RawExoRequest = New-ExoBulkRequest -tenantid $TenantFilter -cmdletArray @($ExoBulkRequests) + $ExoResults = Resolve-CippExoBulkResult -Response $RawExoRequest -Operations $ExoLogs + + foreach ($ExoResult in $ExoResults) { + if ($ExoResult.Success) { + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message $ExoResult.Operation.message -Sev 'Info' + } else { + $SuccessfulUsers.Clear() + foreach ($Label in $RemovedLabels) { + $FailedUsers.Add("$Label ($($ExoResult.ErrorMessage))") + } + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message "Failed to remove owners from group $($GroupName): $($ExoResult.ErrorMessage)" -Sev 'Error' + } + } + } + } else { + $RemovalRequests = foreach ($OwnerInfo in $ResolvedOwners) { + if (-not $OwnerInfo.Resolved -or -not $OwnerInfo.Id) { continue } + @{ + id = $OwnerInfo.Id + method = 'DELETE' + url = "/groups/$($GroupId)/owners/$($OwnerInfo.Id)/`$ref" + } + } + foreach ($OwnerInfo in $ResolvedOwners) { + if (-not $OwnerInfo.Resolved -or -not $OwnerInfo.Id) { + $Label = $OwnerInfo.UserPrincipalName ?? $OwnerInfo.DisplayName ?? $OwnerInfo.Input + $FailedUsers.Add("$Label (user not found)") + } + } + if (@($RemovalRequests).Count -gt 0) { + $RemovalResults = New-GraphBulkRequest -tenantid $TenantFilter -Requests @($RemovalRequests) + foreach ($Result in $RemovalResults) { + $OwnerInfo = $ResolvedOwners | Where-Object { $_.Id -eq $Result.id } | Select-Object -First 1 + $Label = $OwnerInfo.UserPrincipalName ?? $OwnerInfo.DisplayName ?? $OwnerInfo.Input ?? $Result.id + if ($Result.status -lt 200 -or $Result.status -gt 299) { + $ErrorText = Get-NormalizedError -message ($Result.body.error.message ?? "Request failed with status $($Result.status)") | Select-Object -First 1 + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message "Failed to remove owner $Label from group $($GroupName): $ErrorText" -Sev 'Error' + $FailedUsers.Add("$Label ($ErrorText)") + } else { + $SuccessfulUsers.Add($Label) + } + } + } + } + + $Messages = [System.Collections.Generic.List[string]]::new() + if ($SuccessfulUsers.Count -gt 0) { + $Messages.Add("Successfully removed owner $($SuccessfulUsers -join ', ') from group $($GroupName).") + } + if ($FailedUsers.Count -gt 0) { + $Messages.Add("Failed to remove $($FailedUsers -join '; ').") + } + $Results = $Messages -join ' ' + if ($SuccessfulUsers.Count -eq 0 -and $FailedUsers.Count -gt 0) { + throw $Results + } + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message $Results -Sev 'Info' + return $Results + } catch { + $ErrorMessage = Get-CippException -Exception $_ + $UserList = if ($ResolvedOwners) { + ($ResolvedOwners | ForEach-Object { $_.UserPrincipalName ?? $_.Input }) -join ', ' + } else { + ($Owner -join ', ') + } + $Results = "Failed to remove owner $UserList from group $($GroupName ?? $GroupId) - $($ErrorMessage.NormalizedError)" + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message $Results -Sev 'error' -LogData $ErrorMessage + throw $Results + } +} diff --git a/backend/Modules/CIPPCore/Public/Resolve-CIPPDirectoryId.ps1 b/backend/Modules/CIPPCore/Public/Resolve-CIPPDirectoryId.ps1 new file mode 100644 index 0000000000..1a3f576561 --- /dev/null +++ b/backend/Modules/CIPPCore/Public/Resolve-CIPPDirectoryId.ps1 @@ -0,0 +1,188 @@ +function Resolve-CIPPDirectoryId { + <# + .SYNOPSIS + Resolves directory identities to canonical Graph object ids. + + .DESCRIPTION + Accepts object ids, UPNs, mail addresses, or other Graph-addressable keys and returns + the corresponding directory object for each. Used so membership, ownership, and + ManagedBy compare/write always operate on ids — for users, groups, and other + directory objects — matching ListGroups/EditGroup. + + GUID-shaped values are resolved via directoryObjects/getByIds (any directory type). + Misses fall back to users/{id} then groups/{id}. + Non-GUID values try users/{identity}, then groups filtered by mail/mailNickname. + + .PARAMETER Identity + One or more identities to resolve. + + .PARAMETER TenantFilter + Tenant id or default domain. + + .OUTPUTS + One PSCustomObject per input: + Input, Id, UserPrincipalName, DisplayName, Mail, MailNickname, + ODataType, Type, ExchangeIdentity, Label, Resolved + + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [AllowEmptyCollection()] + [string[]]$Identity, + + [Parameter(Mandatory = $true)] + [string]$TenantFilter + ) + + $Identities = @( + $Identity | + Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | + ForEach-Object { $_.Trim() } | + Select-Object -Unique + ) + if ($Identities.Count -eq 0) { + return @() + } + + $GuidPattern = '^[0-9a-fA-F]{8}-([0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}$' + $Guids = @($Identities | Where-Object { $_ -match $GuidPattern }) + $Others = @($Identities | Where-Object { $_ -notmatch $GuidPattern }) + + $ResolvedByInput = @{} + + $NewResolved = { + param($InputKey, $Obj) + $ODataType = $Obj.'@odata.type' + $Type = switch -Regex ($ODataType) { + 'user$' { 'User'; break } + 'group$' { 'Group'; break } + 'servicePrincipal$' { 'ServicePrincipal'; break } + 'orgContact$' { 'OrgContact'; break } + default { + if ($Obj.userPrincipalName) { 'User' } + elseif ($null -ne $Obj.mailEnabled -or $null -ne $Obj.groupTypes) { 'Group' } + else { 'DirectoryObject' } + } + } + $Label = $Obj.displayName ?? $Obj.userPrincipalName ?? $Obj.mail ?? $InputKey + # EXO Member/ManagedBy accepts SMTP, UPN, alias, or GUID + $ExchangeIdentity = $Obj.mail ?? $Obj.userPrincipalName ?? $Obj.mailNickname ?? $Obj.id + [pscustomobject]@{ + Input = $InputKey + Id = $Obj.id + UserPrincipalName = $Obj.userPrincipalName + DisplayName = $Obj.displayName + Mail = $Obj.mail + MailNickname = $Obj.mailNickname + ODataType = $ODataType + Type = $Type + ExchangeIdentity = $ExchangeIdentity + Label = $Label + Resolved = $true + } + }.GetNewClosure() + + if ($Guids.Count -gt 0) { + for ($i = 0; $i -lt $Guids.Count; $i += 1000) { + $Chunk = @($Guids[$i..([Math]::Min($i + 999, $Guids.Count - 1))]) + try { + $Body = @{ ids = $Chunk } | ConvertTo-Json -Compress + $ByIds = New-GraphPOSTRequest -uri 'https://graph.microsoft.com/v1.0/directoryObjects/getByIds?$select=id,displayName,userPrincipalName,mail,mailNickname,mailEnabled,groupTypes' -tenantid $TenantFilter -body $Body + foreach ($Obj in @($ByIds.value ?? $ByIds)) { + if (-not $Obj.id) { continue } + $ResolvedByInput[$Obj.id] = & $NewResolved -InputKey $Obj.id -Obj $Obj + } + } catch { + Write-Information "Resolve-CIPPDirectoryId: getByIds failed: $($_.Exception.Message)" + } + + $MissingGuids = @($Chunk | Where-Object { -not $ResolvedByInput.ContainsKey($_) }) + if ($MissingGuids.Count -gt 0) { + $FallbackRequests = foreach ($g in $MissingGuids) { + @( + @{ + id = "user-$g" + method = 'GET' + url = "users/$g`?`$select=id,userPrincipalName,displayName,mail,mailNickname" + } + @{ + id = "group-$g" + method = 'GET' + url = "groups/$g`?`$select=id,displayName,mail,mailNickname,mailEnabled,groupTypes" + } + ) + } + $FallbackResults = New-GraphBulkRequest -Requests @($FallbackRequests) -tenantid $TenantFilter + foreach ($g in $MissingGuids) { + $Hit = $FallbackResults | Where-Object { + ($_.id -eq "user-$g" -or $_.id -eq "group-$g") -and + $_.status -ge 200 -and $_.status -le 299 -and $_.body.id + } | Select-Object -First 1 + if ($Hit) { + $ResolvedByInput[$g] = & $NewResolved -InputKey $g -Obj $Hit.body + } + } + } + } + } + + if ($Others.Count -gt 0) { + $OtherRequests = foreach ($o in $Others) { + $Encoded = if ($o -like '*#EXT#*') { [System.Web.HttpUtility]::UrlEncode($o) } else { $o } + @{ + id = "user-$o" + method = 'GET' + url = "users/$Encoded`?`$select=id,userPrincipalName,displayName,mail,mailNickname" + } + } + $OtherResults = New-GraphBulkRequest -Requests @($OtherRequests) -tenantid $TenantFilter + $StillMissing = [System.Collections.Generic.List[string]]::new() + foreach ($Result in $OtherResults) { + $InputKey = $Result.id -replace '^user-', '' + if ($Result.status -ge 200 -and $Result.status -le 299 -and $Result.body.id) { + $ResolvedByInput[$InputKey] = & $NewResolved -InputKey $InputKey -Obj $Result.body + } else { + $StillMissing.Add($InputKey) + } + } + + # Non-user mail/alias → group lookup (ListUsersAndGroups can surface nested groups by id, + # but CSV / EXO-style addresses need mail or mailNickname). + foreach ($o in $StillMissing) { + $Escaped = $o.Replace("'", "''") + try { + $Filter = [System.Uri]::EscapeDataString("mail eq '$Escaped' or mailNickname eq '$Escaped'") + $GroupHits = New-GraphGetRequest -uri "https://graph.microsoft.com/v1.0/groups?`$filter=$Filter&`$select=id,displayName,mail,mailNickname,mailEnabled,groupTypes&`$top=2" -tenantid $TenantFilter + $GroupObj = @($GroupHits) | Select-Object -First 1 + if ($GroupObj.id) { + $ResolvedByInput[$o] = & $NewResolved -InputKey $o -Obj $GroupObj + } + } catch { + Write-Information "Resolve-CIPPDirectoryId: group filter failed for '$o': $($_.Exception.Message)" + } + } + } + + foreach ($Original in $Identities) { + if ($ResolvedByInput.ContainsKey($Original)) { + $ResolvedByInput[$Original] + } else { + [pscustomobject]@{ + Input = $Original + Id = $null + UserPrincipalName = $null + DisplayName = $null + Mail = $null + MailNickname = $null + ODataType = $null + Type = $null + ExchangeIdentity = $null + Label = $Original + Resolved = $false + } + } + } +} diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Groups/Invoke-ExecGroupMembers.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Groups/Invoke-ExecGroupMembers.ps1 new file mode 100644 index 0000000000..fc095dc6d5 --- /dev/null +++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Groups/Invoke-ExecGroupMembers.ps1 @@ -0,0 +1,67 @@ +function Invoke-ExecGroupMembers { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Identity.Group.ReadWrite + .DESCRIPTION + Manages group membership (members and owners) via a switch-style action parameter. + Accepts one or more directory object IDs, UPNs, or mail addresses (users, groups, etc.). + Automatically resolves the group type from Graph to route through the correct API (Graph or Exchange). + + Supported actions: addMember, removeMember, addOwner, removeOwner + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers + $Body = $Request.Body + + $Action = $Body.action + $GroupId = $Body.groupId + $TenantFilter = $Body.tenantFilter + # Accept a single string or an array of strings (IDs, UPNs, or mail) + $Users = @($Body.users | Where-Object { $_ }) + + if (-not $Action -or -not $GroupId -or -not $TenantFilter -or $Users.Count -eq 0) { + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::BadRequest + Body = @{ Results = 'Required parameters: action, groupId, tenantFilter, users (one or more directory object IDs/UPNs/mail addresses)' } + }) + } + + $ValidActions = @('addMember', 'removeMember', 'addOwner', 'removeOwner') + if ($Action -notin $ValidActions) { + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::BadRequest + Body = @{ Results = "Invalid action '$Action'. Valid actions: $($ValidActions -join ', ')" } + }) + } + + try { + switch ($Action) { + 'addMember' { + $Result = Add-CIPPGroupMember -Headers $Headers -GroupId $GroupId -Member $Users -TenantFilter $TenantFilter -APIName $APIName + } + 'removeMember' { + $Result = Remove-CIPPGroupMember -Headers $Headers -GroupId $GroupId -Member $Users -TenantFilter $TenantFilter -APIName $APIName + } + 'addOwner' { + $Result = Add-CIPPGroupOwner -Headers $Headers -GroupId $GroupId -Owner $Users -TenantFilter $TenantFilter -APIName $APIName + } + 'removeOwner' { + $Result = Remove-CIPPGroupOwner -Headers $Headers -GroupId $GroupId -Owner $Users -TenantFilter $TenantFilter -APIName $APIName + } + } + $StatusCode = [HttpStatusCode]::OK + } catch { + $Result = $_.Exception.Message + $StatusCode = [HttpStatusCode]::InternalServerError + } + + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = @{ Results = $Result } + }) +} diff --git a/backend/Tests/Endpoint/Invoke-EditGroup.Tests.ps1 b/backend/Tests/Endpoint/Invoke-EditGroup.Tests.ps1 index cfdd3d265e..81ab2e7d26 100644 --- a/backend/Tests/Endpoint/Invoke-EditGroup.Tests.ps1 +++ b/backend/Tests/Endpoint/Invoke-EditGroup.Tests.ps1 @@ -33,6 +33,7 @@ BeforeAll { function Get-NormalizedError { param($message) $message } function Get-CippException { param($Exception) @{ NormalizedError = "$Exception" } } function Set-CIPPGroupLicense { param($GroupId, $TenantFilter, $AddLicenses, $RemoveLicenses, $Headers, $APIName) } + function Resolve-CIPPDirectoryId { param($Identity, $TenantFilter) } # Real helper, not a stub: matching Exchange bulk results back to operations is exactly what # the reporting assertions below are checking. @@ -82,6 +83,24 @@ BeforeAll { addedFields = [pscustomobject]@{ userPrincipalName = $Upn } } } + + # Stable id map for ManagedBy rewrite tests (EXO may return UPN or GUID; writes must be ids). + function New-ResolvedOwner { + param([string]$InputIdentity, [string]$Id, [string]$Upn) + [pscustomobject]@{ + Input = $InputIdentity + Id = $Id + UserPrincipalName = $Upn + DisplayName = $null + Mail = $null + MailNickname = $null + ODataType = '#microsoft.graph.user' + Type = 'User' + ExchangeIdentity = $Upn ?? $Id + Label = $Upn ?? $Id + Resolved = $true + } + } } Describe 'Invoke-EditGroup - membership' { @@ -92,6 +111,26 @@ Describe 'Invoke-EditGroup - membership' { Mock -CommandName New-ExoBulkRequest -MockWith { @() } Mock -CommandName New-ExoRequest -MockWith { @() } Mock -CommandName Set-CIPPGroupLicense -MockWith { } + Mock -CommandName Resolve-CIPPDirectoryId -MockWith { + param($Identity, $TenantFilter) + $Map = @{ + 'existing@contoso.com' = 'existing-guid' + 'boss@contoso.com' = 'boss-guid' + 'keep@contoso.com' = 'keep-guid' + 'drop@contoso.com' = 'drop-guid' + 'existing-guid' = 'existing-guid' + 'boss-guid' = 'boss-guid' + 'keep-guid' = 'keep-guid' + 'drop-guid' = 'drop-guid' + } + foreach ($raw in @($Identity)) { + $id = $Map[$raw] ?? $raw + $upn = if ($raw -match '@') { $raw } else { + ($Map.GetEnumerator() | Where-Object { $_.Value -eq $id -and $_.Key -match '@' } | Select-Object -First 1).Key + } + New-ResolvedOwner -InputIdentity $raw -Id $id -Upn $upn + } + } } Context 'Adding members' { @@ -296,7 +335,7 @@ Describe 'Invoke-EditGroup - membership' { } It 'rewrites ManagedBy wholesale when adding an owner to a distribution list' { - # Exchange has no owners collection to append to. + # Exchange has no owners collection to append to. ManagedBy is rewritten with Graph ids. Mock -CommandName New-ExoRequest -MockWith { [pscustomobject]@{ ManagedBy = @('existing@contoso.com') } } $Request = New-GroupRequest -GroupType 'Distribution List' -Body @{ AddOwner = @(New-Member -Upn 'boss@contoso.com' -Value 'boss@contoso.com') @@ -307,8 +346,8 @@ Describe 'Invoke-EditGroup - membership' { Should -Invoke New-ExoBulkRequest -Times 1 -Exactly -ParameterFilter { $Set = $cmdletArray | Where-Object { $_.CmdletInput.CmdletName -eq 'Set-DistributionGroup' } $Set -and - $Set.CmdletInput.Parameters.ManagedBy -contains 'existing@contoso.com' -and - $Set.CmdletInput.Parameters.ManagedBy -contains 'boss@contoso.com' + $Set.CmdletInput.Parameters.ManagedBy -contains 'existing-guid' -and + $Set.CmdletInput.Parameters.ManagedBy -contains 'boss-guid' } } @@ -340,7 +379,8 @@ Describe 'Invoke-EditGroup - membership' { Should -Invoke New-ExoBulkRequest -Times 1 -Exactly -ParameterFilter { $Set = $cmdletArray | Where-Object { $_.CmdletInput.CmdletName -eq 'Set-DistributionGroup' } $Set -and - $Set.CmdletInput.Parameters.ManagedBy -contains 'keep@contoso.com' -and + $Set.CmdletInput.Parameters.ManagedBy -contains 'keep-guid' -and + $Set.CmdletInput.Parameters.ManagedBy -notcontains 'drop-guid' -and $Set.CmdletInput.Parameters.ManagedBy -notcontains 'drop@contoso.com' } } diff --git a/backend/Tests/Endpoint/Invoke-ExecGroupMembers.Tests.ps1 b/backend/Tests/Endpoint/Invoke-ExecGroupMembers.Tests.ps1 new file mode 100644 index 0000000000..d9a0de7598 --- /dev/null +++ b/backend/Tests/Endpoint/Invoke-ExecGroupMembers.Tests.ps1 @@ -0,0 +1,159 @@ +# Pester tests for Invoke-ExecGroupMembers. +# +# Thin switch over Add/Remove-CIPPGroupMember/Owner. The helpers own Graph-vs-Exchange +# routing; this endpoint owns action validation, required-body checks, and HTTP status mapping. + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $FunctionPath = Join-Path $RepoRoot 'Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Groups/Invoke-ExecGroupMembers.ps1' + if (-not (Test-Path $FunctionPath)) { throw "Could not locate Invoke-ExecGroupMembers.ps1 at $FunctionPath" } + + class HttpResponseContext { + [object]$StatusCode + [object]$Body + } + $Accelerators = [PSObject].Assembly.GetType('System.Management.Automation.TypeAccelerators') + if (-not ('HttpStatusCode' -as [type])) { + $Accelerators::Add('HttpStatusCode', [System.Net.HttpStatusCode]) + } + + function Add-CIPPGroupMember { param($Headers, $GroupId, $Member, $TenantFilter, $APIName, $GroupType) } + function Remove-CIPPGroupMember { param($Headers, $GroupId, $Member, $TenantFilter, $APIName, $GroupType) } + function Add-CIPPGroupOwner { param($Headers, $GroupId, $Owner, $TenantFilter, $APIName) } + function Remove-CIPPGroupOwner { param($Headers, $GroupId, $Owner, $TenantFilter, $APIName) } + + . $FunctionPath + + function New-MembersRequest { + param( + [string]$Action = 'addMember', + [string]$GroupId = 'group-guid', + [string]$TenantFilter = 'contoso.com', + $Users = @('boss@contoso.com') + ) + $Body = [ordered]@{} + if ($null -ne $Action) { $Body.action = $Action } + if ($null -ne $GroupId) { $Body.groupId = $GroupId } + if ($null -ne $TenantFilter) { $Body.tenantFilter = $TenantFilter } + if ($null -ne $Users) { $Body.users = $Users } + + [pscustomobject]@{ + Params = @{ CIPPEndpoint = 'ExecGroupMembers' } + Headers = @{} + Body = [pscustomobject]$Body + } + } +} + +Describe 'Invoke-ExecGroupMembers' { + BeforeEach { + Mock -CommandName Add-CIPPGroupMember -MockWith { 'Successfully added member' } + Mock -CommandName Remove-CIPPGroupMember -MockWith { 'Successfully removed member' } + Mock -CommandName Add-CIPPGroupOwner -MockWith { 'Successfully added owner' } + Mock -CommandName Remove-CIPPGroupOwner -MockWith { 'Successfully removed owner' } + } + + Context 'Request validation' { + It 'returns BadRequest when action is missing' { + $Response = Invoke-ExecGroupMembers -Request (New-MembersRequest -Action $null) + + $Response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::BadRequest) + $Response.Body.Results | Should -BeLike '*Required parameters*' + Should -Invoke Add-CIPPGroupMember -Times 0 -Exactly + } + + It 'returns BadRequest when groupId is missing' { + $Response = Invoke-ExecGroupMembers -Request (New-MembersRequest -GroupId $null) + + $Response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::BadRequest) + Should -Invoke Add-CIPPGroupMember -Times 0 -Exactly + } + + It 'returns BadRequest when tenantFilter is missing' { + $Response = Invoke-ExecGroupMembers -Request (New-MembersRequest -TenantFilter $null) + + $Response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::BadRequest) + Should -Invoke Add-CIPPGroupMember -Times 0 -Exactly + } + + It 'returns BadRequest when users is empty' { + $Response = Invoke-ExecGroupMembers -Request (New-MembersRequest -Users @()) + + $Response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::BadRequest) + Should -Invoke Add-CIPPGroupMember -Times 0 -Exactly + } + + It 'returns BadRequest for an unknown action' { + $Response = Invoke-ExecGroupMembers -Request (New-MembersRequest -Action 'renameGroup') + + $Response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::BadRequest) + $Response.Body.Results | Should -BeLike '*Invalid action*' + Should -Invoke Add-CIPPGroupMember -Times 0 -Exactly + Should -Invoke Add-CIPPGroupOwner -Times 0 -Exactly + } + } + + Context 'Action routing' { + It 'calls Add-CIPPGroupMember for addMember' { + $Response = Invoke-ExecGroupMembers -Request (New-MembersRequest -Action 'addMember' -Users @('one@contoso.com', 'two@contoso.com')) + + $Response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::OK) + $Response.Body.Results | Should -Be 'Successfully added member' + Should -Invoke Add-CIPPGroupMember -Times 1 -Exactly -ParameterFilter { + $GroupId -eq 'group-guid' -and + $TenantFilter -eq 'contoso.com' -and + $Member.Count -eq 2 -and + $Member[0] -eq 'one@contoso.com' + } + Should -Invoke Add-CIPPGroupOwner -Times 0 -Exactly + } + + It 'calls Remove-CIPPGroupMember for removeMember' { + $Response = Invoke-ExecGroupMembers -Request (New-MembersRequest -Action 'removeMember') + + $Response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::OK) + Should -Invoke Remove-CIPPGroupMember -Times 1 -Exactly -ParameterFilter { + $GroupId -eq 'group-guid' -and $Member[0] -eq 'boss@contoso.com' + } + } + + It 'calls Add-CIPPGroupOwner for addOwner' { + $Response = Invoke-ExecGroupMembers -Request (New-MembersRequest -Action 'addOwner') + + $Response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::OK) + Should -Invoke Add-CIPPGroupOwner -Times 1 -Exactly -ParameterFilter { + $GroupId -eq 'group-guid' -and $Owner[0] -eq 'boss@contoso.com' + } + Should -Invoke Add-CIPPGroupMember -Times 0 -Exactly + } + + It 'calls Remove-CIPPGroupOwner for removeOwner' { + $Response = Invoke-ExecGroupMembers -Request (New-MembersRequest -Action 'removeOwner') + + $Response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::OK) + Should -Invoke Remove-CIPPGroupOwner -Times 1 -Exactly -ParameterFilter { + $GroupId -eq 'group-guid' -and $Owner[0] -eq 'boss@contoso.com' + } + } + + It 'accepts a single user string as well as an array' { + $Response = Invoke-ExecGroupMembers -Request (New-MembersRequest -Action 'addMember' -Users 'solo@contoso.com') + + $Response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::OK) + Should -Invoke Add-CIPPGroupMember -Times 1 -Exactly -ParameterFilter { + $Member.Count -eq 1 -and $Member[0] -eq 'solo@contoso.com' + } + } + } + + Context 'Error mapping' { + It 'returns InternalServerError when the helper throws' { + Mock -CommandName Add-CIPPGroupOwner -MockWith { throw 'Failed to add owner boss@contoso.com (user not found)' } + + $Response = Invoke-ExecGroupMembers -Request (New-MembersRequest -Action 'addOwner') + + $Response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::InternalServerError) + $Response.Body.Results | Should -BeLike '*user not found*' + } + } +} diff --git a/backend/Tests/Private/Add-CIPPGroupMember.Tests.ps1 b/backend/Tests/Private/Add-CIPPGroupMember.Tests.ps1 index efc29d4d0d..8b76beacdd 100644 --- a/backend/Tests/Private/Add-CIPPGroupMember.Tests.ps1 +++ b/backend/Tests/Private/Add-CIPPGroupMember.Tests.ps1 @@ -17,10 +17,14 @@ BeforeAll { if (-not $FunctionPath) { throw 'Could not locate Add-CIPPGroupMember.ps1 under Modules/' } function New-GraphBulkRequest { param($Requests, $tenantid, $scope, $asapp) } + function New-GraphGetRequest { param($uri, $tenantid) } + function New-GraphPOSTRequest { param($uri, $tenantid, $body) } function New-ExoBulkRequest { param($tenantid, $cmdletArray, $useSystemMailbox) } + function New-ExoRequest { param($tenantid, $cmdlet, $cmdParams, $Select, $UseSystemMailbox) } function Write-LogMessage { param($headers, $API, $tenant, $message, $Sev, $LogData) } function Get-NormalizedError { param($message) $message } function Get-CippException { param($Exception) @{ NormalizedError = "$Exception" } } + function Resolve-CIPPDirectoryId { param($Identity, $TenantFilter) } # Real helper, not a stub: correlating Exchange bulk results back to operations is the thing # these tests are checking, so it has to be the production implementation. @@ -35,27 +39,32 @@ BeforeAll { if (-not $ErrorTextPath) { throw 'Could not locate Get-CippExoErrorText.ps1 under Modules/' } . $ErrorTextPath + $GroupTypePath = Get-ChildItem -Path (Join-Path $RepoRoot 'Modules') -Recurse -Filter 'Get-CIPPGroupType.ps1' -File -ErrorAction SilentlyContinue | + Select-Object -First 1 -ExpandProperty FullName + if (-not $GroupTypePath) { throw 'Could not locate Get-CIPPGroupType.ps1 under Modules/' } + . $GroupTypePath + . $FunctionPath - # Graph bulk responses for the lookup leg: one entry per requested user plus the group. - function New-LookupResponse { - param( - [hashtable[]]$Users, - [string]$GroupDisplayName = 'Contoso Group', - [hashtable]$GroupBody - ) - $Response = foreach ($User in $Users) { - [pscustomobject]@{ - id = "users-$($User.upn)" - status = 200 - body = [pscustomobject]@{ id = $User.id; userPrincipalName = $User.upn } - } + function New-ResolvedDirectoryObject { + param($InputIdentity, $Id, $Upn, $DisplayName, [string]$Type = 'User', [bool]$Resolved = $true) + $Label = $DisplayName ?? $Upn ?? $InputIdentity + [pscustomobject]@{ + Input = $InputIdentity + Id = $Id + UserPrincipalName = $Upn + DisplayName = $DisplayName + Mail = $null + MailNickname = $null + ODataType = "#microsoft.graph.$($Type.ToLowerInvariant())" + Type = $Type + ExchangeIdentity = $Upn ?? $Id + Label = $Label + Resolved = $Resolved } - $Body = if ($GroupBody) { [pscustomobject]$GroupBody } else { [pscustomobject]@{ id = 'group-guid'; displayName = $GroupDisplayName } } - @($Response) + @([pscustomobject]@{ id = 'group'; status = 200; body = $Body }) } - # Graph bulk responses for the membership-add leg, keyed by user object id. + # Graph bulk responses for the membership-add leg, keyed by directory object id. function New-AddResponse { param([hashtable[]]$Results) foreach ($Result in $Results) { @@ -74,6 +83,29 @@ Describe 'Add-CIPPGroupMember' { BeforeEach { Mock -CommandName Write-LogMessage -MockWith { } Mock -CommandName New-ExoBulkRequest -MockWith { @() } + Mock -CommandName New-GraphBulkRequest -MockWith { @() } + Mock -CommandName New-GraphGetRequest -MockWith { + [pscustomobject]@{ id = 'group-guid'; displayName = 'Contoso Group' } + } + Mock -CommandName New-ExoRequest -MockWith { throw 'Distribution group not found' } + # Directory resolution is covered by Resolve-CIPPDirectoryId tests; here we stub a + # stable id map matching the historical New-LookupResponse fixtures. + Mock -CommandName Resolve-CIPPDirectoryId -MockWith { + param($Identity, $TenantFilter) + foreach ($raw in @($Identity)) { + $id = switch -Wildcard ($raw) { + 'sseck@*' { 'user-1' } + 'one@*' { 'user-1' } + 'two@*' { 'user-2' } + 'ok@*' { 'user-1' } + 'bad@*' { 'user-2' } + '*#EXT#*' { 'user-1' } + default { $raw } + } + $upn = if ($raw -match '@' -or $raw -like '*#EXT#*') { $raw } else { $null } + New-ResolvedDirectoryObject -InputIdentity $raw -Id $id -Upn $upn + } + } } Context 'Routing to Exchange Online for mail-based groups' { @@ -84,10 +116,6 @@ Describe 'Add-CIPPGroupMember' { @{ GroupType = 'Distribution list' } @{ GroupType = 'Mail-Enabled Security' } ) { - Mock -CommandName New-GraphBulkRequest -MockWith { - New-LookupResponse -Users @(@{ id = 'user-1'; upn = 'sseck@contoso.com' }) - } - $Result = Add-CIPPGroupMember -GroupType $GroupType -GroupId 'group-guid' -Member @('sseck@contoso.com') -TenantFilter 'contoso.com' Should -Invoke New-ExoBulkRequest -Times 1 -Exactly -ParameterFilter { @@ -96,41 +124,29 @@ Describe 'Add-CIPPGroupMember' { $cmdletArray[0].CmdletInput.Parameters.Member -eq 'sseck@contoso.com' -and $cmdletArray[0].CmdletInput.Parameters.BypassSecurityGroupManagerCheck -eq $true } - # Only the lookup should have gone to Graph, never a members/$ref POST. - Should -Invoke New-GraphBulkRequest -Times 1 -Exactly - $Result | Should -Be 'Successfully added user sseck@contoso.com to group Contoso Group.' + # Resolve is mocked; Graph membership POST must not run for Exchange-backed groups. + Should -Invoke New-GraphBulkRequest -Times 0 -Exactly + $Result | Should -Be 'Successfully added sseck@contoso.com to group Contoso Group.' } It 'routes on group type case-insensitively' { # Invoke-ListGroups emits 'Distribution List' (capital L) while callers and the # frontend template mapper use 'Distribution list'. Both must reach Exchange. - Mock -CommandName New-GraphBulkRequest -MockWith { - New-LookupResponse -Users @(@{ id = 'user-1'; upn = 'sseck@contoso.com' }) - } - $null = Add-CIPPGroupMember -GroupType 'Distribution List' -GroupId 'group-guid' -Member @('sseck@contoso.com') -TenantFilter 'contoso.com' Should -Invoke New-ExoBulkRequest -Times 1 -Exactly + Should -Invoke New-GraphBulkRequest -Times 0 -Exactly } It 'batches every member into a single Exchange bulk call' { - Mock -CommandName New-GraphBulkRequest -MockWith { - New-LookupResponse -Users @( - @{ id = 'user-1'; upn = 'one@contoso.com' } - @{ id = 'user-2'; upn = 'two@contoso.com' } - ) - } - $Result = Add-CIPPGroupMember -GroupType 'Distribution list' -GroupId 'group-guid' -Member @('one@contoso.com', 'two@contoso.com') -TenantFilter 'contoso.com' Should -Invoke New-ExoBulkRequest -Times 1 -Exactly -ParameterFilter { $cmdletArray.Count -eq 2 } - $Result | Should -Be 'Successfully added user one@contoso.com, two@contoso.com to group Contoso Group.' + Should -Invoke New-GraphBulkRequest -Times 0 -Exactly + $Result | Should -Be 'Successfully added one@contoso.com, two@contoso.com to group Contoso Group.' } It 'throws when Exchange reports an error for the batch' { - Mock -CommandName New-GraphBulkRequest -MockWith { - New-LookupResponse -Users @(@{ id = 'user-1'; upn = 'sseck@contoso.com' }) - } Mock -CommandName New-ExoBulkRequest -MockWith { @([pscustomobject]@{ target = 'sseck@contoso.com'; error = 'Cannot Update a mail-enabled security groups and or distribution list.' }) } @@ -140,24 +156,18 @@ Describe 'Add-CIPPGroupMember' { } It 'does not call Exchange when the user lookup returned nobody' { - Mock -CommandName New-GraphBulkRequest -MockWith { - @([pscustomobject]@{ id = 'group'; status = 200; body = [pscustomobject]@{ id = 'group-guid'; displayName = 'Contoso Group' } }) - } - $null = Add-CIPPGroupMember -GroupType 'Distribution list' -GroupId 'group-guid' -Member @() -TenantFilter 'contoso.com' Should -Invoke New-ExoBulkRequest -Times 0 -Exactly + Should -Invoke New-GraphBulkRequest -Times 0 -Exactly } } Context 'Routing to Graph for directory groups' { It 'POSTs a members/$ref bind for a security group' { - Mock -CommandName New-GraphBulkRequest -MockWith { - New-LookupResponse -Users @(@{ id = 'user-1'; upn = 'sseck@contoso.com' }) - } -ParameterFilter { $Requests.method -contains 'GET' } Mock -CommandName New-GraphBulkRequest -MockWith { New-AddResponse -Results @(@{ id = 'user-1'; status = 204 }) - } -ParameterFilter { $Requests.method -contains 'POST' } + } $Result = Add-CIPPGroupMember -GroupType 'Security' -GroupId 'group-guid' -Member @('sseck@contoso.com') -TenantFilter 'contoso.com' @@ -167,48 +177,54 @@ Describe 'Add-CIPPGroupMember' { $Requests[0].body.'@odata.id' -eq 'https://graph.microsoft.com/v1.0/directoryObjects/user-1' } Should -Invoke New-ExoBulkRequest -Times 0 -Exactly - $Result | Should -Be 'Successfully added user sseck@contoso.com to group Contoso Group.' + $Result | Should -Be 'Successfully added sseck@contoso.com to group Contoso Group.' } - It 'reports both the successes and the failures of a mixed batch without throwing' { + It 'POSTs directoryObjects/{group-guid} when Resolve returns a Group' { + $NestedGroupId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' + Mock -CommandName Resolve-CIPPDirectoryId -MockWith { + New-ResolvedDirectoryObject -InputIdentity $NestedGroupId -Id $NestedGroupId -DisplayName 'Nested SG' -Type 'Group' + } Mock -CommandName New-GraphBulkRequest -MockWith { - New-LookupResponse -Users @( - @{ id = 'user-1'; upn = 'ok@contoso.com' } - @{ id = 'user-2'; upn = 'bad@contoso.com' } - ) - } -ParameterFilter { $Requests.method -contains 'GET' } + New-AddResponse -Results @(@{ id = $NestedGroupId; status = 204 }) + } + + $Result = Add-CIPPGroupMember -GroupType 'Security' -GroupId 'group-guid' -Member @($NestedGroupId) -TenantFilter 'contoso.com' + + Should -Invoke New-GraphBulkRequest -Times 1 -Exactly -ParameterFilter { + $Requests[0].method -eq 'POST' -and + $Requests[0].body.'@odata.id' -eq "https://graph.microsoft.com/v1.0/directoryObjects/$NestedGroupId" + } + $Result | Should -Be 'Successfully added Nested SG to group Contoso Group.' + } + + It 'reports both the successes and the failures of a mixed batch without throwing' { Mock -CommandName New-GraphBulkRequest -MockWith { New-AddResponse -Results @( @{ id = 'user-1'; status = 204 } @{ id = 'user-2'; status = 400; message = 'One or more added object references already exist' } ) - } -ParameterFilter { $Requests.method -contains 'POST' } + } $Result = Add-CIPPGroupMember -GroupType 'Security' -GroupId 'group-guid' -Member @('ok@contoso.com', 'bad@contoso.com') -TenantFilter 'contoso.com' - $Result | Should -Be 'Successfully added user ok@contoso.com to group Contoso Group. Failed to add bad@contoso.com (One or more added object references already exist).' + $Result | Should -Be 'Successfully added ok@contoso.com to group Contoso Group. Failed to add bad@contoso.com (One or more added object references already exist).' } It 'throws when every member of the batch failed' { # New-CIPPUserTask relies on this throw to decide whether to schedule a retry. - Mock -CommandName New-GraphBulkRequest -MockWith { - New-LookupResponse -Users @(@{ id = 'user-1'; upn = 'sseck@contoso.com' }) - } -ParameterFilter { $Requests.method -contains 'GET' } Mock -CommandName New-GraphBulkRequest -MockWith { New-AddResponse -Results @(@{ id = 'user-1'; status = 400; message = 'Cannot Update a mail-enabled security groups and or distribution list.' }) - } -ParameterFilter { $Requests.method -contains 'POST' } + } { Add-CIPPGroupMember -GroupType 'Security' -GroupId 'group-guid' -Member @('sseck@contoso.com') -TenantFilter 'contoso.com' } | Should -Throw -ExpectedMessage '*Cannot Update a mail-enabled security groups*' } It 'falls back to a status-based message when Graph returns no error body' { - Mock -CommandName New-GraphBulkRequest -MockWith { - New-LookupResponse -Users @(@{ id = 'user-1'; upn = 'sseck@contoso.com' }) - } -ParameterFilter { $Requests.method -contains 'GET' } Mock -CommandName New-GraphBulkRequest -MockWith { New-AddResponse -Results @(@{ id = 'user-1'; status = 503 }) - } -ParameterFilter { $Requests.method -contains 'POST' } + } { Add-CIPPGroupMember -GroupType 'Security' -GroupId 'group-guid' -Member @('sseck@contoso.com') -TenantFilter 'contoso.com' } | Should -Throw -ExpectedMessage '*Request failed with status 503*' @@ -216,22 +232,16 @@ Describe 'Add-CIPPGroupMember' { It 'keeps only the first translation when Get-NormalizedError returns several' { Mock -CommandName Get-NormalizedError -MockWith { @('First translation', 'Second translation') } - Mock -CommandName New-GraphBulkRequest -MockWith { - New-LookupResponse -Users @( - @{ id = 'user-1'; upn = 'ok@contoso.com' } - @{ id = 'user-2'; upn = 'bad@contoso.com' } - ) - } -ParameterFilter { $Requests.method -contains 'GET' } Mock -CommandName New-GraphBulkRequest -MockWith { New-AddResponse -Results @( @{ id = 'user-1'; status = 204 } @{ id = 'user-2'; status = 400; message = 'ambiguous' } ) - } -ParameterFilter { $Requests.method -contains 'POST' } + } $Result = Add-CIPPGroupMember -GroupType 'Security' -GroupId 'group-guid' -Member @('ok@contoso.com', 'bad@contoso.com') -TenantFilter 'contoso.com' - $Result | Should -Be 'Successfully added user ok@contoso.com to group Contoso Group. Failed to add bad@contoso.com (First translation).' + $Result | Should -Be 'Successfully added ok@contoso.com to group Contoso Group. Failed to add bad@contoso.com (First translation).' } } @@ -241,8 +251,8 @@ Describe 'Add-CIPPGroupMember' { # saved. Graph tells us what the group really is in the same lookup we already make, so # that answer wins; the caller's value is only a fallback for when the lookup says nothing. It 'sends a classic distribution list to Exchange even when the caller passed no type' { - Mock -CommandName New-GraphBulkRequest -MockWith { - New-LookupResponse -Users @(@{ id = 'user-1'; upn = 'sseck@contoso.com' }) -GroupBody @{ + Mock -CommandName New-GraphGetRequest -MockWith { + [pscustomobject]@{ id = 'group-guid'; displayName = 'All Office' groupTypes = @(); mailEnabled = $true; securityEnabled = $false } @@ -253,12 +263,13 @@ Describe 'Add-CIPPGroupMember' { Should -Invoke New-ExoBulkRequest -Times 1 -Exactly -ParameterFilter { $cmdletArray[0].CmdletInput.CmdletName -eq 'Add-DistributionGroupMember' } - $Result | Should -Be 'Successfully added user sseck@contoso.com to group All Office.' + Should -Invoke New-GraphBulkRequest -Times 0 -Exactly + $Result | Should -Be 'Successfully added sseck@contoso.com to group All Office.' } It 'sends a mail-enabled security group to Exchange even when the caller passed no type' { - Mock -CommandName New-GraphBulkRequest -MockWith { - New-LookupResponse -Users @(@{ id = 'user-1'; upn = 'sseck@contoso.com' }) -GroupBody @{ + Mock -CommandName New-GraphGetRequest -MockWith { + [pscustomobject]@{ id = 'group-guid'; displayName = 'SG-LIC-M365' groupTypes = @(); mailEnabled = $true; securityEnabled = $true } @@ -267,13 +278,14 @@ Describe 'Add-CIPPGroupMember' { $null = Add-CIPPGroupMember -GroupId 'group-guid' -Member @('sseck@contoso.com') -TenantFilter 'contoso.com' Should -Invoke New-ExoBulkRequest -Times 1 -Exactly + Should -Invoke New-GraphBulkRequest -Times 0 -Exactly } It 'overrides a caller-supplied type that disagrees with the group' { # A stale template option saying 'Security' must not push a distribution list down the # Graph path, which is exactly how the reported failure happened. - Mock -CommandName New-GraphBulkRequest -MockWith { - New-LookupResponse -Users @(@{ id = 'user-1'; upn = 'sseck@contoso.com' }) -GroupBody @{ + Mock -CommandName New-GraphGetRequest -MockWith { + [pscustomobject]@{ id = 'group-guid'; displayName = 'IEQ-Team' groupTypes = @(); mailEnabled = $true; securityEnabled = $false } @@ -282,19 +294,20 @@ Describe 'Add-CIPPGroupMember' { $null = Add-CIPPGroupMember -GroupType 'Security' -GroupId 'group-guid' -Member @('sseck@contoso.com') -TenantFilter 'contoso.com' Should -Invoke New-ExoBulkRequest -Times 1 -Exactly + Should -Invoke New-GraphBulkRequest -Times 0 -Exactly } It 'keeps a Microsoft 365 group on Graph even though it is mail-enabled' { # Unified groups are mail-enabled but Graph owns their membership. - Mock -CommandName New-GraphBulkRequest -MockWith { - New-LookupResponse -Users @(@{ id = 'user-1'; upn = 'sseck@contoso.com' }) -GroupBody @{ + Mock -CommandName New-GraphGetRequest -MockWith { + [pscustomobject]@{ id = 'group-guid'; displayName = 'IEQ - ALL' groupTypes = @('Unified'); mailEnabled = $true; securityEnabled = $false } - } -ParameterFilter { $Requests.method -contains 'GET' } + } Mock -CommandName New-GraphBulkRequest -MockWith { New-AddResponse -Results @(@{ id = 'user-1'; status = 204 }) - } -ParameterFilter { $Requests.method -contains 'POST' } + } $null = Add-CIPPGroupMember -GroupType 'Distribution list' -GroupId 'group-guid' -Member @('sseck@contoso.com') -TenantFilter 'contoso.com' @@ -303,15 +316,15 @@ Describe 'Add-CIPPGroupMember' { } It 'keeps a plain security group on Graph' { - Mock -CommandName New-GraphBulkRequest -MockWith { - New-LookupResponse -Users @(@{ id = 'user-1'; upn = 'sseck@contoso.com' }) -GroupBody @{ + Mock -CommandName New-GraphGetRequest -MockWith { + [pscustomobject]@{ id = 'group-guid'; displayName = 'All-Users' groupTypes = @(); mailEnabled = $false; securityEnabled = $true } - } -ParameterFilter { $Requests.method -contains 'GET' } + } Mock -CommandName New-GraphBulkRequest -MockWith { New-AddResponse -Results @(@{ id = 'user-1'; status = 204 }) - } -ParameterFilter { $Requests.method -contains 'POST' } + } $null = Add-CIPPGroupMember -GroupType 'Distribution list' -GroupId 'group-guid' -Member @('sseck@contoso.com') -TenantFilter 'contoso.com' @@ -321,10 +334,8 @@ Describe 'Add-CIPPGroupMember' { It 'falls back to the caller-supplied type when the group lookup returned nothing usable' { # Addressing a group by mail rather than GUID, or a lookup that 404s, leaves us with # only what the caller told us. - Mock -CommandName New-GraphBulkRequest -MockWith { - New-LookupResponse -Users @(@{ id = 'user-1'; upn = 'sseck@contoso.com' }) -GroupBody @{ - id = $null; displayName = $null - } + Mock -CommandName New-GraphGetRequest -MockWith { + [pscustomobject]@{ id = $null; displayName = $null } } $null = Add-CIPPGroupMember -GroupType 'Distribution list' -GroupId 'All Office' -Member @('sseck@contoso.com') -TenantFilter 'contoso.com' @@ -332,55 +343,54 @@ Describe 'Add-CIPPGroupMember' { Should -Invoke New-ExoBulkRequest -Times 1 -Exactly -ParameterFilter { $cmdletArray[0].CmdletInput.Parameters.Identity -eq 'All Office' } + Should -Invoke New-GraphBulkRequest -Times 0 -Exactly } } Context 'Member lookup' { - It 'url-encodes guest accounts so the #EXT# segment survives the request' { - Mock -CommandName New-GraphBulkRequest -MockWith { - New-LookupResponse -Users @(@{ id = 'user-1'; upn = 'guest_partner.com#EXT#@contoso.onmicrosoft.com' }) - } -ParameterFilter { $Requests.method -contains 'GET' } + It 'passes guest identities through to Resolve-CIPPDirectoryId' { Mock -CommandName New-GraphBulkRequest -MockWith { New-AddResponse -Results @(@{ id = 'user-1'; status = 204 }) - } -ParameterFilter { $Requests.method -contains 'POST' } + } - $null = Add-CIPPGroupMember -GroupType 'Security' -GroupId 'group-guid' -Member @('guest_partner.com#EXT#@contoso.onmicrosoft.com') -TenantFilter 'contoso.com' + $Guest = 'guest_partner.com#EXT#@contoso.onmicrosoft.com' + $null = Add-CIPPGroupMember -GroupType 'Security' -GroupId 'group-guid' -Member @($Guest) -TenantFilter 'contoso.com' - Should -Invoke New-GraphBulkRequest -Times 1 -Exactly -ParameterFilter { - $Requests[0].url -like 'users/*%23EXT%23*' + Should -Invoke Resolve-CIPPDirectoryId -Times 1 -Exactly -ParameterFilter { + @($Identity) -contains $Guest } } - It 'asks for the group alongside the members in one round trip' { - Mock -CommandName New-GraphBulkRequest -MockWith { - New-LookupResponse -Users @(@{ id = 'user-1'; upn = 'sseck@contoso.com' }) - } -ParameterFilter { $Requests.method -contains 'GET' } + It 'resolves the group type via Get-CIPPGroupType before looking up members' { Mock -CommandName New-GraphBulkRequest -MockWith { New-AddResponse -Results @(@{ id = 'user-1'; status = 204 }) - } -ParameterFilter { $Requests.method -contains 'POST' } + } $null = Add-CIPPGroupMember -GroupType 'Security' -GroupId 'group-guid' -Member @('sseck@contoso.com') -TenantFilter 'contoso.com' + Should -Invoke New-GraphGetRequest -Times 1 -Exactly -ParameterFilter { + $uri -like '*groups/group-guid*' + } Should -Invoke New-GraphBulkRequest -Times 1 -Exactly -ParameterFilter { - ($Requests | Where-Object { $_.id -eq 'group' }).url -like 'groups/group-guid*' + $Requests[0].method -eq 'POST' } } It 'falls back to the group id in messages when the display name lookup came back empty' { - Mock -CommandName New-GraphBulkRequest -MockWith { - New-LookupResponse -Users @(@{ id = 'user-1'; upn = 'sseck@contoso.com' }) -GroupBody @{ id = 'group-guid'; displayName = $null } - } -ParameterFilter { $Requests.method -contains 'GET' } + Mock -CommandName New-GraphGetRequest -MockWith { + [pscustomobject]@{ id = 'group-guid'; displayName = $null } + } Mock -CommandName New-GraphBulkRequest -MockWith { New-AddResponse -Results @(@{ id = 'user-1'; status = 204 }) - } -ParameterFilter { $Requests.method -contains 'POST' } + } $Result = Add-CIPPGroupMember -GroupType 'Security' -GroupId 'group-guid' -Member @('sseck@contoso.com') -TenantFilter 'contoso.com' - $Result | Should -Be 'Successfully added user sseck@contoso.com to group group-guid.' + $Result | Should -Be 'Successfully added sseck@contoso.com to group group-guid.' } It 'surfaces a lookup failure as a thrown, member-scoped message' { - Mock -CommandName New-GraphBulkRequest -MockWith { throw 'Graph unavailable' } + Mock -CommandName Resolve-CIPPDirectoryId -MockWith { throw 'Graph unavailable' } { Add-CIPPGroupMember -GroupType 'Security' -GroupId 'group-guid' -Member @('sseck@contoso.com') -TenantFilter 'contoso.com' } | Should -Throw -ExpectedMessage '*sseck@contoso.com*Graph unavailable*' @@ -389,34 +399,25 @@ Describe 'Add-CIPPGroupMember' { Context 'Audit logging' { It 'logs the outcome against the tenant so it shows up in the CIPP log' { - Mock -CommandName New-GraphBulkRequest -MockWith { - New-LookupResponse -Users @(@{ id = 'user-1'; upn = 'sseck@contoso.com' }) - } -ParameterFilter { $Requests.method -contains 'GET' } Mock -CommandName New-GraphBulkRequest -MockWith { New-AddResponse -Results @(@{ id = 'user-1'; status = 204 }) - } -ParameterFilter { $Requests.method -contains 'POST' } + } $null = Add-CIPPGroupMember -GroupType 'Security' -GroupId 'group-guid' -Member @('sseck@contoso.com') -TenantFilter 'contoso.com' -APIName 'Add Group Member' Should -Invoke Write-LogMessage -Times 1 -Exactly -ParameterFilter { $API -eq 'Add Group Member' -and $tenant -eq 'contoso.com' -and $Sev -eq 'Info' -and - $message -eq 'Successfully added user sseck@contoso.com to group Contoso Group.' + $message -eq 'Successfully added sseck@contoso.com to group Contoso Group.' } } It 'logs each Graph failure at Error severity' { - Mock -CommandName New-GraphBulkRequest -MockWith { - New-LookupResponse -Users @( - @{ id = 'user-1'; upn = 'ok@contoso.com' } - @{ id = 'user-2'; upn = 'bad@contoso.com' } - ) - } -ParameterFilter { $Requests.method -contains 'GET' } Mock -CommandName New-GraphBulkRequest -MockWith { New-AddResponse -Results @( @{ id = 'user-1'; status = 204 } @{ id = 'user-2'; status = 400; message = 'boom' } ) - } -ParameterFilter { $Requests.method -contains 'POST' } + } $null = Add-CIPPGroupMember -GroupType 'Security' -GroupId 'group-guid' -Member @('ok@contoso.com', 'bad@contoso.com') -TenantFilter 'contoso.com' diff --git a/backend/Tests/Private/Add-CIPPGroupOwner.Tests.ps1 b/backend/Tests/Private/Add-CIPPGroupOwner.Tests.ps1 new file mode 100644 index 0000000000..12d6704ce0 --- /dev/null +++ b/backend/Tests/Private/Add-CIPPGroupOwner.Tests.ps1 @@ -0,0 +1,245 @@ +# Pester tests for Add-CIPPGroupOwner. +# +# Owners on Graph-backed groups are a POST to owners/$ref. Owners on classic DLs and +# mail-enabled security groups are a wholesale ManagedBy rewrite via Set-DistributionGroup +# (there is no Add-DistributionGroupOwner). Identities are resolved to Graph ids first so +# compare/write matches ListGroups and EditGroup. + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $FunctionPath = Get-ChildItem -Path (Join-Path $RepoRoot 'Modules') -Recurse -Filter 'Add-CIPPGroupOwner.ps1' -File -ErrorAction SilentlyContinue | + Select-Object -First 1 -ExpandProperty FullName + if (-not $FunctionPath) { throw 'Could not locate Add-CIPPGroupOwner.ps1 under Modules/' } + + function New-GraphBulkRequest { param($Requests, $tenantid, $scope, $asapp) } + function New-GraphGetRequest { param($uri, $tenantid) } + function New-ExoBulkRequest { param($tenantid, $cmdletArray, $useSystemMailbox) } + function New-ExoRequest { param($tenantid, $cmdlet, $cmdParams, $Select, $UseSystemMailbox) } + function Write-LogMessage { param($headers, $API, $tenant, $message, $Sev, $LogData) } + function Get-NormalizedError { param($message) $message } + function Get-CippException { param($Exception) @{ NormalizedError = "$Exception" } } + function Resolve-CIPPDirectoryId { param($Identity, $TenantFilter) } + + $ResolverPath = Get-ChildItem -Path (Join-Path $RepoRoot 'Modules') -Recurse -Filter 'Resolve-CippExoBulkResult.ps1' -File -ErrorAction SilentlyContinue | + Select-Object -First 1 -ExpandProperty FullName + if (-not $ResolverPath) { throw 'Could not locate Resolve-CippExoBulkResult.ps1 under Modules/' } + . $ResolverPath + + $ErrorTextPath = Get-ChildItem -Path (Join-Path $RepoRoot 'Modules') -Recurse -Filter 'Get-CippExoErrorText.ps1' -File -ErrorAction SilentlyContinue | + Select-Object -First 1 -ExpandProperty FullName + if (-not $ErrorTextPath) { throw 'Could not locate Get-CippExoErrorText.ps1 under Modules/' } + . $ErrorTextPath + + $GroupTypePath = Get-ChildItem -Path (Join-Path $RepoRoot 'Modules') -Recurse -Filter 'Get-CIPPGroupType.ps1' -File -ErrorAction SilentlyContinue | + Select-Object -First 1 -ExpandProperty FullName + if (-not $GroupTypePath) { throw 'Could not locate Get-CIPPGroupType.ps1 under Modules/' } + . $GroupTypePath + + . $FunctionPath + + function New-ResolvedDirectoryObject { + param($InputIdentity, $Id, $Upn, $DisplayName, [string]$Type = 'User', [bool]$Resolved = $true) + $Label = $DisplayName ?? $Upn ?? $InputIdentity + [pscustomobject]@{ + Input = $InputIdentity + Id = $Id + UserPrincipalName = $Upn + DisplayName = $DisplayName + Mail = $null + MailNickname = $null + ODataType = "#microsoft.graph.$($Type.ToLowerInvariant())" + Type = $Type + ExchangeIdentity = $Upn ?? $Id + Label = $Label + Resolved = $Resolved + } + } + + function New-OwnerGraphResponse { + param([hashtable[]]$Results) + foreach ($Result in $Results) { + [pscustomobject]@{ + id = $Result.id + status = $Result.status + body = if ($Result.ContainsKey('message')) { + [pscustomobject]@{ error = [pscustomobject]@{ message = $Result.message } } + } else { $null } + } + } + } +} + +Describe 'Add-CIPPGroupOwner' { + BeforeEach { + Mock -CommandName Write-LogMessage -MockWith { } + Mock -CommandName New-ExoBulkRequest -MockWith { @() } + Mock -CommandName New-GraphBulkRequest -MockWith { @() } + Mock -CommandName New-ExoRequest -MockWith { throw 'Distribution group not found' } + Mock -CommandName Resolve-CIPPDirectoryId -MockWith { + param($Identity, $TenantFilter) + foreach ($raw in @($Identity)) { + if ($raw -eq 'missing@contoso.com') { + New-ResolvedDirectoryObject -InputIdentity $raw -Id $null -Upn $raw -Resolved $false + continue + } + $id = switch -Wildcard ($raw) { + 'existing@*' { 'existing-guid' } + 'boss@*' { 'boss-guid' } + 'keep@*' { 'keep-guid' } + 'existing-guid' { 'existing-guid' } + 'boss-guid' { 'boss-guid' } + 'keep-guid' { 'keep-guid' } + default { $raw } + } + $upn = if ($raw -match '@') { $raw } else { + ($id -replace '-guid$', '@contoso.com') + } + New-ResolvedDirectoryObject -InputIdentity $raw -Id $id -Upn $upn + } + } + } + + Context 'Routing to Exchange for mail-based groups' { + BeforeEach { + Mock -CommandName New-GraphGetRequest -MockWith { + [pscustomobject]@{ + id = 'group-guid' + displayName = 'Contoso DL' + groupTypes = @() + mailEnabled = $true + securityEnabled = $false + } + } + Mock -CommandName New-ExoRequest -MockWith { + [pscustomobject]@{ ManagedBy = @('existing@contoso.com') } + } + } + + It 'rewrites ManagedBy with Graph ids when adding an owner to a distribution list' { + $Result = Add-CIPPGroupOwner -GroupId 'group-guid' -Owner @('boss@contoso.com') -TenantFilter 'contoso.com' + + Should -Invoke New-ExoBulkRequest -Times 1 -Exactly -ParameterFilter { + $Set = $cmdletArray[0] + $Set.CmdletInput.CmdletName -eq 'Set-DistributionGroup' -and + $Set.CmdletInput.Parameters.Identity -eq 'group-guid' -and + $Set.CmdletInput.Parameters.ManagedBy -contains 'existing-guid' -and + $Set.CmdletInput.Parameters.ManagedBy -contains 'boss-guid' -and + $Set.CmdletInput.Parameters.BypassSecurityGroupManagerCheck -eq $true + } + Should -Invoke New-GraphBulkRequest -Times 0 -Exactly + $Result | Should -BeLike 'Successfully added owner*boss@contoso.com*Contoso DL*' + } + + It 'rewrites ManagedBy for a mail-enabled security group' { + Mock -CommandName New-GraphGetRequest -MockWith { + [pscustomobject]@{ + id = 'group-guid' + displayName = 'Contoso MES' + groupTypes = @() + mailEnabled = $true + securityEnabled = $true + } + } + + $null = Add-CIPPGroupOwner -GroupId 'group-guid' -Owner @('boss@contoso.com') -TenantFilter 'contoso.com' + + Should -Invoke New-ExoBulkRequest -Times 1 -Exactly -ParameterFilter { + $cmdletArray[0].CmdletInput.CmdletName -eq 'Set-DistributionGroup' -and + $cmdletArray[0].CmdletInput.Parameters.BypassSecurityGroupManagerCheck -eq $true + } + Should -Invoke New-GraphBulkRequest -Times 0 -Exactly + } + + It 'does not call Exchange when the owner is already ManagedBy' { + { Add-CIPPGroupOwner -GroupId 'group-guid' -Owner @('existing@contoso.com') -TenantFilter 'contoso.com' } | + Should -Throw -ExpectedMessage '*already an owner*' + + Should -Invoke New-ExoBulkRequest -Times 0 -Exactly + } + + It 'does not call Exchange when the owner cannot be resolved' { + { Add-CIPPGroupOwner -GroupId 'group-guid' -Owner @('missing@contoso.com') -TenantFilter 'contoso.com' } | + Should -Throw -ExpectedMessage '*user not found*' + + Should -Invoke New-ExoBulkRequest -Times 0 -Exactly + } + + It 'throws when Exchange reports an error for the ManagedBy rewrite' { + Mock -CommandName New-ExoBulkRequest -MockWith { + @([pscustomobject]@{ + error = 'The executing user is not in the current organization' + OperationGuid = $cmdletArray[0].OperationGuid + }) + } + + { Add-CIPPGroupOwner -GroupId 'group-guid' -Owner @('boss@contoso.com') -TenantFilter 'contoso.com' } | + Should -Throw -ExpectedMessage '*not in the current organization*' + } + } + + Context 'Routing to Graph for directory groups' { + BeforeEach { + Mock -CommandName New-GraphGetRequest -MockWith { + [pscustomobject]@{ + id = 'group-guid' + displayName = 'Contoso Security' + groupTypes = @() + mailEnabled = $false + securityEnabled = $true + } + } + } + + It 'POSTs an owners/$ref bind for a security group' { + Mock -CommandName New-GraphBulkRequest -MockWith { + New-OwnerGraphResponse -Results @(@{ id = 'boss-guid'; status = 204 }) + } + + $Result = Add-CIPPGroupOwner -GroupId 'group-guid' -Owner @('boss@contoso.com') -TenantFilter 'contoso.com' + + Should -Invoke New-GraphBulkRequest -Times 1 -Exactly -ParameterFilter { + $Requests[0].method -eq 'POST' -and + $Requests[0].url -eq '/groups/group-guid/owners/$ref' -and + $Requests[0].body.'@odata.id' -eq 'https://graph.microsoft.com/v1.0/directoryObjects/boss-guid' + } + Should -Invoke New-ExoBulkRequest -Times 0 -Exactly + $Result | Should -BeLike 'Successfully added owner*boss@contoso.com*Contoso Security*' + } + + It 'POSTs an owners/$ref bind for a Microsoft 365 group' { + Mock -CommandName New-GraphGetRequest -MockWith { + [pscustomobject]@{ + id = 'group-guid' + displayName = 'Contoso M365' + groupTypes = @('Unified') + mailEnabled = $true + securityEnabled = $false + } + } + Mock -CommandName New-GraphBulkRequest -MockWith { + New-OwnerGraphResponse -Results @(@{ id = 'boss-guid'; status = 204 }) + } + + $null = Add-CIPPGroupOwner -GroupId 'group-guid' -Owner @('boss@contoso.com') -TenantFilter 'contoso.com' + + Should -Invoke New-GraphBulkRequest -Times 1 -Exactly + Should -Invoke New-ExoBulkRequest -Times 0 -Exactly + } + + It 'throws when Graph returns a non-2xx for every owner' { + Mock -CommandName New-GraphBulkRequest -MockWith { + New-OwnerGraphResponse -Results @(@{ id = 'boss-guid'; status = 400; message = 'One or more added object references already exist' }) + } + + { Add-CIPPGroupOwner -GroupId 'group-guid' -Owner @('boss@contoso.com') -TenantFilter 'contoso.com' } | + Should -Throw -ExpectedMessage '*already exist*' + } + + It 'does not call Graph when the owner cannot be resolved' { + { Add-CIPPGroupOwner -GroupId 'group-guid' -Owner @('missing@contoso.com') -TenantFilter 'contoso.com' } | + Should -Throw -ExpectedMessage '*user not found*' + + Should -Invoke New-GraphBulkRequest -Times 0 -Exactly + } + } +} diff --git a/backend/Tests/Private/Get-CIPPGroupType.Tests.ps1 b/backend/Tests/Private/Get-CIPPGroupType.Tests.ps1 new file mode 100644 index 0000000000..66b17d4fee --- /dev/null +++ b/backend/Tests/Private/Get-CIPPGroupType.Tests.ps1 @@ -0,0 +1,79 @@ +# Pester tests for Get-CIPPGroupType — Graph first, Exchange fallback, then caller hint. + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $FunctionPath = Get-ChildItem -Path (Join-Path $RepoRoot 'Modules') -Recurse -Filter 'Get-CIPPGroupType.ps1' -File -ErrorAction SilentlyContinue | + Select-Object -First 1 -ExpandProperty FullName + if (-not $FunctionPath) { throw 'Could not locate Get-CIPPGroupType.ps1 under Modules/' } + + function New-GraphGetRequest { param($uri, $tenantid) } + function New-ExoRequest { param($tenantid, $cmdlet, $cmdParams, $Select, $UseSystemMailbox) } + function Write-Information { param($MessageData) } + + . $FunctionPath +} + +Describe 'Get-CIPPGroupType' { + BeforeEach { + Mock -CommandName New-ExoRequest -MockWith { throw 'not found' } + } + + It 'classifies a Unified group as Microsoft 365' { + Mock -CommandName New-GraphGetRequest -MockWith { + [pscustomobject]@{ id = 'g1'; displayName = 'Team'; groupTypes = @('Unified'); mailEnabled = $true; securityEnabled = $false } + } + + $Result = Get-CIPPGroupType -GroupId 'g1' -TenantFilter 'contoso.com' + + $Result.GroupType | Should -Be 'Microsoft 365' + $Result.IsExchangeBacked | Should -BeFalse + $Result.DisplayName | Should -Be 'Team' + } + + It 'classifies mail+security as Mail-Enabled Security and marks Exchange-backed' { + Mock -CommandName New-GraphGetRequest -MockWith { + [pscustomobject]@{ id = 'g1'; displayName = 'MES'; groupTypes = @(); mailEnabled = $true; securityEnabled = $true } + } + + $Result = Get-CIPPGroupType -GroupId 'g1' -TenantFilter 'contoso.com' + + $Result.GroupType | Should -Be 'Mail-Enabled Security' + $Result.IsExchangeBacked | Should -BeTrue + } + + It 'classifies mail-only as Distribution List' { + Mock -CommandName New-GraphGetRequest -MockWith { + [pscustomobject]@{ id = 'g1'; displayName = 'DL'; groupTypes = @(); mailEnabled = $true; securityEnabled = $false } + } + + $Result = Get-CIPPGroupType -GroupId 'g1' -TenantFilter 'contoso.com' + + $Result.GroupType | Should -Be 'Distribution List' + $Result.IsExchangeBacked | Should -BeTrue + } + + It 'falls back to Exchange when Graph has no classification fields' { + Mock -CommandName New-GraphGetRequest -MockWith { + [pscustomobject]@{ id = 'g1'; displayName = $null } + } + Mock -CommandName New-ExoRequest -MockWith { + [pscustomobject]@{ Guid = 'exo-guid'; DisplayName = 'Sales DL'; RecipientTypeDetails = 'MailUniversalDistributionGroup' } + } + + $Result = Get-CIPPGroupType -GroupId 'Sales DL' -TenantFilter 'contoso.com' + + $Result.GroupType | Should -Be 'Distribution List' + $Result.DisplayName | Should -Be 'Sales DL' + $Result.GroupId | Should -Be 'exo-guid' + $Result.IsExchangeBacked | Should -BeTrue + } + + It 'normalizes FallbackGroupType casing when both lookups fail' { + Mock -CommandName New-GraphGetRequest -MockWith { throw '404' } + + $Result = Get-CIPPGroupType -GroupId 'All Office' -TenantFilter 'contoso.com' -FallbackGroupType 'Distribution list' + + $Result.GroupType | Should -Be 'Distribution List' + $Result.IsExchangeBacked | Should -BeTrue + } +} diff --git a/backend/Tests/Private/Remove-CIPPGroupMember.Tests.ps1 b/backend/Tests/Private/Remove-CIPPGroupMember.Tests.ps1 index 50d1d86f05..688571e401 100644 --- a/backend/Tests/Private/Remove-CIPPGroupMember.Tests.ps1 +++ b/backend/Tests/Private/Remove-CIPPGroupMember.Tests.ps1 @@ -12,10 +12,13 @@ BeforeAll { if (-not $FunctionPath) { throw 'Could not locate Remove-CIPPGroupMember.ps1 under Modules/' } function New-GraphBulkRequest { param($Requests, $tenantid, $scope, $asapp) } + function New-GraphGetRequest { param($uri, $tenantid) } function New-ExoBulkRequest { param($tenantid, $cmdletArray, $useSystemMailbox) } + function New-ExoRequest { param($tenantid, $cmdlet, $cmdParams, $Select, $UseSystemMailbox) } function Write-LogMessage { param($headers, $API, $tenant, $message, $Sev, $LogData) } function Get-NormalizedError { param($message) $message } function Get-CippException { param($Exception) @{ NormalizedError = "$Exception" } } + function Resolve-CIPPDirectoryId { param($Identity, $TenantFilter) } # Real helper, not a stub: correlating Exchange bulk results back to operations is the thing # these tests are checking, so it has to be the production implementation. @@ -30,18 +33,29 @@ BeforeAll { if (-not $ErrorTextPath) { throw 'Could not locate Get-CippExoErrorText.ps1 under Modules/' } . $ErrorTextPath + $GroupTypePath = Get-ChildItem -Path (Join-Path $RepoRoot 'Modules') -Recurse -Filter 'Get-CIPPGroupType.ps1' -File -ErrorAction SilentlyContinue | + Select-Object -First 1 -ExpandProperty FullName + if (-not $GroupTypePath) { throw 'Could not locate Get-CIPPGroupType.ps1 under Modules/' } + . $GroupTypePath + . $FunctionPath - function New-LookupResponse { - param([hashtable[]]$Users, [string]$GroupDisplayName = 'Contoso Group') - $Response = foreach ($User in $Users) { - [pscustomobject]@{ - id = "users-$($User.upn)" - status = 200 - body = [pscustomobject]@{ id = $User.id; userPrincipalName = $User.upn } - } + function New-ResolvedDirectoryObject { + param($InputIdentity, $Id, $Upn, $DisplayName, [string]$Type = 'User', [bool]$Resolved = $true) + $Label = $DisplayName ?? $Upn ?? $InputIdentity + [pscustomobject]@{ + Input = $InputIdentity + Id = $Id + UserPrincipalName = $Upn + DisplayName = $DisplayName + Mail = $null + MailNickname = $null + ODataType = "#microsoft.graph.$($Type.ToLowerInvariant())" + Type = $Type + ExchangeIdentity = $Upn ?? $Id + Label = $Label + Resolved = $Resolved } - @($Response) + @([pscustomobject]@{ id = 'group'; status = 200; body = [pscustomobject]@{ id = 'group-guid'; displayName = $GroupDisplayName } }) } function New-RemoveResponse { @@ -62,6 +76,27 @@ Describe 'Remove-CIPPGroupMember' { BeforeEach { Mock -CommandName Write-LogMessage -MockWith { } Mock -CommandName New-ExoBulkRequest -MockWith { @() } + Mock -CommandName New-GraphBulkRequest -MockWith { @() } + Mock -CommandName New-GraphGetRequest -MockWith { + [pscustomobject]@{ id = 'group-guid'; displayName = 'Contoso Group' } + } + Mock -CommandName New-ExoRequest -MockWith { throw 'Distribution group not found' } + Mock -CommandName Resolve-CIPPDirectoryId -MockWith { + param($Identity, $TenantFilter) + foreach ($raw in @($Identity)) { + $id = switch -Wildcard ($raw) { + 'sseck@*' { 'user-1' } + 'one@*' { 'user-1' } + 'two@*' { 'user-2' } + 'ok@*' { 'user-1' } + 'bad@*' { 'user-2' } + '*#EXT#*' { 'user-1' } + default { $raw } + } + $upn = if ($raw -match '@' -or $raw -like '*#EXT#*') { $raw } else { $null } + New-ResolvedDirectoryObject -InputIdentity $raw -Id $id -Upn $upn + } + } } Context 'Routing to Exchange Online for mail-based groups' { @@ -69,10 +104,6 @@ Describe 'Remove-CIPPGroupMember' { @{ GroupType = 'Distribution list' } @{ GroupType = 'Mail-Enabled Security' } ) { - Mock -CommandName New-GraphBulkRequest -MockWith { - New-LookupResponse -Users @(@{ id = 'user-1'; upn = 'sseck@contoso.com' }) - } - $Result = Remove-CIPPGroupMember -GroupType $GroupType -GroupId 'group-guid' -Member @('sseck@contoso.com') -TenantFilter 'contoso.com' Should -Invoke New-ExoBulkRequest -Times 1 -Exactly -ParameterFilter { @@ -81,24 +112,18 @@ Describe 'Remove-CIPPGroupMember' { $cmdletArray[0].CmdletInput.Parameters.Member -eq 'sseck@contoso.com' -and $cmdletArray[0].CmdletInput.Parameters.BypassSecurityGroupManagerCheck -eq $true } - Should -Invoke New-GraphBulkRequest -Times 1 -Exactly - $Result | Should -Be 'Successfully removed user sseck@contoso.com from group Contoso Group.' + Should -Invoke New-GraphBulkRequest -Times 0 -Exactly + $Result | Should -Be 'Successfully removed sseck@contoso.com from group Contoso Group.' } It 'routes on group type case-insensitively' { - Mock -CommandName New-GraphBulkRequest -MockWith { - New-LookupResponse -Users @(@{ id = 'user-1'; upn = 'sseck@contoso.com' }) - } - $null = Remove-CIPPGroupMember -GroupType 'Distribution List' -GroupId 'group-guid' -Member @('sseck@contoso.com') -TenantFilter 'contoso.com' Should -Invoke New-ExoBulkRequest -Times 1 -Exactly + Should -Invoke New-GraphBulkRequest -Times 0 -Exactly } It 'throws when Exchange reports an error for the batch' { - Mock -CommandName New-GraphBulkRequest -MockWith { - New-LookupResponse -Users @(@{ id = 'user-1'; upn = 'sseck@contoso.com' }) - } Mock -CommandName New-ExoBulkRequest -MockWith { @([pscustomobject]@{ target = 'sseck@contoso.com'; error = 'The user is not a member of the group.' }) } @@ -110,12 +135,9 @@ Describe 'Remove-CIPPGroupMember' { Context 'Routing to Graph for directory groups' { It 'DELETEs the members/$ref for a security group' { - Mock -CommandName New-GraphBulkRequest -MockWith { - New-LookupResponse -Users @(@{ id = 'user-1'; upn = 'sseck@contoso.com' }) - } -ParameterFilter { $Requests.method -contains 'GET' } Mock -CommandName New-GraphBulkRequest -MockWith { New-RemoveResponse -Results @(@{ id = 'user-1'; status = 204 }) - } -ParameterFilter { $Requests.method -contains 'DELETE' } + } $Result = Remove-CIPPGroupMember -GroupType 'Security' -GroupId 'group-guid' -Member @('sseck@contoso.com') -TenantFilter 'contoso.com' @@ -124,35 +146,26 @@ Describe 'Remove-CIPPGroupMember' { $Requests[0].url -eq '/groups/group-guid/members/user-1/$ref' } Should -Invoke New-ExoBulkRequest -Times 0 -Exactly - $Result | Should -Be 'Successfully removed user sseck@contoso.com from group Contoso Group.' + $Result | Should -Be 'Successfully removed sseck@contoso.com from group Contoso Group.' } It 'reports both the successes and the failures of a mixed batch without throwing' { - Mock -CommandName New-GraphBulkRequest -MockWith { - New-LookupResponse -Users @( - @{ id = 'user-1'; upn = 'ok@contoso.com' } - @{ id = 'user-2'; upn = 'bad@contoso.com' } - ) - } -ParameterFilter { $Requests.method -contains 'GET' } Mock -CommandName New-GraphBulkRequest -MockWith { New-RemoveResponse -Results @( @{ id = 'user-1'; status = 204 } @{ id = 'user-2'; status = 404; message = 'Resource not found' } ) - } -ParameterFilter { $Requests.method -contains 'DELETE' } + } $Result = Remove-CIPPGroupMember -GroupType 'Security' -GroupId 'group-guid' -Member @('ok@contoso.com', 'bad@contoso.com') -TenantFilter 'contoso.com' - $Result | Should -Be 'Successfully removed user ok@contoso.com from group Contoso Group. Failed to remove bad@contoso.com (Resource not found).' + $Result | Should -Be 'Successfully removed ok@contoso.com from group Contoso Group. Failed to remove bad@contoso.com (Resource not found).' } It 'throws when every member of the batch failed' { - Mock -CommandName New-GraphBulkRequest -MockWith { - New-LookupResponse -Users @(@{ id = 'user-1'; upn = 'sseck@contoso.com' }) - } -ParameterFilter { $Requests.method -contains 'GET' } Mock -CommandName New-GraphBulkRequest -MockWith { New-RemoveResponse -Results @(@{ id = 'user-1'; status = 400; message = 'Cannot Update a mail-enabled security groups and or distribution list.' }) - } -ParameterFilter { $Requests.method -contains 'DELETE' } + } { Remove-CIPPGroupMember -GroupType 'Security' -GroupId 'group-guid' -Member @('sseck@contoso.com') -TenantFilter 'contoso.com' } | Should -Throw -ExpectedMessage '*Cannot Update a mail-enabled security groups*' @@ -160,23 +173,21 @@ Describe 'Remove-CIPPGroupMember' { } Context 'Member lookup' { - It 'url-encodes guest accounts so the #EXT# segment survives the request' { - Mock -CommandName New-GraphBulkRequest -MockWith { - New-LookupResponse -Users @(@{ id = 'user-1'; upn = 'guest_partner.com#EXT#@contoso.onmicrosoft.com' }) - } -ParameterFilter { $Requests.method -contains 'GET' } + It 'passes guest identities through to Resolve-CIPPDirectoryId' { Mock -CommandName New-GraphBulkRequest -MockWith { New-RemoveResponse -Results @(@{ id = 'user-1'; status = 204 }) - } -ParameterFilter { $Requests.method -contains 'DELETE' } + } - $null = Remove-CIPPGroupMember -GroupType 'Security' -GroupId 'group-guid' -Member @('guest_partner.com#EXT#@contoso.onmicrosoft.com') -TenantFilter 'contoso.com' + $Guest = 'guest_partner.com#EXT#@contoso.onmicrosoft.com' + $null = Remove-CIPPGroupMember -GroupType 'Security' -GroupId 'group-guid' -Member @($Guest) -TenantFilter 'contoso.com' - Should -Invoke New-GraphBulkRequest -Times 1 -Exactly -ParameterFilter { - $Requests[0].url -like 'users/*%23EXT%23*' + Should -Invoke Resolve-CIPPDirectoryId -Times 1 -Exactly -ParameterFilter { + @($Identity) -contains $Guest } } It 'surfaces a lookup failure as a thrown, member-scoped message' { - Mock -CommandName New-GraphBulkRequest -MockWith { throw 'Graph unavailable' } + Mock -CommandName Resolve-CIPPDirectoryId -MockWith { throw 'Graph unavailable' } { Remove-CIPPGroupMember -GroupType 'Security' -GroupId 'group-guid' -Member @('sseck@contoso.com') -TenantFilter 'contoso.com' } | Should -Throw -ExpectedMessage '*sseck@contoso.com*Graph unavailable*' @@ -185,18 +196,15 @@ Describe 'Remove-CIPPGroupMember' { Context 'Audit logging' { It 'logs the outcome against the tenant so it shows up in the CIPP log' { - Mock -CommandName New-GraphBulkRequest -MockWith { - New-LookupResponse -Users @(@{ id = 'user-1'; upn = 'sseck@contoso.com' }) - } -ParameterFilter { $Requests.method -contains 'GET' } Mock -CommandName New-GraphBulkRequest -MockWith { New-RemoveResponse -Results @(@{ id = 'user-1'; status = 204 }) - } -ParameterFilter { $Requests.method -contains 'DELETE' } + } $null = Remove-CIPPGroupMember -GroupType 'Security' -GroupId 'group-guid' -Member @('sseck@contoso.com') -TenantFilter 'contoso.com' -APIName 'Remove Group Member' Should -Invoke Write-LogMessage -Times 1 -Exactly -ParameterFilter { $API -eq 'Remove Group Member' -and $tenant -eq 'contoso.com' -and $Sev -eq 'Info' -and - $message -eq 'Successfully removed user sseck@contoso.com from group Contoso Group.' + $message -eq 'Successfully removed sseck@contoso.com from group Contoso Group.' } } } diff --git a/backend/Tests/Private/Remove-CIPPGroupOwner.Tests.ps1 b/backend/Tests/Private/Remove-CIPPGroupOwner.Tests.ps1 new file mode 100644 index 0000000000..0eb98fae89 --- /dev/null +++ b/backend/Tests/Private/Remove-CIPPGroupOwner.Tests.ps1 @@ -0,0 +1,182 @@ +# Pester tests for Remove-CIPPGroupOwner. +# +# Mirror of Add-CIPPGroupOwner: Graph DELETE of owners/$ref for directory groups, ManagedBy +# rewrite (list without them) for classic DLs and mail-enabled security groups. + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $FunctionPath = Get-ChildItem -Path (Join-Path $RepoRoot 'Modules') -Recurse -Filter 'Remove-CIPPGroupOwner.ps1' -File -ErrorAction SilentlyContinue | + Select-Object -First 1 -ExpandProperty FullName + if (-not $FunctionPath) { throw 'Could not locate Remove-CIPPGroupOwner.ps1 under Modules/' } + + function New-GraphBulkRequest { param($Requests, $tenantid, $scope, $asapp) } + function New-GraphGetRequest { param($uri, $tenantid) } + function New-ExoBulkRequest { param($tenantid, $cmdletArray, $useSystemMailbox) } + function New-ExoRequest { param($tenantid, $cmdlet, $cmdParams, $Select, $UseSystemMailbox) } + function Write-LogMessage { param($headers, $API, $tenant, $message, $Sev, $LogData) } + function Get-NormalizedError { param($message) $message } + function Get-CippException { param($Exception) @{ NormalizedError = "$Exception" } } + function Resolve-CIPPDirectoryId { param($Identity, $TenantFilter) } + + $ResolverPath = Get-ChildItem -Path (Join-Path $RepoRoot 'Modules') -Recurse -Filter 'Resolve-CippExoBulkResult.ps1' -File -ErrorAction SilentlyContinue | + Select-Object -First 1 -ExpandProperty FullName + if (-not $ResolverPath) { throw 'Could not locate Resolve-CippExoBulkResult.ps1 under Modules/' } + . $ResolverPath + + $ErrorTextPath = Get-ChildItem -Path (Join-Path $RepoRoot 'Modules') -Recurse -Filter 'Get-CippExoErrorText.ps1' -File -ErrorAction SilentlyContinue | + Select-Object -First 1 -ExpandProperty FullName + if (-not $ErrorTextPath) { throw 'Could not locate Get-CippExoErrorText.ps1 under Modules/' } + . $ErrorTextPath + + $GroupTypePath = Get-ChildItem -Path (Join-Path $RepoRoot 'Modules') -Recurse -Filter 'Get-CIPPGroupType.ps1' -File -ErrorAction SilentlyContinue | + Select-Object -First 1 -ExpandProperty FullName + if (-not $GroupTypePath) { throw 'Could not locate Get-CIPPGroupType.ps1 under Modules/' } + . $GroupTypePath + + . $FunctionPath + + function New-ResolvedDirectoryObject { + param($InputIdentity, $Id, $Upn, $DisplayName, [string]$Type = 'User', [bool]$Resolved = $true) + $Label = $DisplayName ?? $Upn ?? $InputIdentity + [pscustomobject]@{ + Input = $InputIdentity + Id = $Id + UserPrincipalName = $Upn + DisplayName = $DisplayName + Mail = $null + MailNickname = $null + ODataType = "#microsoft.graph.$($Type.ToLowerInvariant())" + Type = $Type + ExchangeIdentity = $Upn ?? $Id + Label = $Label + Resolved = $Resolved + } + } + + function New-OwnerGraphResponse { + param([hashtable[]]$Results) + foreach ($Result in $Results) { + [pscustomobject]@{ + id = $Result.id + status = $Result.status + body = if ($Result.ContainsKey('message')) { + [pscustomobject]@{ error = [pscustomobject]@{ message = $Result.message } } + } else { $null } + } + } + } +} + +Describe 'Remove-CIPPGroupOwner' { + BeforeEach { + Mock -CommandName Write-LogMessage -MockWith { } + Mock -CommandName New-ExoBulkRequest -MockWith { @() } + Mock -CommandName New-GraphBulkRequest -MockWith { @() } + Mock -CommandName New-ExoRequest -MockWith { throw 'Distribution group not found' } + Mock -CommandName Resolve-CIPPDirectoryId -MockWith { + param($Identity, $TenantFilter) + foreach ($raw in @($Identity)) { + if ($raw -eq 'missing@contoso.com') { + New-ResolvedDirectoryObject -InputIdentity $raw -Id $null -Upn $raw -Resolved $false + continue + } + $id = switch -Wildcard ($raw) { + 'keep@*' { 'keep-guid' } + 'drop@*' { 'drop-guid' } + 'boss@*' { 'boss-guid' } + 'keep-guid' { 'keep-guid' } + 'drop-guid' { 'drop-guid' } + 'boss-guid' { 'boss-guid' } + default { $raw } + } + $upn = if ($raw -match '@') { $raw } else { + ($id -replace '-guid$', '@contoso.com') + } + New-ResolvedDirectoryObject -InputIdentity $raw -Id $id -Upn $upn + } + } + } + + Context 'Routing to Exchange for mail-based groups' { + BeforeEach { + Mock -CommandName New-GraphGetRequest -MockWith { + [pscustomobject]@{ + id = 'group-guid' + displayName = 'Contoso DL' + groupTypes = @() + mailEnabled = $true + securityEnabled = $false + } + } + Mock -CommandName New-ExoRequest -MockWith { + [pscustomobject]@{ ManagedBy = @('keep@contoso.com', 'drop@contoso.com') } + } + } + + It 'drops the removed owner out of the rewritten ManagedBy list' { + $Result = Remove-CIPPGroupOwner -GroupId 'group-guid' -Owner @('drop@contoso.com') -TenantFilter 'contoso.com' + + Should -Invoke New-ExoBulkRequest -Times 1 -Exactly -ParameterFilter { + $Set = $cmdletArray[0] + $Set.CmdletInput.CmdletName -eq 'Set-DistributionGroup' -and + $Set.CmdletInput.Parameters.ManagedBy -contains 'keep-guid' -and + $Set.CmdletInput.Parameters.ManagedBy -notcontains 'drop-guid' -and + $Set.CmdletInput.Parameters.BypassSecurityGroupManagerCheck -eq $true + } + Should -Invoke New-GraphBulkRequest -Times 0 -Exactly + $Result | Should -BeLike 'Successfully removed owner*drop@contoso.com*Contoso DL*' + } + + It 'does not call Exchange when the owner is not in ManagedBy' { + { Remove-CIPPGroupOwner -GroupId 'group-guid' -Owner @('boss@contoso.com') -TenantFilter 'contoso.com' } | + Should -Throw -ExpectedMessage '*not an owner*' + + Should -Invoke New-ExoBulkRequest -Times 0 -Exactly + } + + It 'does not call Exchange when the owner cannot be resolved' { + { Remove-CIPPGroupOwner -GroupId 'group-guid' -Owner @('missing@contoso.com') -TenantFilter 'contoso.com' } | + Should -Throw -ExpectedMessage '*user not found*' + + Should -Invoke New-ExoBulkRequest -Times 0 -Exactly + } + } + + Context 'Routing to Graph for directory groups' { + BeforeEach { + Mock -CommandName New-GraphGetRequest -MockWith { + [pscustomobject]@{ + id = 'group-guid' + displayName = 'Contoso Security' + groupTypes = @() + mailEnabled = $false + securityEnabled = $true + } + } + } + + It 'DELETEs the owners/$ref for a security group' { + Mock -CommandName New-GraphBulkRequest -MockWith { + New-OwnerGraphResponse -Results @(@{ id = 'drop-guid'; status = 204 }) + } + + $Result = Remove-CIPPGroupOwner -GroupId 'group-guid' -Owner @('drop@contoso.com') -TenantFilter 'contoso.com' + + Should -Invoke New-GraphBulkRequest -Times 1 -Exactly -ParameterFilter { + $Requests[0].method -eq 'DELETE' -and + $Requests[0].url -eq '/groups/group-guid/owners/drop-guid/$ref' + } + Should -Invoke New-ExoBulkRequest -Times 0 -Exactly + $Result | Should -BeLike 'Successfully removed owner*drop@contoso.com*Contoso Security*' + } + + It 'throws when Graph returns a non-2xx for every owner' { + Mock -CommandName New-GraphBulkRequest -MockWith { + New-OwnerGraphResponse -Results @(@{ id = 'drop-guid'; status = 404; message = 'Resource not found' }) + } + + { Remove-CIPPGroupOwner -GroupId 'group-guid' -Owner @('drop@contoso.com') -TenantFilter 'contoso.com' } | + Should -Throw -ExpectedMessage '*Resource not found*' + } + } +} diff --git a/backend/Tests/Private/Resolve-CIPPDirectoryId.Tests.ps1 b/backend/Tests/Private/Resolve-CIPPDirectoryId.Tests.ps1 new file mode 100644 index 0000000000..866b35b18c --- /dev/null +++ b/backend/Tests/Private/Resolve-CIPPDirectoryId.Tests.ps1 @@ -0,0 +1,145 @@ +# Pester tests for Resolve-CIPPDirectoryId. + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $FunctionPath = Get-ChildItem -Path (Join-Path $RepoRoot 'Modules') -Recurse -Filter 'Resolve-CIPPDirectoryId.ps1' -File -ErrorAction SilentlyContinue | + Select-Object -First 1 -ExpandProperty FullName + if (-not $FunctionPath) { throw 'Could not locate Resolve-CIPPDirectoryId.ps1 under Modules/' } + + function New-GraphPOSTRequest { param($uri, $tenantid, $body) } + function New-GraphBulkRequest { param($Requests, $tenantid) } + function New-GraphGetRequest { param($uri, $tenantid) } + function Write-Information { param($MessageData) } + + . $FunctionPath +} + +Describe 'Resolve-CIPPDirectoryId' { + It 'returns an empty array for empty input' { + $Result = Resolve-CIPPDirectoryId -Identity @() -TenantFilter 'contoso.com' + @($Result).Count | Should -Be 0 + } + + It 'resolves GUID identities via getByIds' { + Mock -CommandName New-GraphPOSTRequest -MockWith { + [pscustomobject]@{ + value = @( + [pscustomobject]@{ id = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'; userPrincipalName = 'a@contoso.com'; displayName = 'Alice' } + ) + } + } + + $Result = Resolve-CIPPDirectoryId -Identity @('aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee') -TenantFilter 'contoso.com' + + $Result.Count | Should -Be 1 + $Result[0].Resolved | Should -BeTrue + $Result[0].Id | Should -Be 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' + $Result[0].UserPrincipalName | Should -Be 'a@contoso.com' + Should -Invoke New-GraphPOSTRequest -Times 1 -Exactly + } + + It 'resolves a GUID group via getByIds with @odata.type group' { + $GroupGuid = 'bbbbbbbb-bbbb-cccc-dddd-eeeeeeeeeeee' + Mock -CommandName New-GraphBulkRequest -MockWith { @() } + Mock -CommandName New-GraphPOSTRequest -MockWith { + [pscustomobject]@{ + value = @( + [pscustomobject]@{ + id = $GroupGuid + displayName = 'Nested SG' + mail = 'nested@contoso.com' + mailNickname = 'nested' + mailEnabled = $false + groupTypes = @() + '@odata.type' = '#microsoft.graph.group' + } + ) + } + } + + $Result = Resolve-CIPPDirectoryId -Identity @($GroupGuid) -TenantFilter 'contoso.com' + + $Result[0].Resolved | Should -BeTrue + $Result[0].Id | Should -Be $GroupGuid + $Result[0].Type | Should -Be 'Group' + $Result[0].ODataType | Should -Be '#microsoft.graph.group' + $Result[0].Label | Should -Be 'Nested SG' + Should -Invoke New-GraphPOSTRequest -Times 1 -Exactly + Should -Invoke New-GraphBulkRequest -Times 0 -Exactly + } + + It 'resolves UPNs via users/{identity}' { + Mock -CommandName New-GraphBulkRequest -MockWith { + @( + [pscustomobject]@{ + id = 'user-bob@contoso.com' + status = 200 + body = [pscustomobject]@{ id = 'bbbbbbbb-bbbb-cccc-dddd-eeeeeeeeeeee'; userPrincipalName = 'bob@contoso.com'; displayName = 'Bob' } + } + ) + } + + $Result = Resolve-CIPPDirectoryId -Identity @('bob@contoso.com') -TenantFilter 'contoso.com' + + $Result[0].Resolved | Should -BeTrue + $Result[0].Id | Should -Be 'bbbbbbbb-bbbb-cccc-dddd-eeeeeeeeeeee' + $Result[0].Input | Should -Be 'bob@contoso.com' + } + + It 'falls through users 404 then resolves non-GUID mail via group filter' { + Mock -CommandName New-GraphBulkRequest -MockWith { + @([pscustomobject]@{ id = 'user-nested@contoso.com'; status = 404; body = $null }) + } + Mock -CommandName New-GraphGetRequest -MockWith { + [pscustomobject]@{ + id = 'cccccccc-bbbb-cccc-dddd-eeeeeeeeeeee' + displayName = 'Nested by mail' + mail = 'nested@contoso.com' + mailNickname = 'nested' + mailEnabled = $true + groupTypes = @() + } + } + + $Result = Resolve-CIPPDirectoryId -Identity @('nested@contoso.com') -TenantFilter 'contoso.com' + + $Result[0].Resolved | Should -BeTrue + $Result[0].Id | Should -Be 'cccccccc-bbbb-cccc-dddd-eeeeeeeeeeee' + $Result[0].Type | Should -Be 'Group' + Should -Invoke New-GraphBulkRequest -Times 1 -Exactly + Should -Invoke New-GraphGetRequest -Times 1 -Exactly -ParameterFilter { + $uri -like '*groups?*filter=*' -and ($uri -like '*nested@contoso.com*' -or $uri -like '*nested%40contoso.com*') + } + } + + It 'marks unresolved identities without throwing' { + Mock -CommandName New-GraphBulkRequest -MockWith { + @([pscustomobject]@{ id = 'user-missing@contoso.com'; status = 404; body = $null }) + } + Mock -CommandName New-GraphGetRequest -MockWith { @() } + + $Result = Resolve-CIPPDirectoryId -Identity @('missing@contoso.com') -TenantFilter 'contoso.com' + + $Result[0].Resolved | Should -BeFalse + $Result[0].Id | Should -BeNullOrEmpty + } + + It 'normalizes a UPN and a GUID for the same user to the same id' { + Mock -CommandName New-GraphPOSTRequest -MockWith { + [pscustomobject]@{ + value = @([pscustomobject]@{ id = 'cccccccc-bbbb-cccc-dddd-eeeeeeeeeeee'; userPrincipalName = 'c@contoso.com'; displayName = 'C' }) + } + } + Mock -CommandName New-GraphBulkRequest -MockWith { + @([pscustomobject]@{ + id = 'user-c@contoso.com' + status = 200 + body = [pscustomobject]@{ id = 'cccccccc-bbbb-cccc-dddd-eeeeeeeeeeee'; userPrincipalName = 'c@contoso.com'; displayName = 'C' } + }) + } + + $Result = Resolve-CIPPDirectoryId -Identity @('c@contoso.com', 'cccccccc-bbbb-cccc-dddd-eeeeeeeeeeee') -TenantFilter 'contoso.com' + + ($Result | Where-Object Resolved).Id | Select-Object -Unique | Should -Be 'cccccccc-bbbb-cccc-dddd-eeeeeeeeeeee' + } +} From 791c69718520e71754d3257b1c43019160007b4f Mon Sep 17 00:00:00 2001 From: Roel van der Wegen Date: Thu, 20 Aug 2026 19:26:07 +0200 Subject: [PATCH 212/226] feat(cipp): enhance group management and UI components - Improved `Invoke-EditGroup` function to better handle owner additions and removals with enhanced identity resolution. - Updated `CippApiDialog` to integrate CSV field handling and nested value resolution. - Introduced `CippDataTableButton` and `CippTableCardButton` components for improved action handling in data tables. - Added sub-table functionality in `CippDataTable` for displaying members and owners with dynamic API integration. - Refactored `CIPPTableToptoolbar` to support parent row attachment for bulk actions. These changes enhance the usability and functionality of group management and data display within the CIPP module. --- .../Groups/Invoke-EditGroup.ps1 | 75 ++-- .../CippComponents/CippApiDialog.jsx | 131 +++++-- .../CippTable/CIPPTableToptoolbar.js | 63 ++-- .../src/components/CippTable/CippDataTable.js | 214 ++++++++++-- .../CippTable/CippDataTableButton.jsx | 165 +++++++-- .../CippTable/CippMobileCardList.jsx | 4 +- .../CippTable/CippTableCardButton.jsx | 73 ++++ .../components/CippTable/util-subTables.js | 75 ++++ .../identity/administration/groups/index.js | 198 ++++++++--- frontend/src/utils/csv-field-values.js | 50 +++ frontend/src/utils/resolve-row-templates.js | 93 +++++ .../CippComponents/CippApiDialog.test.jsx | 34 ++ .../CippTable/CippDataTable.test.jsx | 321 ++++++++++++++++++ .../CippTable/CippDataTableButton.stories.jsx | 50 +++ .../CippTable/CippDataTableButton.test.jsx | 67 ++++ .../CippTable/util-subTables.test.js | 62 ++++ frontend/tests/utils/csv-field-values.test.js | 68 ++++ .../tests/utils/resolve-row-templates.test.js | 121 +++++++ 18 files changed, 1689 insertions(+), 175 deletions(-) create mode 100644 frontend/src/components/CippTable/CippTableCardButton.jsx create mode 100644 frontend/src/components/CippTable/util-subTables.js create mode 100644 frontend/src/utils/csv-field-values.js create mode 100644 frontend/src/utils/resolve-row-templates.js create mode 100644 frontend/tests/components/CippTable/util-subTables.test.js create mode 100644 frontend/tests/utils/csv-field-values.test.js create mode 100644 frontend/tests/utils/resolve-row-templates.test.js diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Groups/Invoke-EditGroup.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Groups/Invoke-EditGroup.ps1 index 3988e05e95..e7042c65e8 100644 --- a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Groups/Invoke-EditGroup.ps1 +++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Groups/Invoke-EditGroup.ps1 @@ -338,34 +338,69 @@ function Invoke-EditGroup { } if ($GroupType -in @( 'Distribution List', 'Mail-Enabled Security') -and ($AddOwners -or $RemoveOwners)) { - $CurrentOwners = New-ExoRequest -tenantid $TenantId -cmdlet 'Get-DistributionGroup' -cmdParams @{ Identity = $GroupId } -UseSystemMailbox $true | Select-Object -ExpandProperty ManagedBy + $CurrentOwnersRaw = @( + New-ExoRequest -tenantid $TenantId -cmdlet 'Get-DistributionGroup' -cmdParams @{ Identity = $GroupId } -UseSystemMailbox $true | + Select-Object -ExpandProperty ManagedBy + ) + $CurrentResolved = @(Resolve-CIPPDirectoryId -Identity $CurrentOwnersRaw -TenantFilter $TenantId) + + $RemoveIds = @() + if ($RemoveOwners) { + $RemoveIds = @( + Resolve-CIPPDirectoryId -Identity @($RemoveOwners.value) -TenantFilter $TenantId | + Where-Object { $_.Resolved -and $_.Id } | + ForEach-Object { $_.Id } + ) + } + $AddResolved = @() + if ($AddOwners) { + $AddResolved = @(Resolve-CIPPDirectoryId -Identity @($AddOwners.value) -TenantFilter $TenantId) + } # Every owner change here is carried by the one Set-DistributionGroup call below, so they # share an OperationGuid: the ManagedBy rewrite either applies in full or not at all, and # reporting per-owner outcomes that disagree with each other would be a lie. $OwnersGuid = [Guid]::NewGuid().ToString() $NewManagedBy = [System.Collections.Generic.List[string]]::new() - foreach ($CurrentOwner in $CurrentOwners) { - if ($RemoveOwners -and $RemoveOwners.value -contains $CurrentOwner) { - $OwnerToRemove = $RemoveOwners | Where-Object { $_.value -eq $CurrentOwner } - $ExoLogs.Add(@{ - message = "Removed owner $($OwnerToRemove.label) from $($GroupName) group" - target = $GroupId - OperationGuid = $OwnersGuid - }) - continue + $RemoveIdSet = [System.Collections.Generic.HashSet[string]]::new([string[]]$RemoveIds, [StringComparer]::OrdinalIgnoreCase) + + foreach ($Entry in $CurrentResolved) { + if ($Entry.Resolved -and $Entry.Id) { + if ($RemoveIdSet.Contains($Entry.Id)) { + $OwnerToRemove = $RemoveOwners | Where-Object { + $_.value -eq $Entry.Id -or $_.value -eq $Entry.Input -or $_.addedFields.userPrincipalName -eq $Entry.UserPrincipalName + } | Select-Object -First 1 + $ExoLogs.Add(@{ + message = "Removed owner $($OwnerToRemove.label ?? $Entry.UserPrincipalName ?? $Entry.Id) from $($GroupName) group" + target = $GroupId + OperationGuid = $OwnersGuid + }) + continue + } + $NewManagedBy.Add($Entry.Id) + } else { + # Keep unresolved ManagedBy entries so a failed lookup cannot strip an owner. + if ($RemoveOwners -and ($RemoveOwners.value -contains $Entry.Input)) { + $OwnerToRemove = $RemoveOwners | Where-Object { $_.value -eq $Entry.Input } | Select-Object -First 1 + $ExoLogs.Add(@{ + message = "Removed owner $($OwnerToRemove.label) from $($GroupName) group" + target = $GroupId + OperationGuid = $OwnersGuid + }) + continue + } + $NewManagedBy.Add($Entry.Input) } - $NewManagedBy.Add($CurrentOwner) } - if ($AddOwners) { - foreach ($NewOwner in $AddOwners) { - $NewManagedBy.Add($NewOwner.value) - $ExoLogs.Add(@{ - message = "Added owner $($NewOwner.label) to $($GroupName) group" - target = $GroupId - OperationGuid = $OwnersGuid - }) - } + foreach ($NewOwner in $AddResolved) { + if (-not $NewOwner.Resolved -or -not $NewOwner.Id) { continue } + $NewManagedBy.Add($NewOwner.Id) + $OwnerLabel = ($AddOwners | Where-Object { $_.value -eq $NewOwner.Input -or $_.value -eq $NewOwner.Id } | Select-Object -First 1).label + $ExoLogs.Add(@{ + message = "Added owner $($OwnerLabel ?? $NewOwner.UserPrincipalName ?? $NewOwner.Id) to $($GroupName) group" + target = $GroupId + OperationGuid = $OwnersGuid + }) } $NewManagedBy = $NewManagedBy | Sort-Object -Unique diff --git a/frontend/src/components/CippComponents/CippApiDialog.jsx b/frontend/src/components/CippComponents/CippApiDialog.jsx index c8c434a42c..2f2603a693 100644 --- a/frontend/src/components/CippComponents/CippApiDialog.jsx +++ b/frontend/src/components/CippComponents/CippApiDialog.jsx @@ -16,6 +16,15 @@ import { useForm, useFormState } from 'react-hook-form' import { useSettings } from '../../hooks/use-settings' import CippFormComponent from './CippFormComponent' import { CippFormCondition } from './CippFormCondition' +import { + getNestedValue as getRowPath, + getRowTenant, +} from '../../utils/resolve-row-templates' +import { + extractCsvColumnValues, + mergeCsvFormFields, + normalizeAutoCompleteValues, +} from '../../utils/csv-field-values' export const CippApiDialog = (props) => { const { @@ -121,7 +130,8 @@ export const CippApiDialog = (props) => { if (typeof value === 'string' && value.startsWith('!')) { newData[key] = value.slice(1) } else if (typeof value === 'string') { - newData[key] = row[value] ?? value + const nested = getRowPath(row, value) + newData[key] = nested !== undefined ? nested : value } else if (typeof value === 'boolean') { newData[key] = value } else if (typeof value === 'object' && value !== null) { @@ -138,33 +148,24 @@ export const CippApiDialog = (props) => { } const tenantFilter = useSettings().currentTenant + const handleActionClick = (row, action, formData) => { setIsFormSubmitted(true) + const resolvedFormData = mergeCsvFormFields(formData, fields) let finalData = {} let isBulkRequest = false if (typeof api?.customDataformatter === 'function') { - finalData = api.customDataformatter(row, action, formData) - // If customDataformatter returns an array, enable bulk request mode + finalData = api.customDataformatter(row, action, resolvedFormData) isBulkRequest = Array.isArray(finalData) } else { if (action.multiPost === undefined) action.multiPost = false if (api.customFunction) { - action.customFunction(row, action, formData) + action.customFunction(row, action, resolvedFormData) createDialog.handleClose() return } - // Helper function to get the correct tenant filter for a row - const getRowTenantFilter = (rowData) => { - // If we're in AllTenants mode and the row has a Tenant property, use that - if (tenantFilter === 'AllTenants' && rowData?.Tenant) { - return rowData.Tenant - } - // Otherwise use the current tenant filter - return tenantFilter - } - const processedActionData = processActionData(action.data, row, action.replacementBehaviour) if (!processedActionData || Object.keys(processedActionData).length === 0) { @@ -174,14 +175,16 @@ export const CippApiDialog = (props) => { if (Array.isArray(row)) { const arrayData = row.map((singleRow) => { const commonData = { - tenantFilter: getRowTenantFilter(singleRow), - ...formData, + tenantFilter: getRowTenant(singleRow, tenantFilter), + ...resolvedFormData, ...addedFieldData, } const itemData = { ...commonData } Object.keys(processedActionData).forEach((key) => { - const rowValue = singleRow[processedActionData[key]] - itemData[key] = rowValue !== undefined ? rowValue : processedActionData[key] + const mapped = processedActionData[key] + const rowValue = + typeof mapped === 'string' ? getRowPath(singleRow, mapped) : undefined + itemData[key] = rowValue !== undefined ? rowValue : mapped }) return itemData }) @@ -208,12 +211,11 @@ export const CippApiDialog = (props) => { // SINGLE ROW CASE const commonData = { - tenantFilter: getRowTenantFilter(row), - ...formData, + tenantFilter: getRowTenant(row, tenantFilter), + ...resolvedFormData, ...addedFieldData, } - // ✅ FIXED: DIRECT MERGE INSTEAD OF CORRUPT TRANSFORMATION finalData = { ...commonData, ...processedActionData, @@ -404,23 +406,64 @@ export const CippApiDialog = (props) => { ) : ( <> {fields?.map((fieldProps, i) => { - const { condition, ...rest } = fieldProps - if ( - rest.api?.processFieldData && - rest.api?.data && - row && - !Array.isArray(row) - ) { - const processedData = processActionData(rest.api.data, row) - rest.api = { - ...rest.api, - data: processedData, - queryKey: - rest.api.queryKey ?? `${rest.api.url}-${JSON.stringify(processedData)}`, + const { condition, component, csvColumn, ...rest } = fieldProps + + if (csvColumn && rest.type === 'autoComplete') { + const csvFieldName = `${rest.name}__csv` + const origValidate = rest.validators?.validate + rest.validators = { + ...rest.validators, + validate: (value, formValues) => { + const hasAC = normalizeAutoCompleteValues(value).length > 0 + const csvRows = formValues[csvFieldName] + const csvValues = extractCsvColumnValues(csvRows, csvColumn) + const hasCsvValues = csvValues.length > 0 + const hasCsvRows = + Array.isArray(csvRows) && csvRows.length > 0 + + if (hasAC || hasCsvValues) { + if (typeof origValidate === 'function' && hasAC) { + return origValidate(value, formValues) + } + return true + } + if (hasCsvRows) { + return `CSV must include a ${csvColumn} column with at least one value` + } + return `Select at least one option or upload a CSV with a ${csvColumn} column` + }, + deps: [csvFieldName], + } + } + + if (rest.api) { + let nextApi = rest.api + if ( + nextApi.processFieldData && + nextApi.data && + row && + !Array.isArray(row) + ) { + const processedData = processActionData(nextApi.data, row) + nextApi = { + ...nextApi, + data: processedData, + queryKey: + nextApi.queryKey ?? + `${nextApi.url}-${JSON.stringify(processedData)}`, + } } + if (nextApi.tenantFilter === undefined && nextApi.url) { + nextApi = { + ...nextApi, + tenantFilter: getRowTenant(row, tenantFilter), + } + } + rest.api = nextApi } + const FieldComponent = component ?? CippFormComponent const fieldElement = ( - { {...rest} /> ) + + const csvElement = csvColumn ? ( + + + + ) : null + return ( {condition ? ( {fieldElement} + {csvElement} ) : ( - fieldElement + <> + {fieldElement} + {csvElement} + )} ) diff --git a/frontend/src/components/CippTable/CIPPTableToptoolbar.js b/frontend/src/components/CippTable/CIPPTableToptoolbar.js index f4d8e0248d..08db847f8f 100644 --- a/frontend/src/components/CippTable/CIPPTableToptoolbar.js +++ b/frontend/src/components/CippTable/CIPPTableToptoolbar.js @@ -48,6 +48,7 @@ import { usePopover } from '../../hooks/use-popover' import { useDialog } from '../../hooks/use-dialog' import { CippApiDialog } from '../CippComponents/CippApiDialog' import { useSettings } from '../../hooks/use-settings' +import { attachParentRow } from '../../utils/resolve-row-templates' import { useBrandingSettings } from '../CippPdf/useBrandingSettings' import { useRouter } from 'next/router' import { CippOffCanvas } from '../CippComponents/CippOffCanvas' @@ -114,6 +115,8 @@ export const CIPPTableToptoolbar = React.memo( searchValue = '', setSearchValue, restoredFiltersRef, + persistenceKey, + parentRow, }) => { const popover = usePopover() const [filtersAnchor, setFiltersAnchor] = useState(null) @@ -144,12 +147,14 @@ export const CIPPTableToptoolbar = React.memo( useState(simpleColumns) const [filterCanvasVisible, setFilterCanvasVisible] = useState(false) const presetKey = (filter) => filter?.id ?? filter?.filterName - const pageName = router.pathname.split('/').slice(1).join('/') + const pageName = persistenceKey ?? (isInDialog ? '' : router.pathname.split('/').slice(1).join('/')) const [useCompactMode, setUseCompactMode] = useState(false) const toolbarRef = useRef(null) const leftContainerRef = useRef(null) const actionsContainerRef = useRef(null) + const wrapActionRow = (original) => attachParentRow(original, parentRow) + const getBulkActions = (actions, selectedRows) => { return ( actions @@ -163,8 +168,8 @@ export const CIPPTableToptoolbar = React.memo( // The default stays all-or-nothing (every selected row must qualify). disabled: action.condition ? action.bulkFilterEligible - ? !selectedRows.some((row) => action.condition(row.original)) - : !selectedRows.every((row) => action.condition(row.original)) + ? !selectedRows.some((row) => action.condition(wrapActionRow(row.original))) + : !selectedRows.every((row) => action.condition(wrapActionRow(row.original))) : false, })) || [] ) @@ -227,9 +232,9 @@ export const CIPPTableToptoolbar = React.memo( const allSelectedRows = table.getSelectedRowModel().rows const eligibleRows = action.bulkFilterEligible && action.condition - ? allSelectedRows.filter((row) => action.condition(row.original)) + ? allSelectedRows.filter((row) => action.condition(wrapActionRow(row.original))) : allSelectedRows - const selectedData = eligibleRows.map((row) => row.original) + const selectedData = eligibleRows.map((row) => wrapActionRow(row.original)) if (typeof action.customBulkHandler === 'function') { action.customBulkHandler({ @@ -246,7 +251,7 @@ export const CIPPTableToptoolbar = React.memo( // api.noConfirm true, and its mount effect auto-submits into the same customFunction // being called here — every selected row's action fired twice. if (action?.noConfirm && action.customFunction) { - eligibleRows.forEach((row) => action.customFunction(row.original.original, action, {})) + eligibleRows.forEach((row) => action.customFunction(wrapActionRow(row.original.original ?? row.original), action, {})) // Deliberately no closeMenu() here — that matches the behaviour this branch had // before; the only thing being fixed is the duplicate invocation. return @@ -276,6 +281,7 @@ export const CIPPTableToptoolbar = React.memo( const restorationKey = `${pageName}-graph` if ( + pageName && settings.persistFilters && settings.lastUsedFilters && settings.lastUsedFilters[pageName] && @@ -393,6 +399,7 @@ export const CIPPTableToptoolbar = React.memo( const restorationKey = `${pageName}-table` // Wait for table to be initialized and columns to exist (column filters need them) if ( + pageName && settings.persistFilters && settings.lastUsedFilters && settings.lastUsedFilters[pageName] && @@ -510,12 +517,14 @@ export const CIPPTableToptoolbar = React.memo( } return updatedVisibility }) - settings.handleUpdate({ - columnDefaults: { - ...settings?.columnDefaults, - [pageName]: {}, - }, - }) + if (pageName) { + settings.handleUpdate({ + columnDefaults: { + ...settings?.columnDefaults, + [pageName]: {}, + }, + }) + } setColumnsAnchor(null) } @@ -540,12 +549,14 @@ export const CIPPTableToptoolbar = React.memo( } const saveAsPreferedColumns = () => { - settings.handleUpdate({ - columnDefaults: { - ...settings?.columnDefaults, - [pageName]: columnVisibility, - }, - }) + if (pageName) { + settings.handleUpdate({ + columnDefaults: { + ...settings?.columnDefaults, + [pageName]: columnVisibility, + }, + }) + } setColumnsAnchor(null) } @@ -638,7 +649,7 @@ export const CIPPTableToptoolbar = React.memo( } const persistFilterSlots = (updater) => { - if (!settings.persistFilters || !settings.setLastUsedFilter) { + if (!pageName || !settings.persistFilters || !settings.setLastUsedFilter) { return } const current = normalizePersistedFilters( @@ -1627,8 +1638,19 @@ export const CIPPTableToptoolbar = React.memo( fields={actionData.action?.fields} api={actionData.action} row={actionData.data} - relatedQueryKeys={queryKeys} {...actionData.action} + relatedQueryKeys={[ + ...(queryKeys + ? Array.isArray(queryKeys) + ? queryKeys + : [queryKeys] + : []), + ...(Array.isArray(actionData.action?.relatedQueryKeys) + ? actionData.action.relatedQueryKeys + : actionData.action?.relatedQueryKeys + ? [actionData.action.relatedQueryKeys] + : []), + ].filter(Boolean)} /> )} @@ -1640,6 +1662,7 @@ export const CIPPTableToptoolbar = React.memo( onClose={() => setFilterCanvasVisible(!filterCanvasVisible)} contentPadding={1} keepMounted={true} + aboveModal={isInDialog} > { @@ -128,6 +138,31 @@ export const orderColumnsBySelection = (allIds, selectedIds) => { // and loop the static-data sync effect. const EMPTY_ARRAY = [] +const buildSubTableColumn = (sub) => ({ + id: sub.id, + header: sub.header ?? sub.id, + size: sub.size ?? 120, + minSize: sub.minSize ?? 100, + enableSorting: false, + enableColumnFilter: false, + enableGlobalFilter: false, + accessorFn: (row) => { + if (typeof sub.label === 'function') { + return sub.label(row) + } + return sub.label ?? 'View' + }, + Cell: ({ row }) => ( + + ), +}) + const SORTING_FNS = { dateTimeNullsLast: (a, b, id) => { const aRaw = getRowValueByColumnId(a, id) @@ -431,6 +466,9 @@ export const CippDataTable = (props) => { viewMode: viewModeProp, mobileCard, dataSourceControls, + subTables = EMPTY_ARRAY, + persistenceKey, + parentRow, } = props // Create a map of column IDs to their filterType for quick lookup @@ -459,7 +497,7 @@ export const CippDataTable = (props) => { useState(simpleColumns) const [usedData, setUsedData] = useState(data) const [usedColumns, setUsedColumns] = useState([]) - const lastOrderedSelectionRef = useRef(simpleColumns) + const lastOrderedSelectionRef = useRef(null) const [offcanvasVisible, setOffcanvasVisible] = useState(false) const [offCanvasData, setOffCanvasData] = useState({}) const [offCanvasRowIndex, setOffCanvasRowIndex] = useState(0) @@ -484,7 +522,8 @@ export const CippDataTable = (props) => { const settings = useSettings() const router = useRouter() - const pageName = router.pathname.split('/').slice(1).join('/') + const routerPageName = router.pathname.split('/').slice(1).join('/') + const pageName = persistenceKey ?? (isInDialog ? '' : routerPageName) // 'cards' below the md breakpoint (or when forced via settings/prop), 'table' otherwise. // simple tables always resolve to 'table'. @@ -654,8 +693,12 @@ export const CippDataTable = (props) => { }) } else if (configuredSimpleColumns.length > 0) { // Resolve any variables in the simple columns before checking visibility - const resolvedSimpleColumns = resolveSimpleColumnVariables( - configuredSimpleColumns, + const resolvedSimpleColumns = resolveSubTableSimpleColumns( + resolveSimpleColumnVariables( + configuredSimpleColumns, + usedData + ), + subTables, usedData ) @@ -671,11 +714,23 @@ export const CippDataTable = (props) => { newVisibility[col.id] = finalResolvedColumns.includes(col.id) } }) - // Selection order wins over data-key order — but only when the selection itself - // changed, so a data refetch doesn't stomp a manual column reorder. - if (lastOrderedSelectionRef.current !== configuredSimpleColumns) { - lastOrderedSelectionRef.current = configuredSimpleColumns - const allIds = finalColumns.map((col) => col.id).filter(Boolean) + // Selection order wins over data-key order — but only when the resolved selection + // changed (including subTable cachedColumn swaps), so a data refetch doesn't + // stomp a manual column reorder. + const resolvedOrderKey = finalResolvedColumns.join('|') + if (lastOrderedSelectionRef.current !== resolvedOrderKey) { + lastOrderedSelectionRef.current = resolvedOrderKey + const subTableIds = getSubTableDisplayColumnIds( + subTables, + configuredSimpleColumns, + usedData + ) + const allIds = [ + ...new Set([ + ...finalColumns.map((col) => col.id).filter(Boolean), + ...subTableIds, + ]), + ] table.setColumnOrder(orderColumnsBySelection(allIds, finalResolvedColumns)) } } else { @@ -713,6 +768,7 @@ export const CippDataTable = (props) => { queryKey, settings?.currentTenant, filterTypeMap, + subTables, ]) // Previous-value refs for the guards below: CippDataTable is the single owner of this @@ -758,8 +814,11 @@ export const CippDataTable = (props) => { }, [pageName]) // apply preferred columns once per page, and again whenever the saved preference's - // identity changes + // identity changes. Nested dialog tables must not read or write the parent page key. useEffect(() => { + if (!pageName) { + return + } const preferred = settings?.columnDefaults?.[pageName] if ( preferred && @@ -821,12 +880,70 @@ export const CippDataTable = (props) => { return result }, [columnVisibility]) + const displayColumns = useMemo(() => { + if (!Array.isArray(subTables) || subTables.length === 0) { + return usedColumns + } + const cachedHeaders = new Map() + const injected = [] + for (const sub of subTables) { + if (!subTableIsSelected(sub, configuredSimpleColumns)) { + continue + } + if (subTableShowsCachedColumn(sub, usedData)) { + cachedHeaders.set(sub.cachedColumn, sub.header ?? sub.id) + continue + } + injected.push(buildSubTableColumn(sub)) + } + const columns = cachedHeaders.size + ? usedColumns.map((col) => + cachedHeaders.has(col.id) + ? { ...col, header: cachedHeaders.get(col.id) } + : col + ) + : usedColumns + const injectedById = new Map(injected.map((col) => [col.id, col])) + const replaced = columns.map((col) => injectedById.get(col.id) ?? col) + const existing = new Set(columns.map((col) => col.id)) + return [...replaced, ...injected.filter((col) => !existing.has(col.id))] + }, [usedColumns, usedData, subTables, configuredSimpleColumns]) + + useEffect(() => { + if (!Array.isArray(subTables) || subTables.length === 0) { + return + } + setColumnVisibility((prev) => { + const next = { ...prev } + let changed = false + for (const sub of subTables) { + if (!subTableIsSelected(sub, configuredSimpleColumns)) { + continue + } + const columnId = subTableShowsCachedColumn(sub, usedData) + ? sub.cachedColumn + : sub.id + if (next[columnId] === undefined) { + next[columnId] = true + changed = true + } + } + return changed ? next : prev + }) + }, [subTables, configuredSimpleColumns, usedData]) + const handleActionDisabled = useCallback((row, action) => { + const actionRow = attachParentRow(row, parentRow) if (action?.condition) { - return !action.condition(row) + return !action.condition(actionRow) } return false - }, []) + }, [parentRow]) + + const getActionRow = useCallback( + (rowOriginal) => attachParentRow(rowOriginal, parentRow), + [parentRow] + ) // Stable callback for sorting changes. const handleSortingChange = useCallback((newSorting) => { @@ -935,16 +1052,17 @@ export const CippDataTable = (props) => { const dispatchRowAction = useCallback( (action, rowOriginal, closeMenu = () => {}) => { const scopeToRowTenant = () => { - if (settings.currentTenant === 'AllTenants' && rowOriginal?.Tenant) { + const tenant = getRowTenant(getActionRow(rowOriginal), settings.currentTenant) + if (settings.currentTenant === 'AllTenants' && tenant && tenant !== 'AllTenants') { settings.handleUpdate({ - currentTenant: rowOriginal.Tenant, + currentTenant: tenant, }) } } if (action.noConfirm && action.customFunction) { scopeToRowTenant() - action.customFunction(rowOriginal, action, {}) + action.customFunction(getActionRow(rowOriginal), action, {}) closeMenu() return } @@ -952,7 +1070,7 @@ export const CippDataTable = (props) => { // Handle custom component differently if (typeof action.customComponent === 'function') { scopeToRowTenant() - setCustomComponentData({ data: rowOriginal, action: action }) + setCustomComponentData({ data: getActionRow(rowOriginal), action: action }) setCustomComponentVisible(true) closeMenu() return @@ -960,14 +1078,14 @@ export const CippDataTable = (props) => { // Standard dialog flow setActionData({ - data: rowOriginal, + data: getActionRow(rowOriginal), action: action, ready: true, }) createDialog.handleOpen() closeMenu() }, - [settings, createDialog] + [settings, createDialog, getActionRow] ) // Open the extended-info offcanvas for a row, recording its position in the row model so @@ -1003,7 +1121,7 @@ export const CippDataTable = (props) => { // condition, which renders it disabled). (action) => typeof action.hideCondition !== 'function' || - !action.hideCondition(row.original) + !action.hideCondition(getActionRow(row.original)) ) .map((action, index) => ( { dispatchRowAction, openRowOffCanvas, handleActionDisabled, + getActionRow, ]) // Stable renderTopToolbar — memoized so MaterialReactTable doesn't re-create the toolbar @@ -1075,7 +1194,7 @@ export const CippDataTable = (props) => { data={data} columnVisibility={columnVisibility} getRequestData={getRequestData} - usedColumns={usedColumns} + usedColumns={displayColumns} usedData={memoizedData ?? EMPTY_ARRAY} title={title} actions={actions} @@ -1088,6 +1207,8 @@ export const CippDataTable = (props) => { setGraphFilterData={setGraphFilterData} setConfiguredSimpleColumns={setConfiguredSimpleColumns} queueMetadata={getRequestData.data?.pages?.[0]?.Metadata} + persistenceKey={persistenceKey} + parentRow={parentRow} isInDialog={isInDialog} showBulkExportAction={showBulkExportAction} onViewToggle={toggleAllowed ? handleViewToggle : undefined} @@ -1114,6 +1235,7 @@ export const CippDataTable = (props) => { columnVisibility, getRequestData, usedColumns, + displayColumns, memoizedData, title, actions, @@ -1154,7 +1276,7 @@ export const CippDataTable = (props) => { columnFilters: columnFilters, columnVisibility: sanitizedColumnVisibility, }, - columns: usedColumns, + columns: displayColumns, data: memoizedData ?? EMPTY_ARRAY, state: tableState, onSortingChange: handleSortingChange, @@ -1195,6 +1317,29 @@ export const CippDataTable = (props) => { table.toggleAllRowsSelected(false) }, [memoizedData]) + // utilTableMode seeds columnOrder from simpleColumns (e.g. "members"), but cached report + // data shows membersCsv instead — MRT crashes if order references ids that are not in + // displayColumns. + useEffect(() => { + if (!Array.isArray(subTables) || subTables.length === 0) { + return + } + const displayIds = displayColumns.map((col) => col.id).filter(Boolean) + if (displayIds.length === 0) { + return + } + const currentOrder = table.getState().columnOrder ?? [] + if (!columnOrderHasStaleIds(currentOrder, displayIds)) { + return + } + const selectedForOrder = resolveSubTableSimpleColumns( + configuredSimpleColumns, + subTables, + usedData + ).filter((id) => displayIds.includes(id)) + table.setColumnOrder(orderColumnsBySelection(displayIds, selectedForOrder)) + }, [configuredSimpleColumns, displayColumns, subTables, usedData, table]) + // size the narrow table's scroll viewport from where it actually sits: viewport height // minus the container's measured top, the real footer height and the chrome below the // paper. the desktop calc assumes chrome heights that phone layouts do not have. @@ -1337,8 +1482,12 @@ export const CippDataTable = (props) => { const selectModeActive = hasOnChange ? true : mobileSelectMode + const resolvedCardButton = cardButton ? ( + + ) : undefined + // below md, table-in-Card branch: the actions FAB carries cardButton - const headerAction = isNarrowViewport && !isInDialog ? undefined : cardButton + const headerAction = isNarrowViewport && !isInDialog ? undefined : resolvedCardButton return ( <> @@ -1392,7 +1541,7 @@ export const CippDataTable = (props) => { data={data} columnVisibility={columnVisibility} getRequestData={getRequestData} - usedColumns={usedColumns} + usedColumns={displayColumns} usedData={memoizedData ?? EMPTY_ARRAY} title={title} actions={actions} @@ -1405,6 +1554,8 @@ export const CippDataTable = (props) => { setGraphFilterData={setGraphFilterData} setConfiguredSimpleColumns={setConfiguredSimpleColumns} queueMetadata={getRequestData.data?.pages?.[0]?.Metadata} + persistenceKey={persistenceKey} + parentRow={parentRow} isInDialog={isInDialog} embedded={isInDialog || noCard} showBulkExportAction={showBulkExportAction} @@ -1430,8 +1581,9 @@ export const CippDataTable = (props) => { onRowAction={dispatchRowAction} onMoreInfo={openRowOffCanvas} isActionDisabled={handleActionDisabled} + getActionRow={getActionRow} selectMode={selectModeActive} - cardButton={cardButton} + cardButton={resolvedCardButton} mobileCard={mobileCard} fixedChrome={!isInDialog && !noCard} onClearFilters={handleClearAllFilters} @@ -1518,8 +1670,8 @@ export const CippDataTable = (props) => { - {isNarrowViewport && !isInDialog && cardButton && ( - {cardButton} + {isNarrowViewport && !isInDialog && resolvedCardButton && ( + {resolvedCardButton} )} )} @@ -1530,6 +1682,7 @@ export const CippDataTable = (props) => { extendedData={offCanvasData} extendedInfoFields={offCanvas?.extendedInfoFields} title={offCanvasData?.Name || offCanvas?.title || 'Extended Info'} + aboveModal={isInDialog} children={ offCanvas?.children ? (row) => offCanvas.children(row, currentRowIndex) @@ -1587,8 +1740,15 @@ export const CippDataTable = (props) => { fields={actionData.action?.fields} api={actionData.action} row={actionData.data} - relatedQueryKeys={queryKey ? queryKey : title} {...actionData.action} + relatedQueryKeys={[ + ...(queryKey ? [queryKey] : title ? [title] : []), + ...(Array.isArray(actionData.action?.relatedQueryKeys) + ? actionData.action.relatedQueryKeys + : actionData.action?.relatedQueryKeys + ? [actionData.action.relatedQueryKeys] + : []), + ].filter(Boolean)} /> ) }, [ diff --git a/frontend/src/components/CippTable/CippDataTableButton.jsx b/frontend/src/components/CippTable/CippDataTableButton.jsx index f0b3d6c742..9c99484e72 100644 --- a/frontend/src/components/CippTable/CippDataTableButton.jsx +++ b/frontend/src/components/CippTable/CippDataTableButton.jsx @@ -1,13 +1,45 @@ -import { useState } from "react"; +import { useMemo, useState } from "react"; import { Dialog, DialogContent, DialogTitle, IconButton, Button, useMediaQuery } from "@mui/material"; import CloseIcon from "@mui/icons-material/Close"; import { CippDataTable } from "./CippDataTable"; import { getCippTranslation } from "../../utils/get-cipp-translation"; -const CippDataTableButton = ({ data, title, tableTitle = "Data" }) => { +import { resolveRowTemplates, getRowTenant } from "../../utils/resolve-row-templates"; +import { useSettings } from "../../hooks/use-settings"; + +const applyTenantFilterDefault = (api, row, currentTenant) => { + if (!api) { + return api; + } + const data = { ...(api.data || {}) }; + if (data.tenantFilter === undefined && data.TenantFilter === undefined) { + const tenant = getRowTenant(row, currentTenant); + if (tenant) { + data.tenantFilter = tenant; + } + } + return { ...api, data }; +}; + +const CippDataTableButton = ({ + data, + title, + tableTitle = "Data", + row, + api, + label, + condition, + queryKey, + ...tableProps +}) => { const [openDialogs, setOpenDialogs] = useState([]); + const [liveOpen, setLiveOpen] = useState(false); const mdDown = useMediaQuery((theme) => theme.breakpoints.down("md")); + const settings = useSettings(); + const isLive = Boolean(api?.url); + + const nestedTitle = tableProps.title ?? tableTitle ?? title ?? "Data"; - const handleOpenDialog = (event) => { + const handleOpenStaticDialog = (event) => { event?.stopPropagation(); let dataArray; @@ -25,10 +57,49 @@ const CippDataTableButton = ({ data, title, tableTitle = "Data" }) => { setOpenDialogs([...openDialogs, dataArray]); }; - const handleCloseDialog = (index, event) => { + const handleCloseStaticDialog = (index, event) => { event?.stopPropagation?.(); setOpenDialogs(openDialogs.filter((_, i) => i !== index)); }; + + const handleOpenLiveDialog = (event) => { + event?.stopPropagation(); + setLiveOpen(true); + }; + + const handleCloseLiveDialog = (event) => { + event?.stopPropagation?.(); + setLiveOpen(false); + }; + + const liveTableProps = useMemo(() => { + if (!isLive || !liveOpen) { + return null; + } + const templatedApi = applyTenantFilterDefault( + resolveRowTemplates(api, row), + row, + settings?.currentTenant + ); + const templatedQueryKey = queryKey + ? resolveRowTemplates(queryKey, row) + : undefined; + const templatedTitle = resolveRowTemplates(nestedTitle, row); + const { title: _ignoredTitle, ...rest } = tableProps; + + return { + ...rest, + api: templatedApi, + queryKey: templatedQueryKey, + title: templatedTitle, + parentRow: row, + isInDialog: true, + simple: rest.simple ?? false, + hideTitle: mdDown, + maxHeightOffset: rest.maxHeightOffset ?? "160px", + }; + }, [api, isLive, liveOpen, mdDown, nestedTitle, queryKey, row, settings?.currentTenant, tableProps]); + const dataIsNotANullArray = !Array.isArray(data) && (typeof data !== "object" || data === null || Object.keys(data).length === 0); @@ -38,55 +109,99 @@ const CippDataTableButton = ({ data, title, tableTitle = "Data" }) => { ? Object.keys(data).length : 0; + const liveDisabled = typeof condition === "function" ? !condition(row) : false; + const buttonLabel = isLive + ? typeof label === "function" + ? label(row) + : label ?? "View" + : dataIsNotANullArray + ? "No items" + : `${dataLength} items`; + + const dialogTitle = isLive + ? liveTableProps?.title ?? nestedTitle + : tableTitle; + return ( <> - {openDialogs.map((dialogData, index) => ( + {isLive && liveOpen && liveTableProps && ( handleCloseDialog(index, event)} + onClose={handleCloseLiveDialog} onMouseDown={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()} fullWidth - // Fullscreen on phones (CippApiDialog precedent): the nested card list needs the - // viewport, not a cramped modal window — and fullscreen has no backdrop, so give - // it an explicit close header. fullScreen={mdDown} maxWidth="lg" > {mdDown && ( handleCloseDialog(index, event)} + onClick={handleCloseLiveDialog} aria-label="Close" sx={{ minWidth: 44, minHeight: 44 }} > - {tableTitle} + {dialogTitle} )} - - + + - ))} + )} + + {!isLive && + openDialogs.map((dialogData, index) => ( + handleCloseStaticDialog(index, event)} + onMouseDown={(event) => event.stopPropagation()} + onClick={(event) => event.stopPropagation()} + fullWidth + fullScreen={mdDown} + maxWidth="lg" + > + {mdDown && ( + + handleCloseStaticDialog(index, event)} + aria-label="Close" + sx={{ minWidth: 44, minHeight: 44 }} + > + + + {tableTitle} + + )} + + + + + ))} ); }; diff --git a/frontend/src/components/CippTable/CippMobileCardList.jsx b/frontend/src/components/CippTable/CippMobileCardList.jsx index d4e27fb991..f817177544 100644 --- a/frontend/src/components/CippTable/CippMobileCardList.jsx +++ b/frontend/src/components/CippTable/CippMobileCardList.jsx @@ -94,6 +94,7 @@ export const CippMobileCardList = (props) => { onRowAction, onMoreInfo, isActionDisabled, + getActionRow = (row) => row, selectMode = false, cardButton, mobileCard, @@ -136,7 +137,8 @@ export const CippMobileCardList = (props) => { const rowActionItems = (row) => (actions ?? []).filter( - (action) => typeof action.hideCondition !== "function" || !action.hideCondition(row.original) + (action) => + typeof action.hideCondition !== "function" || !action.hideCondition(getActionRow(row.original)) ); // Detail rows that would waste space: empty values, or values already shown as the diff --git a/frontend/src/components/CippTable/CippTableCardButton.jsx b/frontend/src/components/CippTable/CippTableCardButton.jsx new file mode 100644 index 0000000000..d3960a6e5f --- /dev/null +++ b/frontend/src/components/CippTable/CippTableCardButton.jsx @@ -0,0 +1,73 @@ +import React from 'react' +import { Button } from '@mui/material' +import { Stack } from '@mui/system' +import { CippApiDialog } from '../CippComponents/CippApiDialog' +import { useDialog } from '../../hooks/use-dialog' +import { resolveRowTemplates } from '../../utils/resolve-row-templates' + +const isActionConfig = (value) => + Boolean(value) && + typeof value === 'object' && + !React.isValidElement(value) && + !Array.isArray(value) && + (typeof value.url === 'string' || typeof value.link === 'string') + +const CippTableActionButton = ({ action, row }) => { + const createDialog = useDialog() + + if (typeof action.condition === 'function' && !action.condition(row)) { + return null + } + + return ( + <> + + + + ) +} + +export const CippTableCardButton = ({ cardButton, row }) => { + if (!cardButton) { + return null + } + if (typeof cardButton === 'function') { + return cardButton(row) + } + if (Array.isArray(cardButton)) { + return ( + + {cardButton.map((item, index) => ( + + ))} + + ) + } + if (isActionConfig(cardButton)) { + return + } + return cardButton +} diff --git a/frontend/src/components/CippTable/util-subTables.js b/frontend/src/components/CippTable/util-subTables.js new file mode 100644 index 0000000000..60b256bc4e --- /dev/null +++ b/frontend/src/components/CippTable/util-subTables.js @@ -0,0 +1,75 @@ +const hasOwn = (row, key) => + Boolean(key) && row != null && typeof row === 'object' && Object.prototype.hasOwnProperty.call(row, key) + +const hasPopulatedColumnValue = (row, columnId) => { + if (!hasOwn(row, columnId)) { + return false + } + const value = row[columnId] + if (value == null) { + return false + } + if (typeof value === 'string') { + return value.trim().length > 0 + } + if (Array.isArray(value)) { + return value.length > 0 + } + return true +} + +export const dataHasPopulatedColumn = (data, columnId) => + Boolean(columnId) && + Array.isArray(data) && + data.some((row) => hasPopulatedColumnValue(row, columnId)) + +export const subTableIsSelected = (sub, selectedIds) => { + if (!sub?.id) { + return false + } + if (!Array.isArray(selectedIds) || selectedIds.length === 0) { + return true + } + return selectedIds.includes(sub.id) +} + +export const subTableShowsCachedColumn = (sub, data) => + Boolean(sub?.cachedColumn) && dataHasPopulatedColumn(data, sub.cachedColumn) + +export const resolveSubTableSimpleColumns = (simpleColumns, subTables, data) => { + if (!Array.isArray(simpleColumns) || !Array.isArray(subTables) || subTables.length === 0) { + return simpleColumns + } + + return simpleColumns.map((id) => { + const sub = subTables.find((item) => item.id === id) + if (sub && subTableShowsCachedColumn(sub, data)) { + return sub.cachedColumn + } + return id + }) +} + +export const getSubTableDisplayColumnIds = (subTables, simpleColumns, data) => { + if (!Array.isArray(subTables) || subTables.length === 0) { + return [] + } + const ids = [] + for (const sub of subTables) { + if (!subTableIsSelected(sub, simpleColumns)) { + continue + } + const columnId = subTableShowsCachedColumn(sub, data) ? sub.cachedColumn : sub.id + if (columnId) { + ids.push(columnId) + } + } + return ids +} + +export const columnOrderHasStaleIds = (columnOrder, displayColumnIds) => { + const displayIdSet = new Set(displayColumnIds) + return (columnOrder ?? []).some( + (id) => id && !String(id).startsWith('mrt-') && !displayIdSet.has(id) + ) +} diff --git a/frontend/src/pages/identity/administration/groups/index.js b/frontend/src/pages/identity/administration/groups/index.js index 6da25fb47d..be83c9eb2d 100644 --- a/frontend/src/pages/identity/administration/groups/index.js +++ b/frontend/src/pages/identity/administration/groups/index.js @@ -13,17 +13,20 @@ import { CloudSync, RocketLaunch, PersonAdd, + PersonRemove, } from '@mui/icons-material' import { Stack } from '@mui/system' -import { useState } from 'react' import { useSettings } from '../../../../hooks/use-settings' import { useCippReportDB } from '../../../../components/CippComponents/CippReportDBControls' +import { getRowTenant } from '../../../../utils/resolve-row-templates' const Page = () => { const pageTitle = 'Groups' - const [showMembers, setShowMembers] = useState(false) - const [showOwners, setShowOwners] = useState(false) const { currentTenant } = useSettings() + const tenantQuery = + currentTenant === 'AllTenants' ? '[Tenant]' : currentTenant + const nestedTenantQuery = + currentTenant === 'AllTenants' ? '[parent.Tenant]' : currentTenant const reportDB = useCippReportDB({ apiUrl: '/api/ListGroups', @@ -36,25 +39,10 @@ const Page = () => { cacheColumns: ['CacheTimestamp'], }) - const handleMembersToggle = () => { - setShowMembers((prev) => { - const next = !prev - if (next) setShowOwners(false) - return next - }) - } - - const handleOwnersToggle = () => { - setShowOwners((prev) => { - const next = !prev - if (next) setShowMembers(false) - return next - }) - } const actions = [ { label: 'View Group', - link: `/identity/administration/groups/group?groupId=[id]&tenantFilter=${currentTenant}`, + link: `/identity/administration/groups/group?groupId=[id]&tenantFilter=${tenantQuery}`, color: 'info', icon: , multiPost: false, @@ -94,7 +82,7 @@ const Page = () => { const selectedGroups = Array.isArray(row) ? row : [row] return selectedGroups.map((group) => ({ AddMember: addMember, - tenantFilter: group.Tenant ?? currentTenant, + tenantFilter: getRowTenant(group, currentTenant), groupId: group.id, groupName: group.displayName, groupType: group.groupType, @@ -436,16 +424,6 @@ const Page = () => { title={pageTitle} cardButton={ - {!reportDB.useReportDB && ( - <> - - - - )} @@ -460,23 +438,9 @@ const Page = () => { } dataSourceControls={reportDB.controls} apiUrl={reportDB.resolvedApiUrl} - apiData={ - reportDB.useReportDB - ? undefined - : showMembers - ? { expandMembers: true } - : showOwners - ? { expandOwners: true } - : {} - } + apiData={reportDB.useReportDB ? undefined : {}} queryKey={ - reportDB.useReportDB - ? reportDB.resolvedQueryKey - : showMembers - ? `groups-with-members-${currentTenant}` - : showOwners - ? `groups-with-owners-${currentTenant}` - : `groups-${currentTenant}` + reportDB.useReportDB ? reportDB.resolvedQueryKey : `groups-${currentTenant}` } actions={actions} offCanvas={offCanvas} @@ -495,6 +459,148 @@ const Page = () => { 'onPremisesSamAccountName', 'membershipRule', 'onPremisesSyncEnabled', + 'members', + 'owners', + ]} + subTables={[ + { + id: 'members', + header: 'Members', + label: 'View members', + cachedColumn: 'membersCsv', + table: { + title: 'Members of [displayName]', + queryKey: 'group-members-[id]', + api: { + url: '/api/ListGroups', + data: { groupID: '[id]', members: true, groupType: '[groupType]' }, + dataKey: 'members', + }, + simpleColumns: ['displayName', 'userPrincipalName', 'mail', '@odata.type'], + actions: [ + { + label: 'View User', + link: `/identity/administration/users/user?userId=[id]&tenantFilter=${nestedTenantQuery}`, + color: 'info', + icon: , + condition: (row) => + !row?.['@odata.type'] || row['@odata.type'] === '#microsoft.graph.user', + }, + { + label: 'View Group', + link: `/identity/administration/groups/group?groupId=[id]&tenantFilter=${nestedTenantQuery}`, + color: 'info', + icon: , + condition: (row) => row?.['@odata.type'] === '#microsoft.graph.group', + }, + { + label: 'Remove Member', + type: 'POST', + url: '/api/ExecGroupMembers', + icon: , + data: { action: '!removeMember', groupId: 'parent.id', users: 'id' }, + confirmText: 'Remove [displayName] from [parent.displayName]?', + condition: (row) => + !row?.parent?.dynamicGroupBool && !row?.parent?.membershipRule, + }, + ], + cardButton: { + label: 'Add Members', + icon: , + url: '/api/ExecGroupMembers', + allowResubmit: true, + relatedQueryKeys: 'group-members-[id]', + confirmText: 'Add members to [displayName]?', + condition: (row) => !row?.dynamicGroupBool && !row?.membershipRule, + data: { action: '!addMember', groupId: 'id' }, + fields: [ + { + type: 'autoComplete', + name: 'users', + label: 'Add Members', + multiple: true, + creatable: false, + csvColumn: 'userPrincipalName', + api: { + url: '/api/ListUsersAndGroups', + dataKey: 'Results', + valueField: 'id', + labelField: 'displayName', + descriptionField: 'userPrincipalName', + }, + }, + ], + }, + }, + }, + { + id: 'owners', + header: 'Owners', + label: 'View owners', + cachedColumn: 'ownersCsv', + table: { + title: 'Owners of [displayName]', + queryKey: 'group-owners-[id]', + api: { + url: '/api/ListGroups', + data: { groupID: '[id]', owners: true, groupType: '[groupType]' }, + dataKey: 'owners', + }, + simpleColumns: ['displayName', 'userPrincipalName', 'mail'], + actions: [ + { + label: 'View User', + link: `/identity/administration/users/user?userId=[id]&tenantFilter=${nestedTenantQuery}`, + color: 'info', + icon: , + condition: (row) => + !row?.['@odata.type'] || row['@odata.type'] === '#microsoft.graph.user', + }, + { + label: 'Remove Owner', + type: 'POST', + url: '/api/ExecGroupMembers', + icon: , + data: { action: '!removeOwner', groupId: 'parent.id', users: 'id' }, + confirmText: 'Remove [displayName] as owner of [parent.displayName]?', + }, + ], + cardButton: { + label: 'Add Owners', + icon: , + url: '/api/ExecGroupMembers', + allowResubmit: true, + relatedQueryKeys: 'group-owners-[id]', + confirmText: 'Add owners to [displayName]?', + data: { action: '!addOwner', groupId: 'id' }, + fields: [ + { + type: 'autoComplete', + name: 'users', + label: 'Add Owners', + multiple: true, + creatable: false, + csvColumn: 'userPrincipalName', + api: { + url: '/api/ListGraphRequest', + dataKey: 'Results', + valueField: 'id', + labelField: 'displayName', + descriptionField: 'userPrincipalName', + data: { + Endpoint: 'users', + manualPagination: true, + $select: 'id,userPrincipalName,displayName', + $count: true, + $orderby: 'displayName', + $top: 999, + }, + }, + }, + ], + }, + }, + }, ]} /> {reportDB.syncDialog} diff --git a/frontend/src/utils/csv-field-values.js b/frontend/src/utils/csv-field-values.js new file mode 100644 index 0000000000..7a61882af2 --- /dev/null +++ b/frontend/src/utils/csv-field-values.js @@ -0,0 +1,50 @@ +/** + * Pull values from CSV rows for a named column (case-insensitive, trimmed header match). + */ +export const extractCsvColumnValues = (csvRows, csvColumn) => { + if (!csvColumn || !Array.isArray(csvRows) || csvRows.length === 0) { + return [] + } + const colLower = String(csvColumn).trim().toLowerCase() + return csvRows + .map((row) => { + if (!row || typeof row !== 'object') return null + const key = Object.keys(row).find((k) => k.trim().toLowerCase() === colLower) + return key ? String(row[key]).trim() : null + }) + .filter((v) => v != null && v !== '') +} + +/** + * Flatten autocomplete form values to plain string ids/UPNs. + */ +export const normalizeAutoCompleteValues = (value) => { + const items = Array.isArray(value) ? value : value != null && value !== '' ? [value] : [] + return items + .filter(Boolean) + .map((item) => + typeof item === 'object' && item?.value != null + ? String(item.value) + : item != null + ? String(item) + : null + ) + .filter(Boolean) +} + +/** + * Merge autocomplete + optional CSV companion field (`${name}__csv`) into a flat string array. + */ +export const mergeCsvFormFields = (formData, fields) => { + if (!fields?.length) return formData + const merged = { ...formData } + fields.forEach((field) => { + if (!field.csvColumn || !field.name) return + const csvFieldName = `${field.name}__csv` + const acValues = normalizeAutoCompleteValues(merged[field.name]) + const csvValues = extractCsvColumnValues(merged[csvFieldName], field.csvColumn) + merged[field.name] = [...acValues, ...csvValues] + delete merged[csvFieldName] + }) + return merged +} diff --git a/frontend/src/utils/resolve-row-templates.js b/frontend/src/utils/resolve-row-templates.js new file mode 100644 index 0000000000..7a3656c7db --- /dev/null +++ b/frontend/src/utils/resolve-row-templates.js @@ -0,0 +1,93 @@ +const TEMPLATE = /\[([^\]]+)\]/g + +/** + * Resolve a dotted path against an object. Missing segments yield undefined. + */ +export const getNestedValue = (source, path) => { + if (source === undefined || source === null) { + return undefined + } + if (!path) { + return source + } + + return path.split('.').reduce((acc, key) => { + if (acc === undefined || acc === null) { + return undefined + } + if (typeof acc !== 'object') { + return undefined + } + return acc[key] + }, source) +} + +/** + * Nested-table action context: `parent` is the opening row. If the child already + * had `parent` (API data), chain it at `parent.parent` unless the opening row is + * itself nested and already owns that slot. + */ +export const attachParentRow = (row, parentRow) => { + if (!parentRow || row == null) { + return row + } + if (Array.isArray(row)) { + return row.map((item) => attachParentRow(item, parentRow)) + } + if (row.parent === parentRow) { + return row + } + + let nextParent = parentRow + if (row.parent !== undefined && parentRow.parent === undefined) { + nextParent = { ...parentRow, parent: row.parent } + } + return { ...row, parent: nextParent } +} + +/** + * AllTenants convention used across CIPP: prefer the row (or nested parent) tenant. + */ +export const getRowTenant = (row, currentTenant) => { + if (currentTenant !== 'AllTenants') { + return currentTenant + } + const source = Array.isArray(row) ? row[0] : row + return ( + source?.Tenant || + source?.parent?.Tenant || + source?.tenantFilter || + source?.parent?.tenantFilter || + currentTenant + ) +} + +const replaceTemplatesInString = (value, row) => + value.replace(TEMPLATE, (_, key) => { + const resolved = getNestedValue(row, key) + if (resolved === undefined || resolved === null) { + return `[${key}]` + } + return String(resolved) + }) + +/** + * Walk strings (and objects/arrays of them) and replace `[field]` / `[nested.path]` + * from `row`. Booleans, numbers, and null stay as-is. + */ +export const resolveRowTemplates = (value, row) => { + if (typeof value === 'string') { + return replaceTemplatesInString(value, row) + } + if (Array.isArray(value)) { + return value.map((item) => resolveRowTemplates(item, row)) + } + if (value && typeof value === 'object') { + const next = {} + for (const key of Object.keys(value)) { + next[key] = resolveRowTemplates(value[key], row) + } + return next + } + return value +} diff --git a/frontend/tests/components/CippComponents/CippApiDialog.test.jsx b/frontend/tests/components/CippComponents/CippApiDialog.test.jsx index a1c94bfc73..032d9d8f5a 100644 --- a/frontend/tests/components/CippComponents/CippApiDialog.test.jsx +++ b/frontend/tests/components/CippComponents/CippApiDialog.test.jsx @@ -116,4 +116,38 @@ describe('CippApiDialog', () => { await user.click(screen.getByRole('button', { name: 'Close' })) expect(createDialog.handleClose).toHaveBeenCalledTimes(1) }) + + it('resolves dotted parent maps on confirm', async () => { + const user = userEvent.setup() + renderDialog({ + row: { + id: 'member-1', + displayName: 'Jane', + parent: { id: 'group-1', displayName: 'Finance' }, + }, + api: { + type: 'POST', + url: '/api/ExecWhatever', + data: { childId: 'id', parentId: 'parent.id' }, + confirmText: 'Remove [displayName] from [parent.displayName]?', + }, + }) + + expect(screen.getByText('Remove Jane from Finance?')).toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'Confirm' })) + + await waitFor(() => { + expect(apiState.mutate).toHaveBeenCalledTimes(1) + }) + expect(apiState.mutate).toHaveBeenCalledWith({ + url: '/api/ExecWhatever', + bulkRequest: false, + data: { + tenantFilter: 'testdomain.com', + childId: 'member-1', + parentId: 'group-1', + }, + }) + }) }) diff --git a/frontend/tests/components/CippTable/CippDataTable.test.jsx b/frontend/tests/components/CippTable/CippDataTable.test.jsx index eca76ceac1..2b7c1dd216 100644 --- a/frontend/tests/components/CippTable/CippDataTable.test.jsx +++ b/frontend/tests/components/CippTable/CippDataTable.test.jsx @@ -715,3 +715,324 @@ describe('CippDataTable cards->table toggle scroll', () => { expect(scroller.scrollTop).toBe(120 + (300 - 64)) }) }) + +describe('CippDataTable subTables', () => { + const parentRows = [{ id: 'parent-1', displayName: 'Finance' }] + const relatedRows = [{ id: 'child-1', displayName: 'Jane Doe' }] + + it('injects a button column that opens a nested table', async () => { + const user = userEvent.setup() + renderWithProviders( + + ) + + await waitFor(() => expect(screen.getByText('Finance')).toBeInTheDocument()) + await user.click(screen.getByRole('button', { name: 'View' })) + + const dialog = await screen.findByRole('dialog') + await waitFor(() => { + expect(within(dialog).getByText('Related for Finance')).toBeInTheDocument() + }) + await waitFor(() => { + expect(within(dialog).getByText('Jane Doe')).toBeInTheDocument() + }) + }) + + it('runs nested row and bulk actions with the parent row attached', async () => { + const rowFn = vi.fn() + const user = userEvent.setup() + renderWithProviders( + + ) + + await waitFor(() => expect(screen.getByText('Finance')).toBeInTheDocument()) + await user.click(screen.getByRole('button', { name: 'View' })) + + const dialog = await screen.findByRole('dialog') + await waitFor(() => expect(within(dialog).getByText('Jane Doe')).toBeInTheDocument()) + + await user.click(within(dialog).getByRole('button', { name: 'Row actions' })) + await user.click(await screen.findByText('Remove')) + + expect(rowFn).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'child-1', + displayName: 'Jane Doe', + parent: expect.objectContaining({ id: 'parent-1', displayName: 'Finance' }), + }), + expect.anything(), + expect.anything() + ) + + rowFn.mockClear() + await user.click(within(dialog).getByRole('button', { name: 'Select' })) + await user.click(within(dialog).getByRole('checkbox', { name: 'Select Jane Doe' })) + await user.click(within(dialog).getByRole('button', { name: 'Actions' })) + await user.click(await screen.findByText('Remove')) + + expect(rowFn).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'child-1', + parent: expect.objectContaining({ id: 'parent-1' }), + }), + expect.anything(), + expect.anything() + ) + }) + + it('replaces a data column that shares the subTable id', async () => { + const user = userEvent.setup() + renderWithProviders( + + ) + + await waitFor(() => expect(screen.getByText('Finance')).toBeInTheDocument()) + await user.click(screen.getByRole('button', { name: 'View' })) + + const dialog = await screen.findByRole('dialog') + await waitFor(() => { + expect(within(dialog).getByText('Jane Doe')).toBeInTheDocument() + }) + expect(within(dialog).queryByText('stale')).not.toBeInTheDocument() + }) + + it('does not show a subTable column unless it is listed in simpleColumns', async () => { + renderWithProviders( + + ) + + await waitFor(() => expect(screen.getByText('Finance')).toBeInTheDocument()) + expect(screen.queryByRole('button', { name: 'View' })).not.toBeInTheDocument() + }) + + it('shows cachedColumn instead of the nested table button when that field is on the data', async () => { + renderWithProviders( + + ) + + await waitFor(() => expect(screen.getByText('Finance')).toBeInTheDocument()) + expect(screen.queryByRole('button', { name: 'View members' })).not.toBeInTheDocument() + expect(screen.getByText('Jane, Bob')).toBeInTheDocument() + }) + + it('renders cached report columns in table view without a stale column order crash', async () => { + renderWithProviders( + + ) + + await waitFor(() => expect(screen.getByText('Finance')).toBeInTheDocument()) + expect(screen.getByRole('columnheader', { name: 'Members' })).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'View members' })).not.toBeInTheDocument() + }) + + it('still shows the nested table button when cachedColumn is configured but missing from the data', async () => { + renderWithProviders( + + ) + + await waitFor(() => expect(screen.getByText('Finance')).toBeInTheDocument()) + expect(screen.getByRole('button', { name: 'View members' })).toBeInTheDocument() + }) + + it('shows the nested table button when cachedColumn exists but is empty (live API shape)', async () => { + renderWithProviders( + + ) + + await waitFor(() => expect(screen.getByText('Finance')).toBeInTheDocument()) + expect(screen.getByRole('button', { name: 'View members' })).toBeInTheDocument() + }) + + it('renders a declarative nested cardButton from table config', async () => { + const user = userEvent.setup() + renderWithProviders( + + ) + + await waitFor(() => expect(screen.getByText('Finance')).toBeInTheDocument()) + await user.click(screen.getByRole('button', { name: 'View' })) + + const nested = await screen.findByRole('dialog') + const addButton = await within(nested).findByRole('button', { name: 'Add Members' }) + await user.click(addButton) + + expect(await screen.findByText('Add Members for Finance?')).toBeInTheDocument() + }) +}) diff --git a/frontend/tests/components/CippTable/CippDataTableButton.stories.jsx b/frontend/tests/components/CippTable/CippDataTableButton.stories.jsx index 87a7fb8973..b348d2de56 100644 --- a/frontend/tests/components/CippTable/CippDataTableButton.stories.jsx +++ b/frontend/tests/components/CippTable/CippDataTableButton.stories.jsx @@ -1,3 +1,4 @@ +import { http, HttpResponse } from 'msw' import { within, expect, userEvent, waitFor } from 'storybook/test' import CippDataTableButton from '../../../src/components/CippTable/CippDataTableButton' @@ -54,3 +55,52 @@ export const EmptyData = { data: null, }, } + +export const LiveNestedTable = { + parameters: { + msw: { + handlers: [ + http.get('/api/TestRelated', () => + HttpResponse.json({ + Results: [ + { id: 'rel-1', displayName: 'Related one' }, + { id: 'rel-2', displayName: 'Related two' }, + ], + }) + ), + http.post('/api/ExecTestRelated', () => HttpResponse.json({ Results: 'ok' })), + ], + }, + }, + args: { + row: { id: 'parent-1', displayName: 'Finance' }, + label: 'View', + title: 'Related for [displayName]', + queryKey: 'related-[id]', + api: { + url: '/api/TestRelated', + data: { someId: '[id]' }, + dataKey: 'Results', + }, + simpleColumns: ['displayName'], + actions: [ + { + label: 'Remove', + type: 'POST', + url: '/api/ExecTestRelated', + data: { childId: 'id', parentId: 'parent.id' }, + confirmText: 'Remove [displayName] from [parent.displayName]?', + }, + ], + }, + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement) + await step('opens a live nested table on click', async () => { + await userEvent.click(canvas.getByRole('button', { name: 'View' })) + const root = within(document.body) + await waitFor(() => { + expect(root.getByRole('dialog')).toBeVisible() + }) + }) + }, +} diff --git a/frontend/tests/components/CippTable/CippDataTableButton.test.jsx b/frontend/tests/components/CippTable/CippDataTableButton.test.jsx index c42bbd76de..3a8172f072 100644 --- a/frontend/tests/components/CippTable/CippDataTableButton.test.jsx +++ b/frontend/tests/components/CippTable/CippDataTableButton.test.jsx @@ -3,8 +3,22 @@ import { screen, within, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { renderWithProviders } from '../../test-utils' import CippDataTableButton from '../../../src/components/CippTable/CippDataTableButton' +import { ApiGetCallWithPagination } from '../../../src/api/ApiCall' +import { api, paginatedResult } from '../../mocks/api-call' + +vi.mock('../../../src/api/ApiCall', async () => (await import('../../mocks/api-call')).apiCallMock()) + +const idlePaginated = paginatedResult([], { isSuccess: false }) +const relatedRows = [{ id: 'rel-1', name: 'Related one' }] +const relatedResult = paginatedResult(relatedRows) describe('CippDataTableButton', () => { + beforeEach(() => { + ApiGetCallWithPagination.mockClear() + api.paginated = (opts) => + opts?.url === '/api/TestRelated' ? relatedResult : idlePaginated + }) + it('shows item count and opens dialog on click', async () => { const user = userEvent.setup() renderWithProviders( @@ -80,4 +94,57 @@ describe('CippDataTableButton', () => { expect(button).toHaveTextContent('No items') expect(button).toBeDisabled() }) + + it('does not fetch live related data until the button is clicked', async () => { + const user = userEvent.setup() + const parentRow = { id: 'parent-1', displayName: 'Finance' } + + renderWithProviders( + + ) + + expect(screen.getByRole('button', { name: 'View' })).toBeEnabled() + expect( + ApiGetCallWithPagination.mock.calls.some((call) => call[0]?.url === '/api/TestRelated') + ).toBe(false) + + await user.click(screen.getByRole('button', { name: 'View' })) + + const dialog = await screen.findByRole('dialog') + expect(dialog).toBeInTheDocument() + await waitFor(() => { + expect( + ApiGetCallWithPagination.mock.calls.some((call) => call[0]?.url === '/api/TestRelated') + ).toBe(true) + }) + + const relatedCall = ApiGetCallWithPagination.mock.calls.find( + (call) => call[0]?.url === '/api/TestRelated' + ) + expect(relatedCall[0].data.someId).toBe('parent-1') + expect(relatedCall[0].queryKey).toBe('related-parent-1') + }) + + it('disables the live button when condition is false', () => { + renderWithProviders( + row.id === 'other'} + api={{ url: '/api/TestRelated', dataKey: 'Results' }} + /> + ) + expect(screen.getByRole('button', { name: 'View' })).toBeDisabled() + }) }) diff --git a/frontend/tests/components/CippTable/util-subTables.test.js b/frontend/tests/components/CippTable/util-subTables.test.js new file mode 100644 index 0000000000..bb3ee02a32 --- /dev/null +++ b/frontend/tests/components/CippTable/util-subTables.test.js @@ -0,0 +1,62 @@ +import { + dataHasPopulatedColumn, + resolveSubTableSimpleColumns, + subTableIsSelected, + subTableShowsCachedColumn, + getSubTableDisplayColumnIds, + columnOrderHasStaleIds, +} from '../../../src/components/CippTable/util-subTables' + +const membersSub = { + id: 'members', + header: 'Members', + cachedColumn: 'membersCsv', +} + +describe('util-subTables', () => { + it('selects a subTable only when its id is in simpleColumns', () => { + expect(subTableIsSelected(membersSub, ['displayName', 'members'])).toBe(true) + expect(subTableIsSelected(membersSub, ['displayName'])).toBe(false) + expect(subTableIsSelected(membersSub, [])).toBe(true) + }) + + it('uses the cached column when that field is present on the data', () => { + const cached = [{ id: '1', membersCsv: 'Jane, Bob' }] + const live = [{ id: '1', displayName: 'Finance' }] + const liveWithEmptyCsv = [{ id: '1', displayName: 'Finance', membersCsv: '' }] + + expect(dataHasPopulatedColumn(cached, 'membersCsv')).toBe(true) + expect(dataHasPopulatedColumn(liveWithEmptyCsv, 'membersCsv')).toBe(false) + expect(subTableShowsCachedColumn(membersSub, cached)).toBe(true) + expect(subTableShowsCachedColumn(membersSub, live)).toBe(false) + expect(subTableShowsCachedColumn(membersSub, liveWithEmptyCsv)).toBe(false) + expect( + resolveSubTableSimpleColumns(['displayName', 'members'], [membersSub], cached) + ).toEqual(['displayName', 'membersCsv']) + expect( + resolveSubTableSimpleColumns(['displayName', 'members'], [membersSub], live) + ).toEqual(['displayName', 'members']) + }) + + it('maps subTables to the active display column ids', () => { + const cached = [{ id: '1', membersCsv: 'Jane, Bob' }] + const live = [{ id: '1', displayName: 'Finance' }] + + expect( + getSubTableDisplayColumnIds([membersSub], ['displayName', 'members'], cached) + ).toEqual(['membersCsv']) + expect( + getSubTableDisplayColumnIds([membersSub], ['displayName', 'members'], live) + ).toEqual(['members']) + }) + + it('detects stale column order ids that are not on the table', () => { + expect(columnOrderHasStaleIds(['displayName', 'members'], ['displayName', 'membersCsv'])).toBe( + true + ) + expect( + columnOrderHasStaleIds(['displayName', 'membersCsv'], ['displayName', 'membersCsv']) + ).toBe(false) + expect(columnOrderHasStaleIds(['mrt-row-select', 'displayName'], ['displayName'])).toBe(false) + }) +}) diff --git a/frontend/tests/utils/csv-field-values.test.js b/frontend/tests/utils/csv-field-values.test.js new file mode 100644 index 0000000000..027262e2be --- /dev/null +++ b/frontend/tests/utils/csv-field-values.test.js @@ -0,0 +1,68 @@ +import { + extractCsvColumnValues, + mergeCsvFormFields, + normalizeAutoCompleteValues, +} from '../../src/utils/csv-field-values' + +describe('csv-field-values', () => { + describe('extractCsvColumnValues', () => { + it('extracts values for a matching column (case-insensitive, trimmed header)', () => { + const rows = [ + { userPrincipalName: 'a@contoso.com' }, + { ' UserPrincipalName ': 'b@contoso.com' }, + { other: 'skip' }, + ] + expect(extractCsvColumnValues(rows, 'userPrincipalName')).toEqual([ + 'a@contoso.com', + 'b@contoso.com', + ]) + }) + + it('returns empty when the column header is missing', () => { + const rows = [{ 'User Principal Name': 'a@contoso.com' }] + expect(extractCsvColumnValues(rows, 'userPrincipalName')).toEqual([]) + }) + }) + + describe('normalizeAutoCompleteValues', () => { + it('flattens {label,value} objects to string values', () => { + expect( + normalizeAutoCompleteValues([ + { label: 'Alice', value: 'id-1' }, + { label: 'Bob', value: 'id-2' }, + ]) + ).toEqual(['id-1', 'id-2']) + }) + }) + + describe('mergeCsvFormFields', () => { + const fields = [ + { type: 'autoComplete', name: 'users', csvColumn: 'userPrincipalName' }, + ] + + it('merges autocomplete and CSV values and drops the companion field', () => { + const merged = mergeCsvFormFields( + { + users: [{ label: 'Alice', value: 'id-1' }], + users__csv: [{ userPrincipalName: 'csv@contoso.com' }], + }, + fields + ) + expect(merged).toEqual({ + users: ['id-1', 'csv@contoso.com'], + }) + }) + + it('yields an empty users array when CSV rows lack the configured column', () => { + const merged = mergeCsvFormFields( + { + users: [], + users__csv: [{ 'User Principal Name': 'a@contoso.com' }], + }, + fields + ) + expect(merged.users).toEqual([]) + expect(merged.users__csv).toBeUndefined() + }) + }) +}) diff --git a/frontend/tests/utils/resolve-row-templates.test.js b/frontend/tests/utils/resolve-row-templates.test.js new file mode 100644 index 0000000000..e89ef97dac --- /dev/null +++ b/frontend/tests/utils/resolve-row-templates.test.js @@ -0,0 +1,121 @@ +import { + getNestedValue, + resolveRowTemplates, + attachParentRow, + getRowTenant, +} from '../../src/utils/resolve-row-templates' + +const row = { + id: 'abc-123', + displayName: 'Finance', + siteId: 'site-1', + nested: { mail: 'finance@contoso.com' }, +} + +describe('getNestedValue', () => { + it('reads a top-level field', () => { + expect(getNestedValue(row, 'id')).toBe('abc-123') + }) + + it('reads a dotted path', () => { + expect(getNestedValue(row, 'nested.mail')).toBe('finance@contoso.com') + }) + + it('returns undefined for a missing path', () => { + expect(getNestedValue(row, 'missing.path')).toBeUndefined() + }) +}) + +describe('resolveRowTemplates', () => { + it('replaces [id] in a string', () => { + expect(resolveRowTemplates('group-members-[id]', row)).toBe( + 'group-members-abc-123' + ) + }) + + it('replaces a nested path', () => { + expect(resolveRowTemplates('mail=[nested.mail]', row)).toBe( + 'mail=finance@contoso.com' + ) + }) + + it('leaves an unmatched token in place', () => { + expect(resolveRowTemplates('x-[unknown]', row)).toBe('x-[unknown]') + }) + + it('walks objects used as api.data', () => { + expect( + resolveRowTemplates( + { someId: '[id]', extra: true, siteId: '[siteId]' }, + row + ) + ).toEqual({ someId: 'abc-123', extra: true, siteId: 'site-1' }) + }) + + it('leaves booleans and numbers alone', () => { + expect(resolveRowTemplates(true, row)).toBe(true) + expect(resolveRowTemplates(999, row)).toBe(999) + }) + + it('walks arrays', () => { + expect(resolveRowTemplates(['[id]', 1], row)).toEqual(['abc-123', 1]) + }) +}) + +describe('attachParentRow', () => { + const parentRow = { id: 'group-1', displayName: 'Finance' } + + it('attaches the opening row as parent', () => { + expect(attachParentRow({ id: 'member-1' }, parentRow)).toEqual({ + id: 'member-1', + parent: parentRow, + }) + }) + + it('leaves a row unchanged when there is no parent', () => { + const child = { id: 'member-1' } + expect(attachParentRow(child, undefined)).toBe(child) + }) + + it('maps arrays', () => { + expect(attachParentRow([{ id: 'a' }, { id: 'b' }], parentRow)).toEqual([ + { id: 'a', parent: parentRow }, + { id: 'b', parent: parentRow }, + ]) + }) + + it('chains an existing parent when the opening row is not nested', () => { + const child = { id: 'member-1', parent: { id: 'api-parent' } } + expect(attachParentRow(child, parentRow).parent).toEqual({ + id: 'group-1', + displayName: 'Finance', + parent: { id: 'api-parent' }, + }) + }) + + it('keeps a nested table chain instead of overwriting it', () => { + const nestedParent = { id: 'member-1', parent: parentRow } + const grandchild = { id: 'license-1' } + expect(attachParentRow(grandchild, nestedParent).parent).toBe(nestedParent) + }) +}) + +describe('getRowTenant', () => { + it('returns the current tenant outside AllTenants', () => { + expect( + getRowTenant({ Tenant: 'other.com' }, 'contoso.com') + ).toBe('contoso.com') + }) + + it('prefers the row tenant in AllTenants', () => { + expect(getRowTenant({ Tenant: 'child.com' }, 'AllTenants')).toBe( + 'child.com' + ) + }) + + it('falls back to the nested parent tenant', () => { + expect( + getRowTenant({ parent: { Tenant: 'parent.com' } }, 'AllTenants') + ).toBe('parent.com') + }) +}) From ba6091ccb76dc76d0af5bff50d6880f1a898a2a8 Mon Sep 17 00:00:00 2001 From: Logan Cook <2997336+MWG-Logan@users.noreply.github.com> Date: Thu, 20 Aug 2026 14:51:53 -0400 Subject: [PATCH 213/226] fix(standards): clarify user submissions drift rule state Keep the built-in Outlook report-button state separate from the optional custom destination rule in the comparison payload, and cover enabled, custom-destination, and disabled configurations. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Invoke-CIPPStandardUserSubmissions.ps1 | 4 +- ...voke-CIPPStandardUserSubmissions.Tests.ps1 | 144 ++++++++++++++++++ 2 files changed, 146 insertions(+), 2 deletions(-) create mode 100644 backend/Tests/Standards/Invoke-CIPPStandardUserSubmissions.Tests.ps1 diff --git a/backend/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardUserSubmissions.ps1 b/backend/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardUserSubmissions.ps1 index 7c5c3d8c8e..13f53548e5 100644 --- a/backend/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardUserSubmissions.ps1 +++ b/backend/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardUserSubmissions.ps1 @@ -237,7 +237,7 @@ function Invoke-CIPPStandardUserSubmissions { ReportJunkAddresses = @($PolicyState.ReportJunkAddresses) ReportNotJunkAddresses = @($PolicyState.ReportNotJunkAddresses) ReportPhishAddresses = @($PolicyState.ReportPhishAddresses) - RuleState = @{ + CustomDestinationRule = @{ State = if ($RuleState.length -eq 0) { 'Disabled' } else { $RuleState.State } SentTo = if ($RuleState.length -eq 0) { $null } else { @($RuleState.SentTo) } } @@ -250,7 +250,7 @@ function Invoke-CIPPStandardUserSubmissions { ReportJunkAddresses = @(if (-not [string]::IsNullOrWhiteSpace($Email)) { $Email }) ReportNotJunkAddresses = @(if (-not [string]::IsNullOrWhiteSpace($Email)) { $Email }) ReportPhishAddresses = @(if (-not [string]::IsNullOrWhiteSpace($Email)) { $Email }) - RuleState = if ([string]::IsNullOrWhiteSpace($Email) -or $state -eq 'disable') { + CustomDestinationRule = if ([string]::IsNullOrWhiteSpace($Email) -or $state -eq 'disable') { @{ State = 'Disabled' SentTo = $null diff --git a/backend/Tests/Standards/Invoke-CIPPStandardUserSubmissions.Tests.ps1 b/backend/Tests/Standards/Invoke-CIPPStandardUserSubmissions.Tests.ps1 new file mode 100644 index 0000000000..fa625aa7d5 --- /dev/null +++ b/backend/Tests/Standards/Invoke-CIPPStandardUserSubmissions.Tests.ps1 @@ -0,0 +1,144 @@ +# Pester tests for Invoke-CIPPStandardUserSubmissions. +# +# The comparison payload contains two related but distinct states: +# EnableReportToMicrosoft controls the built-in Outlook Report button, while +# CustomDestinationRule describes the optional rule that sends submissions to a custom address. + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $StandardPath = Get-ChildItem -Path (Join-Path $RepoRoot 'Modules') -Recurse -Filter 'Invoke-CIPPStandardUserSubmissions.ps1' -File -ErrorAction SilentlyContinue | + Select-Object -First 1 -ExpandProperty FullName + if (-not $StandardPath) { throw 'Could not locate Invoke-CIPPStandardUserSubmissions.ps1 under Modules/' } + + function Test-CIPPStandardLicense { [CmdletBinding()] param($StandardName, $TenantFilter, $Preset, [switch]$SkipLog) } + function Get-CIPPTextReplacement { [CmdletBinding()] param($TenantFilter, $Text, [switch]$EscapeForJson) } + function New-ExoRequest { [CmdletBinding()] param($tenantid, $cmdlet, $cmdParams, [switch]$UseSystemMailbox) } + function Write-LogMessage { [CmdletBinding()] param($API, $tenant, $message, $sev, $LogData) } + function Write-StandardsAlert { [CmdletBinding()] param($message, $object, $tenant, $standardName, $standardId) } + function Set-CIPPStandardsCompareField { + [CmdletBinding()] + param($FieldName, $FieldValue, $CurrentValue, $ExpectedValue, $TenantFilter) + } + function Add-CIPPBPAField { [CmdletBinding()] param($FieldName, $FieldValue, $StoreAs, $Tenant) } + function Get-NormalizedError { [CmdletBinding()] param($Message) $Message } + function Get-CippException { [CmdletBinding()] param($Exception) @{ NormalizedError = $Exception.Exception.Message } } + + . $StandardPath + + $script:Tenant = 'contoso.onmicrosoft.com' +} + +Describe 'Invoke-CIPPStandardUserSubmissions comparison payload' { + BeforeEach { + $script:compareFields = [System.Collections.Generic.List[object]]::new() + $script:policyState = [pscustomobject]@{ + EnableReportToMicrosoft = $true + ReportJunkToCustomizedAddress = $false + ReportNotJunkToCustomizedAddress = $false + ReportPhishToCustomizedAddress = $false + ReportJunkAddresses = @() + ReportNotJunkAddresses = @() + ReportPhishAddresses = @() + } + $script:ruleState = @() + + Mock -CommandName Test-CIPPStandardLicense -MockWith { $true } + Mock -CommandName Get-CIPPTextReplacement -MockWith { param($TenantFilter, $Text) $Text } + Mock -CommandName New-ExoRequest -MockWith { + param($tenantid, $cmdlet, $cmdParams) + if ($cmdlet -eq 'Get-ReportSubmissionPolicy') { return $script:policyState } + if ($cmdlet -eq 'Get-ReportSubmissionRule') { return $script:ruleState } + } + Mock -CommandName Write-LogMessage -MockWith { } + Mock -CommandName Write-StandardsAlert -MockWith { } + Mock -CommandName Add-CIPPBPAField -MockWith { } + Mock -CommandName Set-CIPPStandardsCompareField -MockWith { + param($FieldName, $FieldValue, $CurrentValue, $ExpectedValue, $TenantFilter) + $script:compareFields.Add([pscustomobject]@{ + Field = $FieldName + Current = $CurrentValue + Expected = $ExpectedValue + Tenant = $TenantFilter + }) + } + } + + It 'shows built-in reporting enabled and the custom rule disabled when no email is configured' { + $script:ruleState = @( + [pscustomobject]@{ + State = 'Enabled' + SentTo = 'old-destination@contoso.com' + } + ) + + Invoke-CIPPStandardUserSubmissions -Tenant $script:Tenant -Settings @{ + state = 'enable' + email = $null + report = $true + } + + $Comparison = $script:compareFields[0] + $Comparison.Expected.EnableReportToMicrosoft | Should -BeTrue + $Comparison.Expected.CustomDestinationRule.State | Should -Be 'Disabled' + $Comparison.Expected.CustomDestinationRule.SentTo | Should -BeNullOrEmpty + $Comparison.Current.CustomDestinationRule.State | Should -Be 'Enabled' + $Comparison.Current.CustomDestinationRule.SentTo | Should -Be 'old-destination@contoso.com' + $Comparison.Expected.PSObject.Properties.Name | Should -Not -Contain 'RuleState' + $Comparison.Current.PSObject.Properties.Name | Should -Not -Contain 'RuleState' + } + + It 'shows the enabled custom destination rule when an email is configured' { + $Email = 'security@contoso.com' + $script:policyState = [pscustomobject]@{ + EnableReportToMicrosoft = $true + ReportJunkToCustomizedAddress = $true + ReportNotJunkToCustomizedAddress = $true + ReportPhishToCustomizedAddress = $true + ReportJunkAddresses = $Email + ReportNotJunkAddresses = $Email + ReportPhishAddresses = $Email + } + $script:ruleState = [pscustomobject]@{ + State = 'Enabled' + SentTo = $Email + } + + Invoke-CIPPStandardUserSubmissions -Tenant $script:Tenant -Settings @{ + state = 'enable' + email = $Email + report = $true + } + + $Comparison = $script:compareFields[0] + $Comparison.Expected.EnableReportToMicrosoft | Should -BeTrue + $Comparison.Expected.CustomDestinationRule.State | Should -Be 'Enabled' + $Comparison.Expected.CustomDestinationRule.SentTo | Should -Be $Email + $Comparison.Current.CustomDestinationRule.State | Should -Be 'Enabled' + $Comparison.Current.CustomDestinationRule.SentTo | Should -Be $Email + } + + It 'shows both reporting and the custom destination rule disabled when the standard is disabled' { + $script:policyState = [pscustomobject]@{ + EnableReportToMicrosoft = $false + ReportJunkToCustomizedAddress = $false + ReportNotJunkToCustomizedAddress = $false + ReportPhishToCustomizedAddress = $false + ReportJunkAddresses = @() + ReportNotJunkAddresses = @() + ReportPhishAddresses = @() + } + + Invoke-CIPPStandardUserSubmissions -Tenant $script:Tenant -Settings @{ + state = 'disable' + email = $null + report = $true + } + + $Comparison = $script:compareFields[0] + $Comparison.Expected.EnableReportToMicrosoft | Should -BeFalse + $Comparison.Expected.CustomDestinationRule.State | Should -Be 'Disabled' + $Comparison.Expected.CustomDestinationRule.SentTo | Should -BeNullOrEmpty + $Comparison.Current.EnableReportToMicrosoft | Should -BeFalse + $Comparison.Current.CustomDestinationRule.State | Should -Be 'Disabled' + } +} From cace79c764a28dd4b88aec7a682ab9174229aea4 Mon Sep 17 00:00:00 2001 From: Brian Simpson <50429915+bmsimp@users.noreply.github.com> Date: Thu, 20 Aug 2026 14:50:52 -0500 Subject: [PATCH 214/226] docs(hudu): list all always-included Magic Dash portal links The Settings hint named only Microsoft 365 and Entra as the portal links always written to the Magic Dash card. Exchange, Intune, Teams and Azure were already always included, and SharePoint was added in 86c7aaf3. Name each one as it renders on the card. Co-Authored-By: Claude Opus 5 (1M context) --- docs/user-documentation/cipp/integrations/hudu.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/user-documentation/cipp/integrations/hudu.md b/docs/user-documentation/cipp/integrations/hudu.md index 7d6e348116..758879659b 100644 --- a/docs/user-documentation/cipp/integrations/hudu.md +++ b/docs/user-documentation/cipp/integrations/hudu.md @@ -30,7 +30,7 @@ User and device information is written to a rich text field named **Microsoft 36 | Reschedule next sync date | Sets a future date to delay the next scheduled synchronisation, which is useful for keeping the first run outside business hours. Leave blank to sync at the next scheduled time. | {% hint style="info" %} -The Microsoft 365 and Entra portal links are always included. The Partner Center, Defender and Compliance links are optional because not every technician has access to them. +The M365 Admin Portal, Exchange Admin Portal, Entra Portal, Intune, Teams Portal, SharePoint Portal, and Azure Portal links are always included. The Partner Center, Defender, and Compliance links are optional because not every technician has access to them. {% endhint %} ## Obtaining an API Key in Hudu From 21081565337c8403ac83e36db6b42fa60f478ee2 Mon Sep 17 00:00:00 2001 From: Brian Simpson <50429915+bmsimp@users.noreply.github.com> Date: Thu, 20 Aug 2026 14:59:19 -0500 Subject: [PATCH 215/226] docs(groups): document member and owner sub-tables The Show Members and Show Owners buttons were replaced by Members and Owners columns that open the group's list in a dialog, so the Action Buttons section documented two buttons that no longer exist. - Rewrite Action Buttons to cover Add Group and Deploy Group Template - Add Members and Owners rows to Table Details, and drop the stale paragraph about expansion columns and the centrally covered Tenant and Cache Timestamp columns - Add a Members and Owners section covering both dialogs: their columns, row actions with bulk availability, and the Add Members and Add Owners buttons Co-Authored-By: Claude Opus 5 (1M context) --- .../identity/administration/groups/README.md | 71 ++++++++++++++----- 1 file changed, 52 insertions(+), 19 deletions(-) diff --git a/docs/user-documentation/identity/administration/groups/README.md b/docs/user-documentation/identity/administration/groups/README.md index bacdd24208..6a8e0b5b8a 100644 --- a/docs/user-documentation/identity/administration/groups/README.md +++ b/docs/user-documentation/identity/administration/groups/README.md @@ -8,28 +8,16 @@ The Groups page lists every group in the tenant and is where group membership, m ## Action Buttons -
    - -Show Members - -Adds a column listing the members of each group. You may need to select the column from the table's column selector as well. - -Showing members and showing owners are mutually exclusive, because Graph accepts only one expansion per request, so turning one on turns the other off. Both buttons are hidden while the table is showing cached data. - -
    - -
    - -Show Owners - -Adds a column listing the owners of each group, under the same one-at-a-time restriction as Show Members. - -
    +**Add Group** creates a group in the selected tenant, and **Deploy Group Template** applies a saved template to one or more tenants. {% content-ref url="add.md" %} [add.md](add.md) {% endcontent-ref %} +{% content-ref url="../group-templates/deploy.md" %} +[deploy.md](../group-templates/deploy.md) +{% endcontent-ref %} + {% content-ref url="edit.md" %} [edit.md](edit.md) {% endcontent-ref %} @@ -50,13 +38,58 @@ Adds a column listing the owners of each group, under the same one-at-a-time res | On Premises Sam Account Name | The account name the group carries when it is synchronised from on-premises Active Directory. | | Membership Rule | The rule that decides membership, for a dynamic group. | | On Premises Sync Enabled | Whether the group is synchronised from on-premises Active Directory. | - -Showing members or owners adds a further column listing them. In cached mode a **Cache Timestamp** column records when the cache was last refreshed, and a **Tenant** column is added when the tenant selector is set to All Tenants. +| Members | The group's members, reached through a **View members** button. | +| Owners | The group's owners, reached through a **View owners** button. | {% hint style="info" %} Group Type is composed by CIPP rather than returned by Graph, which reports the same information across the `groupTypes`, `mailEnabled` and `securityEnabled` properties. A group is Microsoft 365 when its `groupTypes` include `Unified`, Mail-Enabled Security when it is both mail and security enabled, Security when it is security enabled alone, and a Distribution List when it is mail enabled alone. This matters when comparing against Graph output or the Entra portal, where no single equivalent field exists. {% endhint %} +## Members and Owners + +The **Members** and **Owners** columns each open the group's list in a dialog, so membership can be reviewed and changed without leaving the Groups page. Selecting rows inside a dialog makes the removal action available across all of them at once. While the table is showing cached data the two columns list user principal names as text instead, and the buttons are not offered. + +
    + +View members + +Opens a dialog headed with the group's name, listing everything that belongs to it. A group can hold nested groups, devices and service principals as well as users, so the list is not always people. + +| Column | Description | +| ------------------- | --------------------------------------------------------------------------------- | +| Display Name | The member's name. | +| User Principal Name | The member's sign-in name, where it has one. | +| Mail | The member's email address, where it has one. | +| Type | The kind of directory object the member is, for example a user or a nested group. | + +
    ActionDescriptionBulk Action Available
    View UserOpens the user page for the member. Greyed out for members that are not users.false
    View GroupOpens the group.md page for a nested group. Greyed out for members that are not groups.false
    Remove MemberTakes the member out of the group. Greyed out when the group's membership is set by a rule, because a dynamic group's membership can only be changed by editing the rule.true
    + +**Add Members** takes one or more users or groups, picked from the tenant list or uploaded as a CSV with a `userPrincipalName` column. It is not offered for a dynamic group. + +
    + +
    + +View owners + +Opens a dialog headed with the group's name, listing the people who own it. + +| Column | Description | +| ------------------- | --------------------------------------------- | +| Display Name | The owner's name. | +| User Principal Name | The owner's sign-in name. | +| Mail | The owner's email address, where they have one. | + +
    ActionDescriptionBulk Action Available
    View UserOpens the user page for the owner.false
    Remove OwnerTakes the owner off the group.true
    + +**Add Owners** takes one or more users, picked from the tenant list or uploaded as a CSV with a `userPrincipalName` column. Anyone already listed as an owner is reported back as skipped, and the rest of the selection is still added. + +{% hint style="info" %} +Owners of a Distribution List or Mail-Enabled Security group are held by Exchange rather than Entra, so a change made here appears as the group's **Managed By** list in Exchange Online. +{% endhint %} + +
    + ## Table Actions
    ActionDescriptionBulk Action Available
    View GroupOpens the group.md page for the group, covering its membership, owners and settings.false
    Edit GroupOpens the edit.md page, where membership, owners and group settings can be changed.false
    Add MemberAdds one or more users to the group. Pick them from the tenant user list, or drop a CSV file with a userPrincipalName column to add members in bulk. Selecting several groups adds the same users to each of them.true
    Set Global Address List VisibilityHides the group from the Global Address List or shows it again. Has no effect on a group synchronised from on-premises Active Directory.true
    Only allow messages from people inside the organisationRequires sender authentication, so the group only accepts mail from within the tenant. Has no effect on a group synchronised from on-premises Active Directory.true
    Allow messages from people inside and outside the organisationDrops the sender authentication requirement, so the group accepts mail from external senders as well. Has no effect on a group synchronised from on-premises Active Directory.true
    Set Source of AuthoritySwitches the group between Cloud Managed and On-Premises Managed. Greyed out for cloud-native groups that have never been synchronised, and a move back to on-premises takes until the next sync cycle to appear.true
    Create template based on groupCreates a reusable group template from this group, copying its name, description, type, membership rule, alias and external sender setting.true
    Create Team from GroupTurns the group into a Microsoft Teams team, with the member, messaging and fun settings set in the dialog. Greyed out for anything other than a Microsoft 365 group.true
    Delete GroupDeletes the group.true
    More InfoOpens the Extended Info flyout with the full details for the selected row.false
    From e4c2877f186e299f1e2f19c4830434d0f7466d67 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Fri, 21 Aug 2026 10:07:22 +0800 Subject: [PATCH 216/226] fix(sharing-links): resume throttled scans Add drive-level requeue handling with a persisted RequeueCount so mid-scan throttling resumes from the saved checkpoint instead of restarting or incorrectly completing a drive; stop requeuing after 6 attempts and then fail normally. Also switch principal-mode baseline detection to the dominant PrincipalCount from the first items page (instead of root permissions), preventing inflated root ACL counts from flagging entire libraries; extend resume tests to cover both baseline derivation and throttle requeue/budget behavior. --- ...Push-DBCacheSharePointSiteSharingLinks.ps1 | 62 +++++++++++-- .../SharePointSharingLinks.Resume.Tests.ps1 | 88 +++++++++++++++++++ 2 files changed, 141 insertions(+), 9 deletions(-) diff --git a/backend/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/SharePoint Sharing/Push-DBCacheSharePointSiteSharingLinks.ps1 b/backend/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/SharePoint Sharing/Push-DBCacheSharePointSiteSharingLinks.ps1 index 7f9b9ca82a..4910fbbbf6 100644 --- a/backend/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/SharePoint Sharing/Push-DBCacheSharePointSiteSharingLinks.ps1 +++ b/backend/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/SharePoint Sharing/Push-DBCacheSharePointSiteSharingLinks.ps1 @@ -356,6 +356,7 @@ function Push-DBCacheSharePointSiteSharingLinks { $Drive = [PSCustomObject]@{ id = [string]$Item.DriveId; name = [string]$Item.DriveName } $DriveKeySegment = ConvertTo-CIPPSharingLinksKeySegment -Value "$($Drive.id)" $CheckpointRowKey = "chk-$SiteKeySegment~$DriveKeySegment" + $RequeueCount = [int]($Item.RequeueCount ?? 0) $Stopwatch = [System.Diagnostics.Stopwatch]::StartNew() function Get-DriveCheckpoint { @@ -408,6 +409,21 @@ function Push-DBCacheSharePointSiteSharingLinks { if ($DoneDrives -ge [int]$DrivesRow.DriveCount) { Complete-Site } } + # Re-dispatches this drive task to resume from its checkpoint. Used when the timebox is + # spent and when enumeration throttles out mid-drive: either way the checkpoint holds the + # last unpersisted page, so the fresh task loses nothing. + function Invoke-DriveRequeue { + param([int]$NextRequeueCount = $RequeueCount) + $ResumeItem = [PSCustomObject]@{} + foreach ($Property in $Item.PSObject.Properties) { $ResumeItem | Add-Member -NotePropertyName $Property.Name -NotePropertyValue $Property.Value -Force } + $ResumeItem | Add-Member -NotePropertyName 'RequeueCount' -NotePropertyValue $NextRequeueCount -Force + $null = Start-CIPPOrchestrator -InputObject ([PSCustomObject]@{ + Batch = @($ResumeItem) + OrchestratorName = "SharingLinksResume_$($TenantFilter)_$([guid]::NewGuid().ToString('N').Substring(0, 8))" + SkipLog = $true + }) + } + # Checkpoints the position, re-dispatches this drive task and returns $true when the timebox # is spent. The platform kills tasks at Worker:BgTimeoutSeconds WITHOUT retrying them, so a # long drive must yield on its own; the fresh task resumes from the checkpoint. @@ -416,11 +432,24 @@ function Push-DBCacheSharePointSiteSharingLinks { if ($Stopwatch.Elapsed.TotalSeconds -lt $TimeboxSeconds) { return $false } Save-DriveCheckpoint -State $State Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Sharing links: timebox reached on drive '$($Drive.name)' ($SiteUrl); requeueing to resume" -sev Debug - $null = Start-CIPPOrchestrator -InputObject ([PSCustomObject]@{ - Batch = @($Item) - OrchestratorName = "SharingLinksResume_$($TenantFilter)_$([guid]::NewGuid().ToString('N').Substring(0, 8))" - SkipLog = $true - }) + Invoke-DriveRequeue + return $true + } + + # Requeues instead of failing when enumeration throttles out mid-drive, so the resumed task + # continues from the checkpoint rather than restarting the drive from page one - on a large + # drive a restart could retread the same pages every scan and never converge. Returns $false + # once the requeue budget is spent (or for non-throttle errors) so the caller fails the + # drive normally. + function Invoke-ThrottleRequeue { + param([string]$ErrorMessage) + if ($ErrorMessage -notmatch 'throttl|too many requests|429') { return $false } + if ($RequeueCount -ge 6) { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Sharing links: drive '$($Drive.name)' ($SiteUrl) still throttled after $RequeueCount resumes; giving up this scan" -sev Warning + return $false + } + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Sharing links: throttled mid-scan on drive '$($Drive.name)' ($SiteUrl); requeueing to resume from checkpoint (attempt $($RequeueCount + 1))" -sev Info + Invoke-DriveRequeue -NextRequeueCount ($RequeueCount + 1) return $true } @@ -454,10 +483,6 @@ function Push-DBCacheSharePointSiteSharingLinks { if (-not $Uri) { $List = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/drives/$($Drive.id)/list?`$select=id" -tenantid $TenantFilter -asapp $true if (-not $List.id) { throw 'drive has no backing list' } - # Baseline = the number of principals an item inherits when nothing was ever - # shared on it. The drive root's permission objects are exactly that set. - $RootPermissions = @(New-GraphGetRequest -uri "https://graph.microsoft.com/beta/drives/$($Drive.id)/root/permissions?`$select=id" -tenantid $TenantFilter -asapp $true) - $Baseline = [int]$RootPermissions.Count $Uri = "https://graph.microsoft.com/beta/sites/$SiteId/lists/$($List.id)/items?`$top=999&`$select=id&`$expand=fields(`$select=PrincipalCount)" } @@ -465,6 +490,16 @@ function Push-DBCacheSharePointSiteSharingLinks { while ($Uri) { $Page = New-GraphGetRequest -uri $Uri -tenantid $TenantFilter -asapp $true -noPagination $true -SkipValueExtraction + # Baseline = the dominant PrincipalCount on the drive's first page: the + # inherited count every unshared item carries. The drive root's own + # permission list is NOT a safe proxy - system entries (Limited Access, + # claims principals) inflate it above the items' inherited count, inverting + # the filter and flagging an entire library for permission reads. + if ($null -eq $Baseline) { + $Dominant = @($Page.value) | Group-Object { [int]$_.fields.PrincipalCount } | Sort-Object Count -Descending | Select-Object -First 1 + $Baseline = if ($Dominant) { [int]$Dominant.Name } else { -1 } + } + # An item whose principal count deviates from the inherited baseline carries # extra (or unusual) role assignments; the permission read is the ground truth # that filters inherited-only false positives back out. @@ -529,6 +564,10 @@ function Push-DBCacheSharePointSiteSharingLinks { # Site locked mid-scan: links are inactive, so leave the drive state stale # for finalisation to prune rather than protecting this drive's rows. Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Sharing links: drive '$($Drive.name)' on '$SiteUrl' is locked; leaving its rows for pruning" -sev Info + } elseif (Invoke-ThrottleRequeue -ErrorMessage $_.Exception.Message) { + # Requeued to resume from the checkpoint; this task must not complete the + # drive or touch its state. + return @() } else { Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Sharing links: failed scanning drive '$($Drive.name)' on '$SiteUrl': $($_.Exception.Message)" -sev Warning # A current LastScanId with an empty token both protects this drive's cached @@ -584,6 +623,11 @@ function Push-DBCacheSharePointSiteSharingLinks { Complete-Drive return @() } + if (Invoke-ThrottleRequeue -ErrorMessage $ErrorMessage) { + # Requeued to resume from the checkpoint; this task must not complete the + # drive or touch its state. + return @() + } Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Sharing links: failed scanning drive '$($Drive.name)' on '$SiteUrl': $ErrorMessage" -sev Warning $DriveFailed = $true break diff --git a/backend/Tests/DBCache/SharePointSharingLinks.Resume.Tests.ps1 b/backend/Tests/DBCache/SharePointSharingLinks.Resume.Tests.ps1 index 27d71e927e..c6d3e07e90 100644 --- a/backend/Tests/DBCache/SharePointSharingLinks.Resume.Tests.ps1 +++ b/backend/Tests/DBCache/SharePointSharingLinks.Resume.Tests.ps1 @@ -334,6 +334,39 @@ Describe 'Per-drive sharing-links scan' { } } + Context 'Principal-mode baseline detection' { + It 'derives the baseline from the dominant item count, not the inflated root permission list' { + $ScanId = 'scan-baseline-1' + Initialize-TestScan -ScanId $ScanId -TotalSites 1 + # Root carries system entries (Limited Access etc.) that items never inherit: a + # root-derived baseline of 5 would invert the filter and flag the whole library. + $script:GraphGetHandler = { + param($Uri) + if ($Uri -match '/sites/[^/]+/drives\?') { return @([pscustomobject]@{ id = 'b!driveone'; name = 'Documents'; webUrl = 'https://contoso.sharepoint.com/sites/one/Shared%20Documents' }) } + if ($Uri -match '/drives/b!driveone/list\?') { return [pscustomobject]@{ id = 'list1' } } + if ($Uri -match '/drives/b!driveone/root/permissions') { return @(1..5 | ForEach-Object { [pscustomobject]@{ id = "g$_" } }) } + if ($Uri -match '/lists/list1/items\?') { + return [pscustomobject]@{ + value = @( + [pscustomobject]@{ id = '21'; fields = [pscustomobject]@{ PrincipalCount = 4 } } + [pscustomobject]@{ id = '22'; fields = [pscustomobject]@{ PrincipalCount = 4 } } + [pscustomobject]@{ id = '23'; fields = [pscustomobject]@{ PrincipalCount = 4 } } + [pscustomobject]@{ id = '24'; fields = [pscustomobject]@{ PrincipalCount = 5 } } # the linked one + ) + } + } + if ($Uri -match 'token=latest') { return New-DeltaPage -DeltaLink 'https://graph.microsoft.com/beta/drives/b!driveone/root/delta?token=captured' } + throw "Unrouted GET: $Uri" + } + + Invoke-SiteAndDrives -SiteItem (New-SiteItem -ScanId $ScanId) + + $LinkRows = @((Get-FakeTableRows -TableName 'CippReportingDB') | Where-Object { $_.RowKey -like 'SharePointSharingLinks-b!driveone_01DRV*' }) + $LinkRows.Count | Should -Be 1 + $LinkRows[0].RowKey | Should -BeLike '*01DRV24*' + } + } + Context 'Principal-mode scan with dropped permission reads' { It 'keeps existing rows and defers the sweep when batch reads are throttled away' { $ScanId = 'scan-principal-drop-1' @@ -528,6 +561,61 @@ Describe 'Per-drive sharing-links scan' { } } + Context 'throttle mid-scan' { + BeforeEach { + # Page 1 succeeds; page 2 throttles out even after the helper's own retries. + $script:GraphGetHandler = { + param($Uri) + if ($Uri -match '/sites/[^/]+/drives\?') { return @([pscustomobject]@{ id = 'b!driveone'; name = 'Documents'; webUrl = 'https://contoso.sharepoint.com/sites/one/Shared%20Documents' }) } + if ($Uri -match 'token=page2') { throw 'The request has been throttled' } + if ($Uri -match '/root/delta') { + return New-DeltaPage -Items @( + [pscustomobject]@{ id = '01ITEMA'; name = 'a.docx'; shared = [pscustomobject]@{ scope = 'anonymous' } } + ) -NextLink 'https://graph.microsoft.com/beta/drives/b!driveone/root/delta?token=page2' + } + throw "Unrouted GET: $Uri" + } + } + + It 'requeues from the checkpoint instead of failing the drive' { + $ScanId = 'scan-throttle-1' + Initialize-TestScan -ScanId $ScanId -TotalSites 1 + + Push-DBCacheSharePointSiteSharingLinks -Item (New-SiteItem -ScanId $ScanId -IsPersonalSite $true) + $DriveTask = @($script:Orchestrations[0].Batch)[0] + Push-DBCacheSharePointSiteSharingLinks -Item $DriveTask + + # Page 1 persisted, checkpoint points at page 2, and a resume task carries the + # incremented requeue count; the drive is neither completed nor failed. + Get-CacheRowKeys | Should -Contain 'SharePointSharingLinks-b!driveone_01ITEMA_perm-01ITEMA' + $Checkpoint = (Get-FakeTableRows -TableName 'CippSharingLinksState') | Where-Object { $_.RowKey -like 'chk-*' } + $Checkpoint.StateJson | Should -BeLike '*token=page2*' + $Requeued = @($script:Orchestrations | Where-Object { $_.OrchestratorName -like 'SharingLinksResume_*' }) + $Requeued.Count | Should -Be 1 + [int]@($Requeued[0].Batch)[0].RequeueCount | Should -Be 1 + (Get-FakeTableRows -TableName 'CippSharingLinksState') | Where-Object { $_.RowKey -like 'ddone-*' } | Should -BeNullOrEmpty + (Get-CIPPSharingLinksDriveState -TenantFilter 'contoso.com' -DriveId 'b!driveone') | Should -BeNullOrEmpty + } + + It 'fails the drive normally once the requeue budget is spent' { + $ScanId = 'scan-throttle-2' + Initialize-TestScan -ScanId $ScanId -TotalSites 1 + + Push-DBCacheSharePointSiteSharingLinks -Item (New-SiteItem -ScanId $ScanId -IsPersonalSite $true) + $DriveTask = @($script:Orchestrations[0].Batch)[0] + $DriveTask | Add-Member -NotePropertyName RequeueCount -NotePropertyValue 6 -Force + Push-DBCacheSharePointSiteSharingLinks -Item $DriveTask + + # Budget exhausted: no further resume, drive state written (rows protected, next + # scan full), drive and site complete so the scan can finalise. + @($script:Orchestrations | Where-Object { $_.OrchestratorName -like 'SharingLinksResume_*' }).Count | Should -Be 0 + $DriveState = Get-CIPPSharingLinksDriveState -TenantFilter 'contoso.com' -DriveId 'b!driveone' + $DriveState.LastScanId | Should -Be $ScanId + [string]$DriveState.DeltaLink | Should -BeNullOrEmpty + Get-CacheRowKeys | Should -Contain 'SharePointSharingLinks-Count' + } + } + Context 'superseded scans and duplicate dispatch' { It 'exits without scanning when a newer scan owns the state' { Initialize-TestScan -ScanId 'scan-new' -TotalSites 5 From f532448d867c1881b54ac06e4ec8ac3ee80c18fa Mon Sep 17 00:00:00 2001 From: Brian Simpson <50429915+bmsimp@users.noreply.github.com> Date: Thu, 20 Aug 2026 21:35:11 -0500 Subject: [PATCH 217/226] docs(quarantine): document the Files and Teams tabs The quarantine route gained child routes for the Files and Teams Messages tabs, so the flat page has moved to a section README and each tab now has its own page, matching the route path the in-app documentation link is built from. - move quarantine.md to quarantine/README.md and cover the Email tab - add quarantine/files.md and quarantine/teams.md for the reduced action set those tabs offer - correct the flyout section: it opens from More Info, not a row click, and it now lists the URLs and Attachments sections - reorder Table Actions to match the row menu, and document Block Sender, Download Message, View Message Headers and Submit to Microsoft - add the three SUMMARY.md nav entries Renaming quarantine.md changes its published URL, so existing links and bookmarks to /user-documentation/email/administration/quarantine.md will need to follow the new path. Co-Authored-By: Claude Opus 5 (1M context) --- docs/SUMMARY.md | 4 +- .../email/administration/quarantine.md | 52 ------------- .../email/administration/quarantine/README.md | 76 +++++++++++++++++++ .../email/administration/quarantine/files.md | 39 ++++++++++ .../email/administration/quarantine/teams.md | 39 ++++++++++ 5 files changed, 157 insertions(+), 53 deletions(-) delete mode 100644 docs/user-documentation/email/administration/quarantine.md create mode 100644 docs/user-documentation/email/administration/quarantine/README.md create mode 100644 docs/user-documentation/email/administration/quarantine/files.md create mode 100644 docs/user-documentation/email/administration/quarantine/teams.md diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index af9e0f4cb3..982fb14fca 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -327,7 +327,9 @@ * [Contact Templates](user-documentation/email/administration/contacts-template/README.md) * [Add Contact Template](user-documentation/email/administration/contacts-template/add.md) * [Edit Contact Template](user-documentation/email/administration/contacts-template/edit.md) - * [Quarantine](user-documentation/email/administration/quarantine.md) + * [Quarantine](user-documentation/email/administration/quarantine/README.md) + * [Files](user-documentation/email/administration/quarantine/files.md) + * [Teams Messages](user-documentation/email/administration/quarantine/teams.md) * [Restricted Users](user-documentation/email/administration/restricted-users.md) * [Tenant Allow/Block Lists](user-documentation/email/administration/tenant-allow-block-lists.md) * [Tenant Allow/Block List Templates](user-documentation/email/administration/tenant-allow-block-list-templates.md) diff --git a/docs/user-documentation/email/administration/quarantine.md b/docs/user-documentation/email/administration/quarantine.md deleted file mode 100644 index 8d4e8aa3b5..0000000000 --- a/docs/user-documentation/email/administration/quarantine.md +++ /dev/null @@ -1,52 +0,0 @@ -# Quarantine - -This page lists the messages Microsoft Defender for Office 365 and Exchange Online Protection have quarantined for the selected tenant. From here you can inspect a message safely, trace how it arrived, and release, deny, or delete it without going into the Defender portal. - -The page has three tabs, one per quarantine type: - -| Tab | What it shows | -| -------------- | --------------------------------------------------------------------- | -| Email | Quarantined email messages (Exchange Online Protection). | -| Files | Safe Attachments files quarantined from SharePoint/OneDrive. | -| Teams Messages | Quarantined Teams messages. | - -Files and Teams quarantine require Defender for Office 365, so those tabs are empty for tenants without it. Rows in the AllTenants view are tagged with their tenant, and every per-message action is executed against the tenant the message belongs to. - -## Filters - -The Email tab offers release-status and quarantine-reason filters: - -| Filter | Shows | -| ------------ | --------------------------------------------------------------------------------------------- | -| Not Released | Messages still sitting in quarantine with no request against them. | -| Released | Messages that have already been released to their recipients. | -| Requested | Messages a recipient has asked to have released, which are the ones waiting on your decision. | -| High Confidence Phishing / Phishing / Spam / Malware / Bulk / Transport Rule | Messages quarantined for that reason. | - -## Table Details - -The properties returned are for the Exchange Online PowerShell command `Get-QuarantineMessage`. For more information on the command please see the [Microsoft documentation](https://learn.microsoft.com/powershell/module/exchangepowershell/get-quarantinemessage). - -Messages are listed newest first. Choosing AllTenants starts a background job to gather messages from every tenant, so the table reports that it is still loading until that job finishes. - -## Row Details Flyout - -Clicking a row opens a flyout with the message's full details in expandable sections: **Quarantine Details**, **Delivery Details**, **Email Details**, and **Authentication**. When Microsoft Defender for Office 365 Plan 2 is available the delivery and authentication sections are enriched with the analyzed threat data, including per-URL and per-attachment threat verdicts. Without it, CIPP falls back to parsing the message headers and contents, so the sections are populated but individual URL/attachment verdicts are not shown. The actions at the bottom of the flyout are the same as the table actions. - -## Table Actions - -The Email tab offers the full action set: - -
    ActionDescriptionBulk Action Available
    Preview MessageOpens a modal that renders the quarantined message so its contents, headers, and attachments can be inspected safely.false
    View Message HeadersOpens a modal with the raw RFC 5322 message headers.false
    Download Message (.eml)Downloads the quarantined message as a .eml file for offline analysis.false
    View Message TraceOpens a modal with a table of the message's trace history, showing where it was received from and what happened to it at each step.false
    ReleaseReleases the message to all of its recipients. Greyed out on a message that has already been released.true
    Release & Allow SenderReleases the message and adds the sender to the allowed senders list of the anti-spam policy that quarantined it, so future mail from them is not quarantined. Greyed out on a message that has already been released.true
    DenyTurns down a recipient's request to have the message released. Greyed out unless the recipient has actually requested release.true
    Delete from QuarantinePermanently deletes the message from quarantine. Greyed out on a message that has already been released.true
    Submit to Microsoft for ReviewSubmits the quarantined message to Microsoft as a threat submission so they can review its classification. Prompts for a category (clean, spam, phishing, or malware).false
    Block SenderAdds the sender to the tenant's sender block list, optionally without an expiration date or with a note.true
    Open Email Entity in DefenderOpens the message's entity view in Microsoft Defender to surface the full detection details.false
    More InfoOpens the Extended Info flyout with the full details for the selected row.false
    - -The flyout highlights the message ID, recipient address, and quarantine type. - -{% hint style="warning" %} -**Release & Allow Sender** adds a standing allow entry to the anti-spam policy, and that entry stays until it is removed by hand. Use it for a sender that is genuinely being caught wrongly, and prefer a plain **Release** otherwise. -{% endhint %} - -{% hint style="info" %} -**Submit to Microsoft for Review** exports the quarantined message and submits it to Microsoft's threat submission pipeline. Submissions are analysed by Microsoft and can help correct false positives and false negatives. -{% endhint %} - -{% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/email/administration/quarantine/README.md b/docs/user-documentation/email/administration/quarantine/README.md new file mode 100644 index 0000000000..2124779e4c --- /dev/null +++ b/docs/user-documentation/email/administration/quarantine/README.md @@ -0,0 +1,76 @@ +# Quarantine + +This page lists the messages Microsoft Defender for Office 365 and Exchange Online Protection have quarantined for the selected tenant. From here you can inspect a message safely, trace how it arrived, and release, deny, or delete it without going into the Defender portal. + +Quarantine is split into three tabs, one for each type of quarantined item. This page covers the **Email** tab, which is the one you land on. + +| Tab | Contents | +| -------------- | ------------------------------------------------------------------------------------------------------ | +| Email | Quarantined email messages, with the full set of investigation and remediation actions. | +| Files | Files quarantined from SharePoint, OneDrive, and Microsoft Teams. See [files.md](files.md "mention"). | +| Teams Messages | Quarantined Microsoft Teams messages. See [teams.md](teams.md "mention"). | + +## Filters + +| Filter | Shows | +| ------------------------ | ----------------------------------------------------------------------------------------------- | +| Not Released | Messages still sitting in quarantine with no request against them. | +| Released | Messages that have already been released to their recipients. | +| Requested | Messages a recipient has asked to have released, which are the ones waiting on your decision. | +| High Confidence Phishing | Messages quarantined as high confidence phishing. | +| Phishing | Messages quarantined as phishing. | +| Spam | Messages quarantined as spam. | +| Malware | Messages quarantined as malware. | +| Bulk | Messages quarantined as bulk mail. | +| Transport Rule | Messages quarantined by a mail flow rule. | + +A release-status filter and a quarantine-reason filter can be active at the same time. + +## Table Details + +The properties returned are for the Exchange Online PowerShell command `Get-QuarantineMessage`. For more information on the command please see the [Microsoft documentation](https://learn.microsoft.com/powershell/module/exchangepowershell/get-quarantinemessage). + +Messages are listed newest first. Choosing AllTenants starts a background job to gather messages from every tenant, so the table reports that it is still loading until that job finishes. + +## Message Details + +**More Info** opens a flyout holding everything CIPP can establish about the message. The top of the flyout shows the subject, the reason it was quarantined, its release status, and how many attachments and links it contains. Below that are expandable sections: + +| Section | Contents | +| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Quarantine Details | When the message arrived and when it expires, the quarantine reason, the policy that caught it, and its release history. | +| Delivery Details | Where the message was originally delivered and where it ended up, the detections that fired against it, and the spam, phishing, and bulk confidence levels. | +| Email Details | Sender display name, addresses, sending IP and location, recipients, direction, message IDs, size, and language. | +| Authentication | The DMARC, DKIM, SPF, and composite authentication results. | +| URLs | Every link found in the message, with its threat verdict and the detection method that produced it. Shown only when the message contains links. | +| Attachments | Every attachment, with its threat verdict, malware family, size, and SHA256 hash. Shown only when the message has attachments. | + +Fields with no value are left out rather than shown empty, so the sections vary in length from message to message. The actions offered in the table are repeated at the foot of the flyout. + +Where Microsoft Defender for Office 365 has analysed the message, the delivery, authentication, URL, and attachment detail comes from Microsoft's own analysis. Where it has not, CIPP falls back to reading the message headers and the message itself, and the flyout says so. The fallback still lists the links and attachments it finds, but Microsoft's per-link verdicts are not available for them. + +{% hint style="info" %} +The enriched detail needs the `SecurityAnalyzedMessage.Read.All` permission on your Secure Application Model application. Without it the flyout falls back to the header-based view. Check your permissions under **CIPP > Application Settings > Permissions** if the sections look thinner than expected. +{% endhint %} + +## Table Actions + +{% hint style="info" %} +Under AllTenants, each action runs against the tenant the message belongs to rather than against the tenant selected at the top of CIPP. +{% endhint %} + +
    ActionDescriptionBulk Action Available
    ReleaseReleases the message to all of its recipients. Greyed out on a message that has already been released.true
    Release & Allow SenderReleases the message and adds the sender to the allowed senders list of the anti-spam policy that quarantined it, so future mail from them is not quarantined. Greyed out on a message that has already been released.true
    DenyTurns down a recipient's request to have the message released. Greyed out unless the recipient has actually requested release.true
    Delete from QuarantinePermanently deletes the message from quarantine. Greyed out on a message that has already been released.true
    Preview MessageOpens a modal that renders the quarantined message so its contents, headers, and attachments can be inspected safely.false
    View Message HeadersOpens a modal showing the message's raw internet headers.false
    Download Message (.eml)Downloads the message as a .eml file, named after its subject, for analysis outside CIPP.false
    View Message TraceOpens a modal with a table of the message's trace history, showing where it was received from and what happened to it at each step.false
    Submit to Microsoft for ReviewSends the message to Microsoft as a threat submission so they can review how it was classified. Asks which category to report it under: clean, spam, phishing, or malware.false
    Block SenderAdds the sender to the tenant's tenant-allow-block-lists.md as a blocked sender. The entry expires after 30 days unless you switch it to never expire, and an optional note can be attached.true
    Open Email Entity in DefenderOpens the message's entity page in Microsoft Defender in a new tab, for the detection detail CIPP does not surface.false
    More InfoOpens the Extended Info flyout with the full details for the selected row.false
    + +{% hint style="warning" %} +**Release & Allow Sender** adds a standing allow entry to the anti-spam policy, and that entry stays until it is removed by hand. Use it for a sender that is genuinely being caught wrongly, and prefer a plain **Release** otherwise. The message is released either way: if the allow entry cannot be added, the release still goes through and the failure is recorded in the logs. +{% endhint %} + +{% hint style="danger" %} +**Delete from Quarantine** removes the message outright. There is no recovery afterwards, so preview or download anything you might need first. +{% endhint %} + +{% hint style="info" %} +**Submit to Microsoft for Review** submits the message to Microsoft's threat submission service. Submissions are analysed by Microsoft and help correct both false positives and false negatives, so a message you release because it was wrongly caught is worth reporting as clean. +{% endhint %} + +{% include "../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/email/administration/quarantine/files.md b/docs/user-documentation/email/administration/quarantine/files.md new file mode 100644 index 0000000000..f1d49aa267 --- /dev/null +++ b/docs/user-documentation/email/administration/quarantine/files.md @@ -0,0 +1,39 @@ +# Files + +This tab lists the files Safe Attachments has quarantined from SharePoint, OneDrive, and Microsoft Teams for the selected tenant, so a file blocked in a document library can be reviewed and released without going into the Defender portal. + +Quarantined files are a Microsoft Defender for Office 365 feature, so this tab stays empty for a tenant that is not protected by it. The message-level investigation actions offered for email do not apply to files, so this tab offers release and deletion only. + +## Filters + +| Filter | Shows | +| ------------ | ---------------------------------------------------------------------------------------- | +| Not Released | Files still sitting in quarantine with no request against them. | +| Released | Files that have already been released. | +| Requested | Files a user has asked to have released, which are the ones waiting on your decision. | + +## Table Details + +The properties returned are for the Exchange Online PowerShell command `Get-QuarantineMessage`. For more information on the command please see the [Microsoft documentation](https://learn.microsoft.com/powershell/module/exchangepowershell/get-quarantinemessage). + +Files are listed newest first. Choosing AllTenants starts a background job to gather quarantined items from every tenant, so the table reports that it is still loading until that job finishes. + +## Item Details + +**More Info** opens a flyout with the quarantine record for the file: when it was quarantined and when it expires, the reason it was caught, the policy that caught it, its release history, and the identifiers Microsoft holds against it. Fields with no value are left out rather than shown empty. The actions offered in the table are repeated at the foot of the flyout. + +The threat analysis shown for email messages does not apply here, so the flyout carries the quarantine record only. + +## Table Actions + +{% hint style="info" %} +Under AllTenants, each action runs against the tenant the file belongs to rather than against the tenant selected at the top of CIPP. +{% endhint %} + +
    ActionDescriptionBulk Action Available
    ReleaseReleases the file back to the library it was quarantined from. Greyed out on a file that has already been released.true
    Delete from QuarantinePermanently deletes the file from quarantine. Greyed out on a file that has already been released.true
    More InfoOpens the Extended Info flyout with the full details for the selected row.false
    + +{% hint style="danger" %} +**Delete from Quarantine** removes the file outright. There is no recovery afterwards. +{% endhint %} + +{% include "../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/email/administration/quarantine/teams.md b/docs/user-documentation/email/administration/quarantine/teams.md new file mode 100644 index 0000000000..324fa5c3d6 --- /dev/null +++ b/docs/user-documentation/email/administration/quarantine/teams.md @@ -0,0 +1,39 @@ +# Teams Messages + +This tab lists the Microsoft Teams messages that have been quarantined for the selected tenant, so a chat or channel message pulled out of a conversation can be reviewed and released without going into the Defender portal. + +Teams message quarantine is a Microsoft Defender for Office 365 feature, so this tab stays empty for a tenant that is not protected by it. The mail-specific investigation actions offered for email do not apply to Teams messages, so this tab offers release and deletion only. + +## Filters + +| Filter | Shows | +| ------------ | ----------------------------------------------------------------------------------------------- | +| Not Released | Messages still sitting in quarantine with no request against them. | +| Released | Messages that have already been released. | +| Requested | Messages a user has asked to have released, which are the ones waiting on your decision. | + +## Table Details + +The properties returned are for the Exchange Online PowerShell command `Get-QuarantineMessage`. For more information on the command please see the [Microsoft documentation](https://learn.microsoft.com/powershell/module/exchangepowershell/get-quarantinemessage). + +Messages are listed newest first. Choosing AllTenants starts a background job to gather quarantined items from every tenant, so the table reports that it is still loading until that job finishes. + +## Item Details + +**More Info** opens a flyout with the quarantine record for the message: when it was quarantined and when it expires, the reason it was caught, the policy that caught it, its release history, the recipients, and the type of conversation it came from. Fields with no value are left out rather than shown empty. The actions offered in the table are repeated at the foot of the flyout. + +The threat analysis shown for email messages does not apply here, so the flyout carries the quarantine record only. + +## Table Actions + +{% hint style="info" %} +Under AllTenants, each action runs against the tenant the message belongs to rather than against the tenant selected at the top of CIPP. +{% endhint %} + +
    ActionDescriptionBulk Action Available
    ReleaseReleases the message back into the conversation it was quarantined from. Greyed out on a message that has already been released.true
    Delete from QuarantinePermanently deletes the message from quarantine. Greyed out on a message that has already been released.true
    More InfoOpens the Extended Info flyout with the full details for the selected row.false
    + +{% hint style="danger" %} +**Delete from Quarantine** removes the message outright. There is no recovery afterwards. +{% endhint %} + +{% include "../../../../../.gitbook/includes/feature-request.md" %} From 9040f665ae8dd2d0ed53ef5f44e26752574a5d3e Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Fri, 21 Aug 2026 10:55:02 +0800 Subject: [PATCH 218/226] feat(container-management): show update history as a data table The Status action now returns the 50 most recent version transitions instead of 25, and the Update History card renders them in a CippDataTable (sortable, searchable, exportable) rather than a property list. RecordedAt is registered as a datetime column so it renders as relative time like other tables. --- .../Invoke-ExecContainerManagement.ps1 | 2 +- .../advanced/container-management/status.md | 6 +-- .../CippSettings/CippContainerManagement.jsx | 38 +++++++------------ .../CippTable/util-columnsFromAPI.js | 2 +- frontend/src/utils/get-cipp-formatting.js | 1 + 5 files changed, 19 insertions(+), 30 deletions(-) diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecContainerManagement.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecContainerManagement.ps1 index 5e4fb47846..979dd2ddb6 100644 --- a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecContainerManagement.ps1 +++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecContainerManagement.ps1 @@ -206,7 +206,7 @@ function Invoke-ExecContainerManagement { ValidChannels = $ValidChannels BuildChannelPattern = $BuildChannelPattern UpdateSettings = $UpdateInfo - UpgradeHistory = @(Get-CIPPVersionHistory) + UpgradeHistory = @(Get-CIPPVersionHistory -Last 50) } } } catch { diff --git a/docs/user-documentation/cipp/advanced/container-management/status.md b/docs/user-documentation/cipp/advanced/container-management/status.md index 6e9f4fd616..6154d820e6 100644 --- a/docs/user-documentation/cipp/advanced/container-management/status.md +++ b/docs/user-documentation/cipp/advanced/container-management/status.md @@ -105,10 +105,10 @@ Note that if the container restarts for any reason, the latest image for the cur ## Update History -Lists the version changes recorded for this instance, newest first, so you can see when it landed on the build it is running and what it was on before. Each row gives the date and time the change was recorded, in UTC, followed by the version it moved from, the version it moved to, and the image tag it landed on. +A table of the version changes recorded for this instance, newest first, so you can see when it landed on the build it is running and what it was on before. Each row gives the date and time the change was recorded, the version it moved from, the version it moved to, and the image tag it landed on, and the table can be searched, sorted, and exported like any other CIPP table. -A change is recorded when the container starts on a different version from the one last seen, so the timestamp is when the new build first ran rather than when the image was published. The most recent 25 changes are kept. +A change is recorded when the container starts on a different version from the one last seen, so the timestamp is when the new build first ran rather than when the image was published. The most recent 50 changes are shown. -Until a change has been recorded the card reads **No updates recorded**. Version transitions are recorded from the next update onward, so a newly deployed instance starts with an empty history rather than a backfilled one. +Until a change has been recorded the table is empty. Version transitions are recorded from the next update onward, so a newly deployed instance starts with an empty history rather than a backfilled one. {% include "../../../../../.gitbook/includes/feature-request.md" %} diff --git a/frontend/src/components/CippSettings/CippContainerManagement.jsx b/frontend/src/components/CippSettings/CippContainerManagement.jsx index 9920cc2dd9..96a1c96a0a 100644 --- a/frontend/src/components/CippSettings/CippContainerManagement.jsx +++ b/frontend/src/components/CippSettings/CippContainerManagement.jsx @@ -29,7 +29,7 @@ import { useForm, useWatch } from 'react-hook-form' import CippFormComponent from '../CippComponents/CippFormComponent' import CippButtonCard from '../CippCards/CippButtonCard' import { CippInfoBar } from '../CippCards/CippInfoBar' -import { CippPropertyListCard } from '../CippCards/CippPropertyListCard' +import { CippDataTable } from '../CippTable/CippDataTable' import { ApiGetCall, ApiPostCall } from '../../api/ApiCall' import { CippApiResults } from '../CippComponents/CippApiResults' import { useDialog } from '../../hooks/use-dialog' @@ -323,27 +323,6 @@ export const CippContainerManagement = () => { ] }, [data, updateSettings, channelInfo.label, channelInfo.color]) - // Version transitions recorded at warmup - answers "when did this instance land on the - // current build, and what was it on before?" without reading container logs. - const upgradeHistoryItems = useMemo(() => { - const history = data?.UpgradeHistory ?? [] - if (!history.length) { - return [ - { - label: 'No updates recorded', - value: - 'Version transitions are recorded from the next update onward.', - }, - ] - } - return history.map((event) => ({ - label: formatUtcDate(event.RecordedAt) ?? 'Unknown time', - value: `v${event.PreviousVersion} → v${event.NewVersion}${ - isUnset(event.ImageTag) ? '' : ` (${event.ImageTag})` - }`, - })) - }, [data?.UpgradeHistory]) - const channelChangePending = data?.ConfiguredChannel && data.ConfiguredChannel !== data.CurrentChannel @@ -567,11 +546,20 @@ export const CippContainerManagement = () => { - containerStatus.refetch()} + simpleColumns={[ + 'RecordedAt', + 'PreviousVersion', + 'NewVersion', + 'ImageTag', + ]} /> diff --git a/frontend/src/components/CippTable/util-columnsFromAPI.js b/frontend/src/components/CippTable/util-columnsFromAPI.js index 15f132dfc9..02d3e92dc8 100644 --- a/frontend/src/components/CippTable/util-columnsFromAPI.js +++ b/frontend/src/components/CippTable/util-columnsFromAPI.js @@ -32,7 +32,7 @@ const TIME_AGO_NAMES = new Set([ 'Date', 'WhenCreated', 'WhenChanged', 'CreationTime', 'renewalDate', 'commitmentTerm.renewalConfiguration.renewalDate', 'purchaseDate', 'NextOccurrence', 'LastOccurrence', 'NotBefore', 'NotAfter', 'latestDataCollection', - 'requestDate', 'reviewedDate', 'GeneratedAt', + 'requestDate', 'reviewedDate', 'GeneratedAt', 'RecordedAt', ]) const MATCH_DATE_TIME = /([dD]ate[tT]ime|[Ee]xpiration|[Tt]imestamp|[sS]tart[Dd]ate)/ const ABSOLUTE_DATE_NAMES = new Set([ diff --git a/frontend/src/utils/get-cipp-formatting.js b/frontend/src/utils/get-cipp-formatting.js index 5795959a99..e52877f0ce 100644 --- a/frontend/src/utils/get-cipp-formatting.js +++ b/frontend/src/utils/get-cipp-formatting.js @@ -319,6 +319,7 @@ export const getCippFormatting = ( 'requestDate', // App Consent Requests 'reviewedDate', // App Consent Requests 'GeneratedAt', // Report Builder + 'RecordedAt', // Container update history 'directTenantAuthDate', // Direct tenant service account 'ServiceAccountLastAuth', // Direct tenant service account ] From 53cafd5cc24125a6d582a2697911163b81d8b4d6 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Fri, 21 Aug 2026 11:14:25 +0800 Subject: [PATCH 219/226] fix(core): roll chained orchestrator runs into one queue status A logical queue operation can span several orchestrator runs carrying the same QueueId suffix: activities re-queue continuation runs (the sharing scan's timebox and throttle resumes) and dispatch child orchestrations. Get-CIPPQueueData returned one entry per run and callers took the first, so a progress tracker read Completed the moment the original run's own tasks finished and stopped polling while resumed work was still running. Queue and reference lookups now roll the whole chain up: task counts sum across runs, and the status stays Running while any chained run is active. --- .../Public/CippQueue/Get-CIPPQueueData.ps1 | 34 ++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/backend/Modules/CIPPCore/Public/CippQueue/Get-CIPPQueueData.ps1 b/backend/Modules/CIPPCore/Public/CippQueue/Get-CIPPQueueData.ps1 index 0e94d7ea8d..dd3181ae61 100644 --- a/backend/Modules/CIPPCore/Public/CippQueue/Get-CIPPQueueData.ps1 +++ b/backend/Modules/CIPPCore/Public/CippQueue/Get-CIPPQueueData.ps1 @@ -6,7 +6,39 @@ function Get-CIPPQueueData { if ($env:CIPPNG -eq 'true') { $json = [Craft.Services.QueueStatusBridge]::GetRunStatus($Reference, $QueueId) - return ($json | ConvertFrom-Json) + $Entries = @($json | ConvertFrom-Json) + + # One logical operation can span several orchestrator runs carrying the same QueueId + # suffix: activities re-queue continuation runs (timebox and throttle resumes) and + # dispatch child orchestrations. A caller asking after one queue needs the roll-up of + # the whole chain, not whichever run the bridge listed first - above all, a progress + # tracker must keep polling while ANY chained run is still active, where reading just + # the original run reports Completed the moment its own tasks finish. + if (($QueueId -or $Reference) -and $Entries.Count -gt 1) { + $Terminal = @('Completed', 'Failed', 'Completed (with errors)', 'Not found') + $TotalTasks = 0; $CompletedTasks = 0; $RunningTasks = 0; $FailedTasks = 0 + $AnyActive = $false; $AnyFailed = $false + $AllTasks = [System.Collections.Generic.List[object]]::new() + foreach ($Entry in $Entries) { + $TotalTasks += [int]($Entry.TotalTasks ?? 0) + $CompletedTasks += [int]($Entry.CompletedTasks ?? 0) + $RunningTasks += [int]($Entry.RunningTasks ?? 0) + $FailedTasks += [int]($Entry.FailedTasks ?? 0) + if ([string]$Entry.Status -notin $Terminal) { $AnyActive = $true } + if ([string]$Entry.Status -in @('Failed', 'Completed (with errors)') -or [int]($Entry.FailedTasks ?? 0) -gt 0) { $AnyFailed = $true } + foreach ($Task in @($Entry.Tasks)) { $AllTasks.Add($Task) } + } + $Rollup = $Entries[0].PSObject.Copy() + $Rollup.TotalTasks = [Math]::Max($TotalTasks, 1) + $Rollup.CompletedTasks = $CompletedTasks + $Rollup.RunningTasks = $RunningTasks + $Rollup.FailedTasks = $FailedTasks + $Rollup.PercentComplete = [math]::Round((($CompletedTasks / [Math]::Max($TotalTasks, 1)) * 100), 1) + $Rollup.Tasks = @($AllTasks) + $Rollup.Status = if ($AnyActive) { 'Running' } elseif ($AnyFailed) { 'Completed (with errors)' } else { 'Completed' } + return $Rollup + } + return $Entries } $CippQueue = Get-CippTable -TableName 'CippQueue' From 226751a392df475284bc2855ceefb959439864fb Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Fri, 21 Aug 2026 11:31:42 +0800 Subject: [PATCH 220/226] feat(worker-health): surface skipped jobs and add job detail off-canvas The job queue stats row now shows the Skipped count (stale queue entries whose task was gone by dispatch time - benign, so never flagged red) and the status filter gains a Skipped toggle. Each job row gets a More Info off-canvas with the fields the table does not show: id, started and completed times, and the last error. QueuedUtc/StartedUtc/CompletedUtc are registered as absolute-date columns, and the absolute-date formatter now returns a rendered string instead of a raw Date when the caller accepts nodes - an off-canvas containing any absolute-date field crashed React before this. --- .../container-management/worker-health.md | 10 +-- .../CippTable/util-columnsFromAPI.js | 1 + .../container-management/worker-health.js | 19 +++++- frontend/src/utils/get-cipp-formatting.js | 9 ++- .../tests/pages/WorkerHealthPage.test.jsx | 65 ++++++++++++++++++- 5 files changed, 96 insertions(+), 8 deletions(-) diff --git a/docs/user-documentation/cipp/advanced/container-management/worker-health.md b/docs/user-documentation/cipp/advanced/container-management/worker-health.md index 7e440332ad..8c096c765e 100644 --- a/docs/user-documentation/cipp/advanced/container-management/worker-health.md +++ b/docs/user-documentation/cipp/advanced/container-management/worker-health.md @@ -26,7 +26,7 @@ At the top of the page, a bar of key indicators gives an at-a-glance view of the | Memory | Container memory used versus its limit, with the usage percentage. | | CPU | Container and application CPU usage. | -Below the indicator bar, a compact stats panel breaks the same areas down in more detail: the HTTP and BG pools (size, busy count, invocations, utilisation, average duration, and faults); Jobs (running, queued, completed, failed); the Limiter (active/maximum, waiting, and throttle status); Memory (container used and limit, application RSS, other processes, GC heap, committed, GC limit, usage percentage, and garbage-collection counts); and CPU (container, application, and other). Any figure that crosses a warning threshold is shown in red. +Below the indicator bar, a compact stats panel breaks the same areas down in more detail: the HTTP and BG pools (size, busy count, invocations, utilisation, average duration, and faults); Jobs (running, queued, completed, failed, and skipped — stale queue entries whose task was already gone when picked up); the Limiter (active/maximum, waiting, and throttle status); Memory (container used and limit, application RSS, other processes, GC heap, committed, GC limit, usage percentage, and garbage-collection counts); and CPU (container, application, and other). Any figure that crosses a warning threshold is shown in red. ## Worker Pools @@ -47,7 +47,7 @@ Two tables list every worker in the container: one for the HTTP pool, which hand ## Job Queue -The Job Queue lists the background jobs known to the worker system, newest first. Queued jobs come from the durable job queue in table storage, so the list shows the full backlog of a large run — not just the handful of tasks the container has buffered for execution — and cancelling or reprioritizing a queued job takes effect even for work no container has picked up yet. Two toggles above the table control what is loaded: a status toggle (All, Queued, Running, Completed, Failed, or Cancelled) and a load limit (500, 2k, 5k, or 10k). +The Job Queue lists the background jobs known to the worker system, newest first. Queued jobs come from the durable job queue in table storage, so the list shows the full backlog of a large run — not just the handful of tasks the container has buffered for execution — and cancelling or reprioritizing a queued job takes effect even for work no container has picked up yet. Two toggles above the table control what is loaded: a status toggle (All, Queued, Running, Completed, Failed, Cancelled, or Skipped) and a load limit (500, 2k, 5k, or 10k). The status toggle filters on the server, before the load limit is applied, so the limit applies to the selected status rather than to all jobs. This matters on a busy instance: with a large backlog of completed jobs, loading All would fill the entire limit with completed work and show no queued jobs at all. Select Queued to see the jobs still waiting to run, regardless of how much history sits behind them. @@ -58,11 +58,13 @@ The status toggle filters on the server, before the load limit is applied, so th | Name | The name of the job's function. | | RunName | The name of the run that the job belongs to, where applicable. | | Priority | The job's priority, where 0 is the highest. | -| Status | The job's current state, such as Queued, Running, Completed, or Failed. | -| QueuedUtc | The date and time the job was queued, in UTC. | +| Status | The job's current state, such as Queued, Running, Completed, or Failed. Skipped means the queue entry went stale — its task was gone or already finished by the time the job was picked up — and nothing was run; it is not a failure. | +| QueuedUtc | The date and time the job was queued. | | WaitSeconds | How long the job waited in the queue before it started running. | | DurationSeconds | How long the job took to run. | +Selecting **More Info** on a row opens a panel with the full detail for that job, including the fields the table does not show: the job's id, when it started and completed, and the last error recorded for it. + ### Table Actions | Action | Description | Bulk Action Available | diff --git a/frontend/src/components/CippTable/util-columnsFromAPI.js b/frontend/src/components/CippTable/util-columnsFromAPI.js index 02d3e92dc8..e530b18f3a 100644 --- a/frontend/src/components/CippTable/util-columnsFromAPI.js +++ b/frontend/src/components/CippTable/util-columnsFromAPI.js @@ -38,6 +38,7 @@ const MATCH_DATE_TIME = /([dD]ate[tT]ime|[Ee]xpiration|[Tt]imestamp|[sS]tart[Dd] const ABSOLUTE_DATE_NAMES = new Set([ 'WindowStart', 'WindowEnd', 'CreatedUtc', 'DownloadedUtc', 'ProcessedUtc', 'NextAttemptUtc', 'LastErrorUtc', 'LastPolledUtc', + 'QueuedUtc', 'StartedUtc', 'CompletedUtc', ]) const isDateTimeColumn = (key) => TIME_AGO_NAMES.has(key) || ABSOLUTE_DATE_NAMES.has(key) || MATCH_DATE_TIME.test(key) diff --git a/frontend/src/pages/cipp/advanced/container-management/worker-health.js b/frontend/src/pages/cipp/advanced/container-management/worker-health.js index 0f422fbe3c..27a5b422cc 100644 --- a/frontend/src/pages/cipp/advanced/container-management/worker-health.js +++ b/frontend/src/pages/cipp/advanced/container-management/worker-health.js @@ -353,6 +353,8 @@ const CompactStatsRow = ({ snapshot }) => { { k: "Queued", v: jobs.Queued ?? 0, w: jobs.Queued > 10 }, { k: "Done", v: jobs.Completed?.toLocaleString() ?? 0 }, { k: "Failed", v: jobs.Failed ?? 0, w: jobs.Failed > 0 }, + // Stale queue entries whose task was gone by dispatch time — benign, so never flagged. + { k: "Skipped", v: jobs.Skipped ?? 0 }, ], }, { @@ -841,6 +843,21 @@ const Page = () => { }} simpleColumns={jobSimpleColumns} actions={jobActions} + offCanvas={{ + extendedInfoFields: [ + "Id", + "Name", + "RunName", + "Status", + "Priority", + "QueuedUtc", + "StartedUtc", + "CompletedUtc", + "WaitSeconds", + "DurationSeconds", + "LastError", + ], + }} defaultSorting={[{ id: "QueuedUtc", desc: true }]} cardButton={ @@ -850,7 +867,7 @@ const Page = () => { onChange={(_, val) => val !== null && setJobStatus(val)} size="small" > - {["", "Queued", "Running", "Completed", "Failed", "Cancelled"].map((s) => ( + {["", "Queued", "Running", "Completed", "Failed", "Cancelled", "Skipped"].map((s) => ( {s || "All"} diff --git a/frontend/src/utils/get-cipp-formatting.js b/frontend/src/utils/get-cipp-formatting.js index e52877f0ce..30b4b28095 100644 --- a/frontend/src/utils/get-cipp-formatting.js +++ b/frontend/src/utils/get-cipp-formatting.js @@ -268,6 +268,9 @@ export const getCippFormatting = ( 'NextAttemptUtc', 'LastErrorUtc', 'LastPolledUtc', + 'QueuedUtc', // Worker health job queue + 'StartedUtc', // Worker health job queue + 'CompletedUtc', // Worker health job queue ] if (absoluteDateArray.includes(cellName)) { if (data === null || data === undefined || data === '') { @@ -276,9 +279,11 @@ export const getCippFormatting = ( const dt = parseCippDate(data) if (isNaN(dt.getTime())) return isText ? '' : '' if (dt.getTime() === 0) return isText ? '' : 'Never' - // text mode: Date object so MRT sorts chronologically (toLocaleString for CSV export); + // text mode: Date object so MRT sorts chronologically — except when the caller can + // receive a rendered node ('both': off-canvas, card views) or explicitly wants a + // string (false: CSV export); a raw Date is not a valid React child. // cell mode: long absolute string in the browser's locale + timezone. - if (isText) return canReceive === false ? dt.toLocaleString() : dt + if (isText) return canReceive === 'both' || canReceive === false ? dt.toLocaleString() : dt return dt.toLocaleString() } diff --git a/frontend/tests/pages/WorkerHealthPage.test.jsx b/frontend/tests/pages/WorkerHealthPage.test.jsx index 70233904da..7f8bf6a8f3 100644 --- a/frontend/tests/pages/WorkerHealthPage.test.jsx +++ b/frontend/tests/pages/WorkerHealthPage.test.jsx @@ -1,6 +1,6 @@ import React from 'react' import { describe, it, expect, vi, beforeEach } from 'vitest' -import { screen, waitFor } from '@testing-library/react' +import { screen, waitFor, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { renderWithProviders } from '../test-utils' import Page from '../../src/pages/cipp/advanced/container-management/worker-health.js' @@ -8,6 +8,7 @@ import Page from '../../src/pages/cipp/advanced/container-management/worker-heal vi.mock('../../src/api/ApiCall', async () => (await import('../mocks/api-call')).apiCallMock()) import { api, getResult, paginatedResult, postResult } from '../mocks/api-call' import { ApiGetCallWithPagination } from '../../src/api/ApiCall' +import { resetOverlayHistory } from '../../src/utils/overlay-history' // stable refs, see GraphExplorerPage.test.jsx (fresh literals per call loop the data-sync effects) const jobsResult = paginatedResult([ @@ -51,6 +52,68 @@ describe('Worker Health page - job queue preset filters', () => { }) }) + // Craft marks stale queue entries (task gone by dispatch time) as Skipped — same + // server-side filter contract as every other status. + it('Skipped toggle requests server-side filtering via the Status param', async () => { + const user = userEvent.setup() + renderWithProviders() + await screen.findByText('1-5 of 5') + + await user.click(screen.getByRole('button', { name: 'Skipped' })) + + await waitFor(() => { + const last = ApiGetCallWithPagination.mock.calls.at(-1)[0] + expect(last.queryKey).toBe('WorkerHealthJobs-2000-Skipped') + expect(last.data).toMatchObject({ Action: 'Jobs', Limit: '2000', Status: 'Skipped' }) + }) + }) + + // jsdom has no layout engine, so MRT's virtualized table renders no cells — drive the + // card view instead, where tapping a card opens the off-canvas (see CippDataTable.test.jsx). + it('opening a job card shows the off-canvas detail fields', async () => { + const cache = new Map() + window.matchMedia = (query) => { + if (!cache.has(query)) { + cache.set(query, { + matches: query.includes('max-width'), + media: query, + onchange: null, + addListener: () => {}, + removeListener: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false, + }) + } + return cache.get(query) + } + try { + const user = userEvent.setup() + renderWithProviders() + + await waitFor(() => expect(screen.getByText('Job Five')).toBeInTheDocument()) + await user.click(screen.getByText('Job Five')) + + // Drawer title is the job name; scope assertions to the drawer since the card + // behind it renders some of the same text. + const drawer = await waitFor(() => { + const d = screen + .getAllByText('Job Five') + .map((el) => el.closest('.MuiDrawer-paper')) + .find(Boolean) + expect(d).toBeTruthy() + return d + }) + // Started Utc is not a table column, and the Id value is hidden from the table. + // Job Five never started, so its StartedUtc renders as N/A. + expect(within(drawer).getByText('Started Utc')).toBeInTheDocument() + expect(within(drawer).getByText('j5')).toBeInTheDocument() + } finally { + resetOverlayHistory() + delete window.matchMedia + } + }, 30000) // card list mount + drawer transition; default 5000ms testTimeout flakes under load (see GraphExplorerPage) + it('All toggle drops the Status param instead of sending an empty string', async () => { const user = userEvent.setup() renderWithProviders() From 31a6220fb1cf99ba4a1e29e82c89ff499cb43684 Mon Sep 17 00:00:00 2001 From: Brian Simpson <50429915+bmsimp@users.noreply.github.com> Date: Thu, 20 Aug 2026 22:46:04 -0500 Subject: [PATCH 221/226] docs: replace embedded API schema with reference to built in integration page --- docs/api-documentation/endpoints.md | 44 ++++++++++++++++--- .../setup-and-authentication.md | 2 +- 2 files changed, 40 insertions(+), 6 deletions(-) diff --git a/docs/api-documentation/endpoints.md b/docs/api-documentation/endpoints.md index c7b6025ac8..aae3f5ec91 100644 --- a/docs/api-documentation/endpoints.md +++ b/docs/api-documentation/endpoints.md @@ -1,13 +1,47 @@ # Endpoints -To view this page in a new window, right click the button below: +Everything CIPP does in the interface is backed by an API endpoint, and the full reference for them now lives inside your own CIPP instance rather than on this site. It is generated from the source of the exact version you are running, so it always describes the deployment in front of you. -Endpoint Documentation (Right Click for New Tab) +## Opening the endpoint reference -{% hint style="info" %} -Everything CIPP does in the frontend is represented by an API endpoint. For further clarification on how CIPP handles processes, it's best to open up DevTools in your browser and inspect the Network tab for which calls are being made. From there, you can reference the call itself or the documentation below. +Go to **CIPP → Integrations → CIPP-API** and open the **API Documentation** tab. + +Every endpoint your deployment exposes is listed there. Expand one to see the parameters it accepts, the body it expects, and the responses it returns. The filter box at the top narrows the list by name. + +{% hint style="warning" %} +This reference is in beta. It is generated automatically from the CIPP source, and request and response schemas are inferred, so some fields may be missing, loosely typed, or described only in part. Treat it as a strong guide rather than a guarantee, and report anything that looks wrong. {% endhint %} -{% @cipp-external-webpage-block/cyberdrain url="https://cipp-ashe.github.io/cipp-oas-generator/" fullWidth="true" %} +## Trying an endpoint + +Each operation has a **Try it out** button that sends the call to the instance you are signed in to, using your existing session. There is no client ID, secret or token to paste in. + +{% hint style="danger" %} +Your own permissions apply, and write operations really do write. A call sent from this tab changes the same tenants CIPP manages, exactly as if you had performed the action in the interface. +{% endhint %} + +## Using the specification in your own tools + +The description behind the page is an OpenAPI 3.1 document, published by your instance at: + +``` +https:///openapi.json +``` + +Open it while signed in to CIPP and save the file, then import it into Postman, Insomnia, or a client generator to scaffold your automation. It is rebuilt with every release, so pull a fresh copy after an upgrade to pick up new and changed endpoints. + +## Finding the endpoint behind a page + +If you are not sure which call sits behind something you do in CIPP, open your browser's developer tools, switch to the Network tab, and perform the action. The request name matches the endpoint in the reference, so you can look it up there and see its full parameters. + +## Setting up access + +{% content-ref url="setup-and-authentication.md" %} +[setup-and-authentication.md](setup-and-authentication.md) +{% endcontent-ref %} + +{% content-ref url="../user-documentation/cipp/integrations/cipp-api.md" %} +[cipp-api.md](../user-documentation/cipp/integrations/cipp-api.md) +{% endcontent-ref %} {% include "../../.gitbook/includes/feature-request.md" %} diff --git a/docs/api-documentation/setup-and-authentication.md b/docs/api-documentation/setup-and-authentication.md index 95ccb8afcd..0c5c624d41 100644 --- a/docs/api-documentation/setup-and-authentication.md +++ b/docs/api-documentation/setup-and-authentication.md @@ -82,7 +82,7 @@ Endpoints that support `UseReportDB` today: | Security & Tenant | `ListMDEOnboarding`, `ListOAuthApps` | {% hint style="info" %} -This list grows over time. The [endpoints.md](endpoints.md "mention") documentation is the authoritative source — if an endpoint lists a `UseReportDB` parameter, it supports this. +This list grows over time. The API Documentation tab in your own instance is the authoritative source: if an endpoint lists a `UseReportDB` parameter, it supports this. See [endpoints.md](endpoints.md "mention") for how to open it. {% endhint %} ### Ask for every tenant in one call From c6dd7a41e04a9c4dbf4b29443e389782d7e18480 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Fri, 21 Aug 2026 18:28:20 +0800 Subject: [PATCH 222/226] feat(vacation): support standalone alert exclusion Add a new `ExecScheduleAuditExclusionVacation` endpoint and wire the vacation wizard to schedule location-based audit alert exclusions independently of Conditional Access. The UI now presents this as its own action, includes confirmation/results handling, updates vacation-mode filtering, and documents the new fifth action. Also harden SharePoint sharing-links cache scans by removing the stale PrincipalCount pre-filter path, always using full/incremental delta ground truth, and preserving existing rows when permission batch reads are dropped. Related backend and frontend tests were updated accordingly. --- backend/Config/openapi.json | 73 ++++++++ ...Push-DBCacheSharePointSiteSharingLinks.ps1 | 160 ++++------------- ...oke-ExecScheduleAuditExclusionVacation.ps1 | 82 +++++++++ .../SharePointSharingLinks.Resume.Tests.ps1 | 50 +----- ...ecScheduleAuditExclusionVacation.Tests.ps1 | 163 ++++++++++++++++++ .../vacation-mode/add-vacation-schedule.md | 19 +- .../CippWizard/CippWizardVacationActions.jsx | 49 +++++- .../CippWizardVacationConfirmation.jsx | 45 ++++- .../administration/vacation-mode/index.js | 5 + .../CippWizardVacationActions.test.jsx | 22 +++ 10 files changed, 473 insertions(+), 195 deletions(-) create mode 100644 backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Alerts/Invoke-ExecScheduleAuditExclusionVacation.ps1 create mode 100644 backend/Tests/Endpoint/Invoke-ExecScheduleAuditExclusionVacation.Tests.ps1 diff --git a/backend/Config/openapi.json b/backend/Config/openapi.json index b45ebc9b02..db770295c1 100644 --- a/backend/Config/openapi.json +++ b/backend/Config/openapi.json @@ -30694,6 +30694,79 @@ "x-cipp-any-tenant": true } }, + "/api/ExecScheduleAuditExclusionVacation": { + "post": { + "summary": "Schedule a location alert exclusion for a vacation period", + "operationId": "ExecScheduleAuditExclusionVacation", + "tags": [ + "Tenant > Administration > Alerts" + ], + "description": "Adds the selected users to the audit log location alert exclusion list at the start date and removes them again at the end date, so location-based alerts do not fire while they travel. Works on its own and does not require a Conditional Access policy.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "endDate": { + "type": "string", + "description": "Unix timestamp for when the exclusion is removed" + }, + "postExecution": { + "type": "string" + }, + "reference": { + "type": "string" + }, + "startDate": { + "type": "string", + "description": "Unix timestamp for when the exclusion is added" + }, + "tenantFilter": { + "type": "string" + }, + "Users": { + "type": "string", + "description": "The users going on vacation" + } + }, + "required": [ + "tenantFilter" + ] + } + } + } + }, + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StandardResults" + } + } + } + }, + "401": { + "description": "Unauthorized - invalid or missing bearer token" + }, + "403": { + "description": "Forbidden - caller lacks the required RBAC role" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "x-cipp-role": "CIPP.Alert.ReadWrite" + } + }, "/api/ExecScheduleForwardingVacation": { "post": { "summary": "ExecScheduleForwardingVacation", diff --git a/backend/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/SharePoint Sharing/Push-DBCacheSharePointSiteSharingLinks.ps1 b/backend/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/SharePoint Sharing/Push-DBCacheSharePointSiteSharingLinks.ps1 index 4910fbbbf6..3423adf56d 100644 --- a/backend/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/SharePoint Sharing/Push-DBCacheSharePointSiteSharingLinks.ps1 +++ b/backend/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/SharePoint Sharing/Push-DBCacheSharePointSiteSharingLinks.ps1 @@ -15,21 +15,15 @@ function Push-DBCacheSharePointSiteSharingLinks { by far the largest drive on the site. Drive task ($Item.DriveId present) - scans one drive for shared items and writes - sharing-link rows to the reporting DB page by page. Three scan modes: - - Principal - full scan of a non-personal site's drive. Enumerates the backing list - with the hidden PrincipalCount field (999 rows per request); only items - whose principal count differs from the drive's inherited baseline have - extra role assignments (sharing links, direct grants), and only those get - a batched driveItem + permissions read. On group-connected team sites the - delta 'shared' facet is true for EVERY item (group access), so the classic - path costs one permission read per item; this path replaces that with - items/999 list pages + a permission read per actually-shared item. - The drive's deltaLink is captured afterwards via delta?token=latest so the - next scan runs incrementally. - Full - classic delta walk reading permissions for every shared-facet item. Used - for personal sites (OneDrive only flags genuinely shared items) and for - ForceFullSync, where it serves as the ground-truth deep scan. + sharing-link rows to the reporting DB page by page. Two scan modes: + + Full - delta walk reading permissions for every shared-facet item. This is the + ground truth, deliberately: a PrincipalCount pre-filter was tried and + removed because paged list enumeration serves stale principal counts on + large busy lists, silently under-reporting shares. On group-connected + team sites the shared facet is true for every item, so a full scan costs + one batched permission read per item - the checkpointed resumes below are + what make that converge on drives of any size. Incremental - delta from the stored token; only changed items are processed. Changed items' existing rows are tombstoned and re-added from a fresh permission read. @@ -175,8 +169,10 @@ function Push-DBCacheSharePointSiteSharingLinks { } # Fetch permissions for a buffer of shared delta items and append their rows to $RowsOut. + # Failed batch responses are counted into $DropCounter: a full scan that lost reads must not + # prune the unread items' still-valid rows afterwards. function Add-CIPPSharingRows { - param($Buffer, $Drive, $Site, $InternalDomains, $TenantFilter, $RowsOut) + param($Buffer, $Drive, $Site, $InternalDomains, $TenantFilter, $RowsOut, [ref]$DropCounter) if (@($Buffer).Count -eq 0) { return } $ItemByRequestId = @{} $RequestId = 0 @@ -191,7 +187,10 @@ function Push-DBCacheSharePointSiteSharingLinks { } $PermissionResponses = New-GraphBulkRequest -tenantid $TenantFilter -Requests @($PermissionRequests) -asapp $true foreach ($Response in $PermissionResponses) { - if ($Response.status -and $Response.status -ne 200) { continue } + if ($Response.status -and $Response.status -ne 200) { + if ($DropCounter) { $DropCounter.Value++ } + continue + } $DriveItem = $ItemByRequestId["$($Response.id)"] ConvertTo-CIPPSharingRow -Permissions @($Response.body.value) -DriveItem $DriveItem -Drive $Drive -Site $Site -InternalDomains $InternalDomains -RowsOut $RowsOut } @@ -457,17 +456,18 @@ function Push-DBCacheSharePointSiteSharingLinks { $FullDeltaUri = "https://graph.microsoft.com/beta/drives/$($Drive.id)/root/delta?`$select=$DeltaSelect&`$top=999" try { - # Where does this drive start: checkpoint > stored delta token > full scan. Full scans of - # non-personal sites use the PrincipalCount path unless this is a forced ground-truth - # sync; OneDrive keeps the classic path because its shared facet is already selective. + # Where does this drive start: checkpoint > stored delta token > full scan. Full scans + # always walk the delta ground truth. A PrincipalCount pre-filter was tried here and + # removed: paged list enumeration serves STALE principal counts on large, busy lists + # (linked items kept reading the inherited count days after their links were created), + # silently under-reporting shares - and the checkpointed timebox/throttle resumes make + # the full walk converge on a drive of any size anyway. $Checkpoint = Get-DriveCheckpoint - $Mode = if ($IsPersonalSite -or $ForceFull) { 'Full' } else { 'Principal' } + $Mode = 'Full' $Uri = $null - $Baseline = $null - if ($Checkpoint -and $Checkpoint.CurrentUri) { + if ($Checkpoint -and $Checkpoint.CurrentUri -and [string]$Checkpoint.CurrentMode -in @('Full', 'Incremental')) { $Mode = [string]$Checkpoint.CurrentMode $Uri = [string]$Checkpoint.CurrentUri - $Baseline = $Checkpoint.Baseline } elseif (-not $ForceFull) { $DriveState = Get-CIPPSharingLinksDriveState -TenantFilter $TenantFilter -DriveId $Drive.id $LastFull = $(try { [DateTimeOffset]::Parse([string]$DriveState.LastFullScanUtc) } catch { [DateTimeOffset]::MinValue }) @@ -477,109 +477,6 @@ function Push-DBCacheSharePointSiteSharingLinks { } } - # ---------------- Principal mode: list enumeration filtered on PrincipalCount ---------- - if ($Mode -eq 'Principal') { - try { - if (-not $Uri) { - $List = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/drives/$($Drive.id)/list?`$select=id" -tenantid $TenantFilter -asapp $true - if (-not $List.id) { throw 'drive has no backing list' } - $Uri = "https://graph.microsoft.com/beta/sites/$SiteId/lists/$($List.id)/items?`$top=999&`$select=id&`$expand=fields(`$select=PrincipalCount)" - } - - $DroppedReads = 0 - while ($Uri) { - $Page = New-GraphGetRequest -uri $Uri -tenantid $TenantFilter -asapp $true -noPagination $true -SkipValueExtraction - - # Baseline = the dominant PrincipalCount on the drive's first page: the - # inherited count every unshared item carries. The drive root's own - # permission list is NOT a safe proxy - system entries (Limited Access, - # claims principals) inflate it above the items' inherited count, inverting - # the filter and flagging an entire library for permission reads. - if ($null -eq $Baseline) { - $Dominant = @($Page.value) | Group-Object { [int]$_.fields.PrincipalCount } | Sort-Object Count -Descending | Select-Object -First 1 - $Baseline = if ($Dominant) { [int]$Dominant.Name } else { -1 } - } - - # An item whose principal count deviates from the inherited baseline carries - # extra (or unusual) role assignments; the permission read is the ground truth - # that filters inherited-only false positives back out. - $FlaggedIds = [System.Collections.Generic.List[string]]::new() - foreach ($ListItem in @($Page.value)) { - if ([int]$ListItem.fields.PrincipalCount -ne $Baseline -and $ListItem.id) { $FlaggedIds.Add([string]$ListItem.id) } - } - - $PageRows = [System.Collections.Generic.List[object]]::new() - if ($FlaggedIds.Count -gt 0) { - $RequestId = 0 - $ItemRequests = foreach ($FlaggedId in $FlaggedIds) { - @{ - id = "$RequestId" - method = 'GET' - url = "sites/$SiteId/lists/$($List.id)/items/$FlaggedId/driveItem?`$select=id,name,webUrl,folder,size,lastModifiedDateTime&`$expand=permissions" - } - $RequestId++ - } - $ItemResponses = New-GraphBulkRequest -tenantid $TenantFilter -Requests @($ItemRequests) -asapp $true - foreach ($Response in $ItemResponses) { - if ($Response.status -and $Response.status -ne 200) { $DroppedReads++; continue } - ConvertTo-CIPPSharingRow -Permissions @($Response.body.permissions) -DriveItem $Response.body -Drive $Drive -Site $SiteContext -InternalDomains $InternalDomains -RowsOut $PageRows - } - } - if ($PageRows.Count -gt 0) { - Add-CIPPDbItem -TenantFilter $TenantFilter -Type $CacheType -Data @($PageRows) -Append -RunId $ScanId - } - - $Uri = [string]$Page.'@odata.nextLink' - if ($Uri) { - $State = @{ CurrentUri = $Uri; CurrentMode = 'Principal'; Baseline = $Baseline } - Save-DriveCheckpoint -State $State - if (Invoke-TimeboxRequeue -State $State) { return @() } - } - } - - if ($DroppedReads -gt 0) { - # Throttled/failed batch responses mean some shared items were not rewritten - # this scan. Pruning now would delete their still-valid rows, so keep - # everything and force the next scan to run this drive full again. - Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Sharing links: $DroppedReads permission reads dropped on drive '$($Drive.name)' ($SiteUrl); keeping existing rows and deferring the sweep to the next full scan" -sev Warning - Set-DriveState -DeltaLink '' - } else { - # Everything currently shared was rewritten with this scan's id; the rest is - # stale by definition. - $null = Remove-CIPPSharingLinksRowsByPrefix -TenantFilter $TenantFilter -Prefix "$CacheType-${DriveKeySegment}_" -ExceptRunId $ScanId - - # Capture the delta position without walking the drive, so the next scan of - # this drive runs incrementally off the classic path. - $DeltaLink = '' - try { - $TokenPage = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/drives/$($Drive.id)/root/delta?token=latest&`$select=id" -tenantid $TenantFilter -asapp $true -noPagination $true -SkipValueExtraction - $DeltaLink = [string]$TokenPage.'@odata.deltaLink' - } catch { - Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Sharing links: could not capture delta token for drive '$($Drive.name)' on '$SiteUrl': $($_.Exception.Message)" -sev Debug - } - Set-DriveState -DeltaLink $DeltaLink -FullScan - } - } catch { - if ($_.Exception.Message -match 'Access to this site has been blocked') { - # Site locked mid-scan: links are inactive, so leave the drive state stale - # for finalisation to prune rather than protecting this drive's rows. - Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Sharing links: drive '$($Drive.name)' on '$SiteUrl' is locked; leaving its rows for pruning" -sev Info - } elseif (Invoke-ThrottleRequeue -ErrorMessage $_.Exception.Message) { - # Requeued to resume from the checkpoint; this task must not complete the - # drive or touch its state. - return @() - } else { - Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Sharing links: failed scanning drive '$($Drive.name)' on '$SiteUrl': $($_.Exception.Message)" -sev Warning - # A current LastScanId with an empty token both protects this drive's cached - # rows from pruning and forces the next scan to run full. - Set-DriveState -DeltaLink '' - } - } - Remove-DriveCheckpoint - Complete-Drive - return @() - } - # ---------------- Full / Incremental: classic delta walk ------------------------------- if (-not $Uri) { $Uri = $FullDeltaUri } @@ -602,6 +499,7 @@ function Push-DBCacheSharePointSiteSharingLinks { $DeltaLink = $null $DriveFailed = $false + $DroppedReads = 0 while ($Uri) { try { $Page = New-GraphGetRequest -uri $Uri -tenantid $TenantFilter -asapp $true -noPagination $true -SkipValueExtraction @@ -652,7 +550,7 @@ function Push-DBCacheSharePointSiteSharingLinks { # Rows for this page: permission lookups happen per page so the checkpoint below # never advances past work that has not been persisted. $PageRows = [System.Collections.Generic.List[object]]::new() - Add-CIPPSharingRows -Buffer $Buffer -Drive $Drive -Site $SiteContext -InternalDomains $InternalDomains -TenantFilter $TenantFilter -RowsOut $PageRows + Add-CIPPSharingRows -Buffer $Buffer -Drive $Drive -Site $SiteContext -InternalDomains $InternalDomains -TenantFilter $TenantFilter -RowsOut $PageRows -DropCounter ([ref]$DroppedReads) if ($TombstoneRows.Count -gt 0) { $Table = Get-CippTable -tablename 'CippReportingDB' @@ -684,6 +582,12 @@ function Push-DBCacheSharePointSiteSharingLinks { [string](Get-CIPPSharingLinksDriveState -TenantFilter $TenantFilter -DriveId $Drive.id).DeltaLink } else { '' } Set-DriveState -DeltaLink $KeepToken + } elseif ($Mode -eq 'Full' -and $DroppedReads -gt 0) { + # Throttled/failed batch responses mean some shared items were not rewritten this + # scan. Pruning now would delete their still-valid rows, so keep everything and + # force the next scan to run this drive full again. + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Sharing links: $DroppedReads permission reads dropped on drive '$($Drive.name)' ($SiteUrl); keeping existing rows and deferring the sweep to the next full scan" -sev Warning + Set-DriveState -DeltaLink '' } else { if ($Mode -eq 'Full') { # The scan rewrote every shared item's rows with this scan's id; anything left diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Alerts/Invoke-ExecScheduleAuditExclusionVacation.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Alerts/Invoke-ExecScheduleAuditExclusionVacation.ps1 new file mode 100644 index 0000000000..702a61d580 --- /dev/null +++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Alerts/Invoke-ExecScheduleAuditExclusionVacation.ps1 @@ -0,0 +1,82 @@ +function Invoke-ExecScheduleAuditExclusionVacation { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + CIPP.Alert.ReadWrite + .SYNOPSIS + Schedule a location alert exclusion for a vacation period + .DESCRIPTION + Adds the selected users to the audit log location alert exclusion list at the start date and removes them again at the end date, so location-based alerts do not fire while they travel. Works on its own and does not require a Conditional Access policy. + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers + + try { + $TenantFilter = $Request.Body.tenantFilter + # The users going on vacation + $Users = @($Request.Body.Users) + # Unix timestamp for when the exclusion is added + $StartDate = $Request.Body.startDate + # Unix timestamp for when the exclusion is removed + $EndDate = $Request.Body.endDate + + $UserUPNs = @($Users | ForEach-Object { $_.addedFields.userPrincipalName ?? $_.value ?? $_ }) + + if ($UserUPNs.Count -eq 0) { + throw 'At least one user is required.' + } + if (-not $StartDate -or -not $EndDate) { + throw 'A start date and end date are required.' + } + + $UserDisplay = ($UserUPNs | Select-Object -First 3) -join ', ' + if ($UserUPNs.Count -gt 3) { $UserDisplay += " (+$($UserUPNs.Count - 3) more)" } + + Add-CIPPScheduledTask -Task ([PSCustomObject]@{ + TenantFilter = $TenantFilter + Name = "Add Location Alert Exclusion Vacation Mode: $UserDisplay" + Command = @{ value = 'Set-CIPPAuditLogUserExclusion'; label = 'Set-CIPPAuditLogUserExclusion' } + Parameters = [PSCustomObject]@{ + TenantFilter = $TenantFilter + Users = $UserUPNs + Action = 'Add' + Type = 'Location' + } + ScheduledTime = [int64]$StartDate + PostExecution = $Request.Body.postExecution + Reference = $Request.Body.reference + }) -hidden $false + + Add-CIPPScheduledTask -Task ([PSCustomObject]@{ + TenantFilter = $TenantFilter + Name = "Remove Location Alert Exclusion Vacation Mode: $UserDisplay" + Command = @{ value = 'Set-CIPPAuditLogUserExclusion'; label = 'Set-CIPPAuditLogUserExclusion' } + Parameters = [PSCustomObject]@{ + TenantFilter = $TenantFilter + Users = $UserUPNs + Action = 'Remove' + Type = 'Location' + } + ScheduledTime = [int64]$EndDate + PostExecution = $Request.Body.postExecution + Reference = $Request.Body.reference + }) -hidden $false + + $Result = "Successfully scheduled location alert exclusion vacation mode for $UserDisplay." + $StatusCode = [HttpStatusCode]::OK + } catch { + $ErrorMessage = Get-CippException -Exception $_ + $Result = "Failed to schedule location alert exclusion vacation mode: $($ErrorMessage.NormalizedError)" + Write-LogMessage -headers $Headers -API $APIName -message $Result -Sev Error -tenant $TenantFilter -LogData $ErrorMessage + $StatusCode = [HttpStatusCode]::InternalServerError + } + + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = @{ Results = $Result } + }) +} diff --git a/backend/Tests/DBCache/SharePointSharingLinks.Resume.Tests.ps1 b/backend/Tests/DBCache/SharePointSharingLinks.Resume.Tests.ps1 index c6d3e07e90..3ab76fd014 100644 --- a/backend/Tests/DBCache/SharePointSharingLinks.Resume.Tests.ps1 +++ b/backend/Tests/DBCache/SharePointSharingLinks.Resume.Tests.ps1 @@ -308,23 +308,22 @@ Describe 'Per-drive sharing-links scan' { } } - Context 'Principal-mode full scan of a team-site drive' { - It 'permission-reads only items whose principal count deviates and captures a delta token' { - $ScanId = 'scan-principal-1' + Context 'full scan of a team-site drive' { + It 'walks the delta ground truth, prunes stale rows and stores the token' { + $ScanId = 'scan-team-1' Initialize-TestScan -ScanId $ScanId -TotalSites 1 Add-CacheRow -RowKey 'SharePointSharingLinks-b!driveone_01GONE_permOld' Invoke-SiteAndDrives -SiteItem (New-SiteItem -ScanId $ScanId) - # Only the deviating list item (id 12) was read; its row carries the scan id. - $Rows = @((Get-FakeTableRows -TableName 'CippReportingDB') | Where-Object { $_.RowKey -like 'SharePointSharingLinks-b!driveone_01DRV12_*' }) + $Rows = @((Get-FakeTableRows -TableName 'CippReportingDB') | Where-Object { $_.RowKey -like 'SharePointSharingLinks-b!driveone_01ITEMA_*' }) $Rows.Count | Should -Be 1 $Rows[0].RunId | Should -Be $ScanId # The full-scan prune removed what this scan did not rewrite. Get-CacheRowKeys | Should -Not -Contain 'SharePointSharingLinks-b!driveone_01GONE_permOld' $DriveState = Get-CIPPSharingLinksDriveState -TenantFilter 'contoso.com' -DriveId 'b!driveone' - $DriveState.DeltaLink | Should -BeLike '*token=captured' + $DriveState.DeltaLink | Should -BeLike '*token=fresh' $DriveState.LastScanId | Should -Be $ScanId $DriveState.LastFullScanUtc | Should -Not -BeNullOrEmpty @@ -334,45 +333,12 @@ Describe 'Per-drive sharing-links scan' { } } - Context 'Principal-mode baseline detection' { - It 'derives the baseline from the dominant item count, not the inflated root permission list' { - $ScanId = 'scan-baseline-1' - Initialize-TestScan -ScanId $ScanId -TotalSites 1 - # Root carries system entries (Limited Access etc.) that items never inherit: a - # root-derived baseline of 5 would invert the filter and flag the whole library. - $script:GraphGetHandler = { - param($Uri) - if ($Uri -match '/sites/[^/]+/drives\?') { return @([pscustomobject]@{ id = 'b!driveone'; name = 'Documents'; webUrl = 'https://contoso.sharepoint.com/sites/one/Shared%20Documents' }) } - if ($Uri -match '/drives/b!driveone/list\?') { return [pscustomobject]@{ id = 'list1' } } - if ($Uri -match '/drives/b!driveone/root/permissions') { return @(1..5 | ForEach-Object { [pscustomobject]@{ id = "g$_" } }) } - if ($Uri -match '/lists/list1/items\?') { - return [pscustomobject]@{ - value = @( - [pscustomobject]@{ id = '21'; fields = [pscustomobject]@{ PrincipalCount = 4 } } - [pscustomobject]@{ id = '22'; fields = [pscustomobject]@{ PrincipalCount = 4 } } - [pscustomobject]@{ id = '23'; fields = [pscustomobject]@{ PrincipalCount = 4 } } - [pscustomobject]@{ id = '24'; fields = [pscustomobject]@{ PrincipalCount = 5 } } # the linked one - ) - } - } - if ($Uri -match 'token=latest') { return New-DeltaPage -DeltaLink 'https://graph.microsoft.com/beta/drives/b!driveone/root/delta?token=captured' } - throw "Unrouted GET: $Uri" - } - - Invoke-SiteAndDrives -SiteItem (New-SiteItem -ScanId $ScanId) - - $LinkRows = @((Get-FakeTableRows -TableName 'CippReportingDB') | Where-Object { $_.RowKey -like 'SharePointSharingLinks-b!driveone_01DRV*' }) - $LinkRows.Count | Should -Be 1 - $LinkRows[0].RowKey | Should -BeLike '*01DRV24*' - } - } - - Context 'Principal-mode scan with dropped permission reads' { + Context 'full scan with dropped permission reads' { It 'keeps existing rows and defers the sweep when batch reads are throttled away' { - $ScanId = 'scan-principal-drop-1' + $ScanId = 'scan-drop-1' Initialize-TestScan -ScanId $ScanId -TotalSites 1 Add-CacheRow -RowKey 'SharePointSharingLinks-b!driveone_01SURVIVOR_permOld' - # Every Principal-mode driveItem read comes back throttled. + # Every permission read comes back throttled inside the batch. Mock New-GraphBulkRequest { foreach ($Request in @($Requests)) { [pscustomobject]@{ id = $Request.id; status = 429; body = $null } diff --git a/backend/Tests/Endpoint/Invoke-ExecScheduleAuditExclusionVacation.Tests.ps1 b/backend/Tests/Endpoint/Invoke-ExecScheduleAuditExclusionVacation.Tests.ps1 new file mode 100644 index 0000000000..fb3a8a9d1d --- /dev/null +++ b/backend/Tests/Endpoint/Invoke-ExecScheduleAuditExclusionVacation.Tests.ps1 @@ -0,0 +1,163 @@ +# Pester tests for Invoke-ExecScheduleAuditExclusionVacation. +# +# This is Vacation Mode's location alert half. It schedules two Set-CIPPAuditLogUserExclusion +# tasks: one adding the users to the audit log location exclusion list at the start date and one +# removing them at the end date. Unlike the Conditional Access half it needs no policy at all - +# the exclusion list is a CIPP table the audit log alert engine consults - which is exactly why +# the option exists standalone: tenants without CA policies still get location alerts. + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $FunctionPath = Join-Path $RepoRoot 'Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Alerts/Invoke-ExecScheduleAuditExclusionVacation.ps1' + if (-not (Test-Path $FunctionPath)) { throw "Could not locate Invoke-ExecScheduleAuditExclusionVacation.ps1 at $FunctionPath" } + + class HttpResponseContext { + [object]$StatusCode + [object]$Body + } + $Accelerators = [PSObject].Assembly.GetType('System.Management.Automation.TypeAccelerators') + if (-not ('HttpStatusCode' -as [type])) { + $Accelerators::Add('HttpStatusCode', [System.Net.HttpStatusCode]) + } + + function Add-CIPPScheduledTask { param($Task, $hidden, $Headers, $DisallowDuplicateName) } + function Write-LogMessage { param($headers, $API, $tenant, $message, $Sev, $LogData) } + function Get-CippException { param($Exception) @{ NormalizedError = "$Exception" } } + + . $FunctionPath + + function New-VacationRequest { + param([hashtable]$Body = @{}) + $RequestBody = [pscustomobject]@{ + tenantFilter = 'contoso.com' + startDate = 1785000000 + endDate = 1786000000 + reference = 'Trip-42' + postExecution = @('Email') + Users = @( + [pscustomobject]@{ + value = 'user-guid' + addedFields = [pscustomobject]@{ userPrincipalName = 'sseck@contoso.com' } + } + ) + } + foreach ($Key in $Body.Keys) { + $RequestBody | Add-Member -NotePropertyName $Key -NotePropertyValue $Body[$Key] -Force + } + [pscustomobject]@{ + Body = $RequestBody + Headers = @{} + Params = @{ CIPPEndpoint = 'ExecScheduleAuditExclusionVacation' } + } + } +} + +Describe 'Invoke-ExecScheduleAuditExclusionVacation' { + BeforeEach { + Mock -CommandName Write-LogMessage -MockWith { } + + # Snapshot each task at call time in case the endpoint ever mutates a shared object. + $script:ScheduledTasks = [System.Collections.Generic.List[object]]::new() + Mock -CommandName Add-CIPPScheduledTask -MockWith { + $script:ScheduledTasks.Add(($Task | ConvertTo-Json -Depth 10 | ConvertFrom-Json)) + } + } + + Context 'Scheduling the exclusion either side of the trip' { + It 'adds the users to the location exclusion list at the start date' { + $null = Invoke-ExecScheduleAuditExclusionVacation -Request (New-VacationRequest) + + $AddTask = $script:ScheduledTasks | Where-Object { $_.Parameters.Action -eq 'Add' } + $AddTask | Should -Not -BeNullOrEmpty + $AddTask.Command.value | Should -Be 'Set-CIPPAuditLogUserExclusion' + $AddTask.Parameters.Users | Should -Be 'sseck@contoso.com' + $AddTask.Parameters.Type | Should -Be 'Location' + $AddTask.Parameters.TenantFilter | Should -Be 'contoso.com' + $AddTask.ScheduledTime | Should -Be 1785000000 + $AddTask.TenantFilter | Should -Be 'contoso.com' + } + + It 'removes the users from the location exclusion list at the end date' { + $null = Invoke-ExecScheduleAuditExclusionVacation -Request (New-VacationRequest) + + $RemoveTask = $script:ScheduledTasks | Where-Object { $_.Parameters.Action -eq 'Remove' } + $RemoveTask | Should -Not -BeNullOrEmpty + $RemoveTask.Command.value | Should -Be 'Set-CIPPAuditLogUserExclusion' + $RemoveTask.Parameters.Users | Should -Be 'sseck@contoso.com' + $RemoveTask.ScheduledTime | Should -Be 1786000000 + } + + It 'schedules exactly one add and one remove as visible tasks' { + # Visible because the vacation mode page lists them; a hidden remove could never be + # cancelled and a missing one leaves the alerts suppressed permanently. + $null = Invoke-ExecScheduleAuditExclusionVacation -Request (New-VacationRequest) + + Should -Invoke Add-CIPPScheduledTask -Times 2 -Exactly -ParameterFilter { $hidden -eq $false } + @($script:ScheduledTasks | Where-Object { $_.Parameters.Action -eq 'Add' }).Count | Should -Be 1 + @($script:ScheduledTasks | Where-Object { $_.Parameters.Action -eq 'Remove' }).Count | Should -Be 1 + } + + It 'names both tasks so the vacation mode page finds them via *Vacation*' { + $null = Invoke-ExecScheduleAuditExclusionVacation -Request (New-VacationRequest) + + ($script:ScheduledTasks | Where-Object { $_.Parameters.Action -eq 'Add' }).Name | + Should -Be 'Add Location Alert Exclusion Vacation Mode: sseck@contoso.com' + ($script:ScheduledTasks | Where-Object { $_.Parameters.Action -eq 'Remove' }).Name | + Should -Be 'Remove Location Alert Exclusion Vacation Mode: sseck@contoso.com' + } + + It 'carries the reference and post execution actions onto both tasks' { + $null = Invoke-ExecScheduleAuditExclusionVacation -Request (New-VacationRequest) + + foreach ($Task in $script:ScheduledTasks) { + $Task.Reference | Should -Be 'Trip-42' + $Task.PostExecution | Should -Be @('Email') + } + } + } + + Context 'Resolving who is excluded' { + It 'excludes every selected user' { + $Request = New-VacationRequest -Body @{ + Users = @( + [pscustomobject]@{ value = 'guid-1'; addedFields = [pscustomobject]@{ userPrincipalName = 'one@contoso.com' } } + [pscustomobject]@{ value = 'guid-2'; addedFields = [pscustomobject]@{ userPrincipalName = 'two@contoso.com' } } + ) + } + + $null = Invoke-ExecScheduleAuditExclusionVacation -Request $Request + + ($script:ScheduledTasks | Where-Object { $_.Parameters.Action -eq 'Add' }).Parameters.Users | + Should -Be @('one@contoso.com', 'two@contoso.com') + } + + It 'falls back to the raw value when the option has no userPrincipalName' { + $Request = New-VacationRequest -Body @{ + Users = @([pscustomobject]@{ value = 'fallback@contoso.com'; addedFields = [pscustomobject]@{} }) + } + + $null = Invoke-ExecScheduleAuditExclusionVacation -Request $Request + + ($script:ScheduledTasks | Where-Object { $_.Parameters.Action -eq 'Add' }).Parameters.Users | + Should -Be 'fallback@contoso.com' + } + } + + Context 'Failures' { + It 'schedules nothing without users' { + $Response = Invoke-ExecScheduleAuditExclusionVacation -Request (New-VacationRequest -Body @{ Users = @() }) + + $script:ScheduledTasks.Count | Should -Be 0 + $Response.StatusCode | Should -Be ([HttpStatusCode]::InternalServerError) + "$($Response.Body.Results)" | Should -BeLike '*At least one user is required*' + } + + It 'schedules nothing without both dates' { + # Half a schedule would suppress the alerts and never restore them. + $Response = Invoke-ExecScheduleAuditExclusionVacation -Request (New-VacationRequest -Body @{ endDate = $null }) + + $script:ScheduledTasks.Count | Should -Be 0 + "$($Response.Body.Results)" | Should -BeLike '*start date and end date are required*' + } + } +} diff --git a/docs/user-documentation/identity/administration/vacation-mode/add-vacation-schedule.md b/docs/user-documentation/identity/administration/vacation-mode/add-vacation-schedule.md index 6b24b14daf..5b8dbce4d9 100644 --- a/docs/user-documentation/identity/administration/vacation-mode/add-vacation-schedule.md +++ b/docs/user-documentation/identity/administration/vacation-mode/add-vacation-schedule.md @@ -1,6 +1,6 @@ # Add Vacation Schedule -This wizard schedules a set of temporary changes for one or more users and the reversal of each, so a period of absence is set up once and undone automatically. Four kinds of change are available and any combination can be used, but at least one has to be enabled before the wizard will continue. +This wizard schedules a set of temporary changes for one or more users and the reversal of each, so a period of absence is set up once and undone automatically. Five kinds of change are available and any combination can be used, but at least one has to be enabled before the wizard will continue. {% stepper %} {% step %} @@ -18,23 +18,26 @@ The users the vacation applies to. Several can be selected, and the changes belo {% step %} ### Vacation Actions -Four switches, each revealing its own settings. Enable whichever apply. +Five switches, each revealing its own settings. Enable whichever apply. #### Enable CA Policy Exclusion Excludes the selected users from Conditional Access policies for the duration. -| Field | Description | -| -------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Conditional Access Policies | The policies to exclude the users from. At least one is required. The list is read from the tenant chosen in step one, so a tenant has to be selected before it populates. | -| Exclude from location-based audit log alerts | Suppresses the alerts that would otherwise fire on sign-ins from an unusual location. | -| Create temporary travel policy | Creates a named location for the travel destination and a policy that blocks sign-ins from everywhere else, then deletes both at the end date. | -| Travel destination countries | The countries the users are travelling to. Required when a travel policy is being created. | +| Field | Description | +| ------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Conditional Access Policies | The policies to exclude the users from. At least one is required. The list is read from the tenant chosen in step one, so a tenant has to be selected before it populates. | +| Create temporary travel policy | Creates a named location for the travel destination and a policy that blocks sign-ins from everywhere else, then deletes both at the end date. | +| Travel destination countries | The countries the users are travelling to. Required when a travel policy is being created. | {% hint style="warning" %} Excluding someone from a Conditional Access policy allows sign-ins from anywhere, which is a wider gap than the trip usually warrants. The temporary travel policy is there to close it, restricting sign-ins to the destination for the same period. {% endhint %} +#### Exclude from location-based audit log alerts + +Suppresses the alerts that would otherwise fire on sign-ins from an unusual location, by putting the users on the audit log alert exclusion list at the start date and taking them off again at the end date. This stands on its own: it needs no Conditional Access policy, so tenants without any still get to quiet the alerts for the trip. + #### Enable Mailbox Permissions Grants delegates temporary access to the users' mailboxes. diff --git a/frontend/src/components/CippWizard/CippWizardVacationActions.jsx b/frontend/src/components/CippWizard/CippWizardVacationActions.jsx index 7a8db5b558..cdf8130ea2 100644 --- a/frontend/src/components/CippWizard/CippWizardVacationActions.jsx +++ b/frontend/src/components/CippWizard/CippWizardVacationActions.jsx @@ -26,10 +26,15 @@ export const CippWizardVacationActions = (props) => { const tenantDomain = currentTenant?.value || currentTenant const enableCA = useWatch({ control: formControl.control, name: 'enableCAExclusion' }) + const enableLocationAlertExclusion = useWatch({ + control: formControl.control, + name: 'excludeLocationAuditAlerts', + }) const enableMailbox = useWatch({ control: formControl.control, name: 'enableMailboxPermissions' }) const enableForwarding = useWatch({ control: formControl.control, name: 'enableForwarding' }) const enableOOO = useWatch({ control: formControl.control, name: 'enableOOO' }) - const atLeastOneEnabled = enableCA || enableMailbox || enableForwarding || enableOOO + const atLeastOneEnabled = + enableCA || enableLocationAlertExclusion || enableMailbox || enableForwarding || enableOOO const users = useWatch({ control: formControl.control, name: 'Users' }) const firstUser = Array.isArray(users) && users.length > 0 ? users[0] : null @@ -194,14 +199,6 @@ export const CippWizardVacationActions = (props) => { disabled={!tenantDomain} /> - - - { + {/* Location Alert Exclusion Section */} + + + + + + + + + + The users are added to the audit log location alert exclusion list at the start + date and removed again at the end date, so alerts that fire on sign-ins from an + unusual location stay quiet while they travel. This works on its own and does not + require a Conditional Access policy. + + + + + + {/* Mailbox Permissions Section */} { const values = useWatch({ control: formControl.control }) const caExclusion = ApiPostCall({ relatedQueryKeys: ['VacationMode'] }) + const auditExclusion = ApiPostCall({ relatedQueryKeys: ['VacationMode'] }) const mailboxVacation = ApiPostCall({ relatedQueryKeys: ['VacationMode'] }) const forwardingVacation = ApiPostCall({ relatedQueryKeys: ['VacationMode'] }) const oooVacation = ApiPostCall({ relatedQueryKeys: ['VacationMode'] }) @@ -30,11 +31,13 @@ export const CippWizardVacationConfirmation = (props) => { const tenantFilter = values.tenantFilter?.value || values.tenantFilter const isSubmitting = caExclusion.isPending || + auditExclusion.isPending || mailboxVacation.isPending || forwardingVacation.isPending || oooVacation.isPending const hasSubmitted = caExclusion.isSuccess || + auditExclusion.isSuccess || mailboxVacation.isSuccess || forwardingVacation.isSuccess || oooVacation.isSuccess @@ -55,7 +58,6 @@ export const CippWizardVacationConfirmation = (props) => { vacation: true, reference: values.reference || null, postExecution: values.postExecution || [], - excludeLocationAuditAlerts: values.excludeLocationAuditAlerts || false, // Only send the travel policy fields on the first request so the // temporary policy is scheduled once, not once per selected CA policy ...(index === 0 && createTravelPolicy @@ -69,6 +71,20 @@ export const CippWizardVacationConfirmation = (props) => { }) } + if (values.excludeLocationAuditAlerts) { + auditExclusion.mutate({ + url: '/api/ExecScheduleAuditExclusionVacation', + data: { + tenantFilter, + Users: values.Users, + startDate: values.startDate, + endDate: values.endDate, + reference: values.reference || null, + postExecution: values.postExecution || [], + }, + }) + } + if (values.enableMailboxPermissions) { mailboxVacation.mutate({ url: '/api/ExecScheduleMailboxVacation', @@ -226,6 +242,7 @@ export const CippWizardVacationConfirmation = (props) => { {(() => { const enabledCount = [ values.enableCAExclusion, + values.excludeLocationAuditAlerts, values.enableMailboxPermissions, values.enableForwarding, values.enableOOO, @@ -255,13 +272,6 @@ export const CippWizardVacationConfirmation = (props) => { : 'Not selected'}
    - {values.excludeLocationAuditAlerts && ( -
    - - Location-based audit log alerts will be excluded - -
    - )} {values.createTravelPolicy && (
    @@ -285,6 +295,24 @@ export const CippWizardVacationConfirmation = (props) => { )} + {values.excludeLocationAuditAlerts && ( + + + } + /> + + + + The users are excluded from location-based audit log alerts between the start + and end date. + + + + + )} + {values.enableMailboxPermissions && ( @@ -435,6 +463,7 @@ export const CippWizardVacationConfirmation = (props) => { {/* API Results */} {values.enableCAExclusion && } + {values.excludeLocationAuditAlerts && } {values.enableMailboxPermissions && } {values.enableForwarding && } {values.enableOOO && } diff --git a/frontend/src/pages/identity/administration/vacation-mode/index.js b/frontend/src/pages/identity/administration/vacation-mode/index.js index 69a79cae1d..766597fc8f 100644 --- a/frontend/src/pages/identity/administration/vacation-mode/index.js +++ b/frontend/src/pages/identity/administration/vacation-mode/index.js @@ -56,6 +56,11 @@ const Page = () => { value: [{ id: "Name", value: "CA Exclusion" }], type: "column", }, + { + filterName: "Location Alerts", + value: [{ id: "Name", value: "Location Alert Exclusion" }], + type: "column", + }, { filterName: "Mailbox Permissions", value: [{ id: "Name", value: "Mailbox Vacation" }], diff --git a/frontend/tests/components/CippWizard/CippWizardVacationActions.test.jsx b/frontend/tests/components/CippWizard/CippWizardVacationActions.test.jsx index 043731c434..522c8e8744 100644 --- a/frontend/tests/components/CippWizard/CippWizardVacationActions.test.jsx +++ b/frontend/tests/components/CippWizard/CippWizardVacationActions.test.jsx @@ -148,6 +148,28 @@ describe('CippWizardVacationActions', () => { ).not.toBeInTheDocument() expect(formApi.getValues('enableCAExclusion')).toBeFalsy() }) + + it('offers the location alert exclusion without Conditional Access', async () => { + // Tenants without CA policies still get location-based audit alerts, so this switch + // must stand on its own rather than hide inside the CA branch. + renderWithProviders() + + expect( + screen.getByText('Exclude from location-based audit log alerts') + ).toBeInTheDocument() + + await setField('excludeLocationAuditAlerts', true) + + await waitFor(() => + expect( + screen.getByText(/does not require a Conditional Access policy/i) + ).toBeInTheDocument() + ) + expect( + screen.queryByText(/uses group-based exclusions/i) + ).not.toBeInTheDocument() + expect(formApi.getValues('enableCAExclusion')).toBeFalsy() + }) }) // The out-of-office branch renders a rich-text editor that does not mount under jsdom From 960f81461c5759a7eb05eb74f6dcc3c8cfe0d5fb Mon Sep 17 00:00:00 2001 From: Roel van der Wegen Date: Fri, 21 Aug 2026 12:53:31 +0200 Subject: [PATCH 223/226] feat(users): add bulk action to require password change at next logon Adds ExecRequirePasswordChange so admins can set forceChangePasswordNextSignIn without resetting the password, including multi-select support. Directory-synced accounts are rejected. --- backend/Config/openapi.json | 71 +++++++++++++++++++ .../Public/Set-CIPPRequirePasswordChange.ps1 | 49 +++++++++++++ .../Invoke-ExecRequirePasswordChange.ps1 | 32 +++++++++ .../CippComponents/CippUserActions.jsx | 13 ++++ 4 files changed, 165 insertions(+) create mode 100644 backend/Modules/CIPPCore/Public/Set-CIPPRequirePasswordChange.ps1 create mode 100644 backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ExecRequirePasswordChange.ps1 diff --git a/backend/Config/openapi.json b/backend/Config/openapi.json index db770295c1..bde46dd207 100644 --- a/backend/Config/openapi.json +++ b/backend/Config/openapi.json @@ -29543,6 +29543,77 @@ "x-cipp-role": "Identity.User.ReadWrite" } }, + "/api/ExecRequirePasswordChange": { + "post": { + "summary": "ExecRequirePasswordChange", + "operationId": "ExecRequirePasswordChange", + "tags": [ + "Identity > Administration > Users" + ], + "description": "Requires password change at next sign-in without resetting the password.\nSets passwordProfile.forceChangePasswordNextSignIn via Graph. Not supported for directory-synced users.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ID": { + "type": "string" + }, + "tenantFilter": { + "type": "string" + } + }, + "required": [ + "tenantFilter" + ] + } + } + } + }, + "parameters": [ + { + "name": "ID", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "$ref": "#/components/parameters/tenantFilter" + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StandardResults" + } + } + } + }, + "401": { + "description": "Unauthorized - invalid or missing bearer token" + }, + "403": { + "description": "Forbidden - caller lacks the required RBAC role" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "x-cipp-role": "Identity.User.ReadWrite" + } + }, "/api/ExecResetMFA": { "post": { "summary": "ExecResetMFA", diff --git a/backend/Modules/CIPPCore/Public/Set-CIPPRequirePasswordChange.ps1 b/backend/Modules/CIPPCore/Public/Set-CIPPRequirePasswordChange.ps1 new file mode 100644 index 0000000000..f15fe7ae71 --- /dev/null +++ b/backend/Modules/CIPPCore/Public/Set-CIPPRequirePasswordChange.ps1 @@ -0,0 +1,49 @@ +function Set-CIPPRequirePasswordChange { + <# + .SYNOPSIS + Require (or clear) password change at next sign-in without resetting the password. + .DESCRIPTION + Sets passwordProfile.forceChangePasswordNextSignIn via Graph. Directory-synced users + are rejected: that flag is not managed for on-premises password authority. + #> + [CmdletBinding()] + param( + $UserID, + $TenantFilter, + $APIName = 'Require Password Change', + $Headers, + [bool]$ForceChangePasswordNextSignIn = $true + ) + + try { + $UserDetails = New-GraphGetRequest -uri "https://graph.microsoft.com/v1.0/users/$($UserID)?`$select=onPremisesSyncEnabled,displayName,userPrincipalName" -noPagination $true -tenantid $TenantFilter -verbose + $Label = $UserDetails.userPrincipalName ?? $UserDetails.displayName ?? $UserID + + if ($UserDetails.onPremisesSyncEnabled -eq $true) { + $Message = "Cannot set must-change-password for $Label. This user is directory synced; manage password change requirements in on-premises Active Directory." + Write-LogMessage -headers $Headers -API $APIName -message $Message -Sev 'Error' -tenant $TenantFilter + throw $Message + } + + $passwordProfile = @{ + passwordProfile = @{ + forceChangePasswordNextSignIn = $ForceChangePasswordNextSignIn + } + } | ConvertTo-Json -Compress + + $null = New-GraphPostRequest -uri "https://graph.microsoft.com/v1.0/users/$($UserID)" -tenantid $TenantFilter -type PATCH -body $passwordProfile -verbose + + $StateText = if ($ForceChangePasswordNextSignIn) { 'required' } else { 'not required' } + $Result = "Successfully set password change at next logon to $StateText for $Label" + Write-LogMessage -headers $Headers -API $APIName -message $Result -Sev 'Info' -tenant $TenantFilter + return $Result + } catch { + if ($_.Exception.Message -like 'Cannot set must-change-password*') { + throw + } + $ErrorMessage = Get-CippException -Exception $_ + $Message = "Failed to set password change at next logon for $UserID. Error: $($ErrorMessage.NormalizedError)" + Write-LogMessage -headers $Headers -API $APIName -message $Message -Sev 'Error' -tenant $TenantFilter -LogData $ErrorMessage + throw $Message + } +} diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ExecRequirePasswordChange.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ExecRequirePasswordChange.ps1 new file mode 100644 index 0000000000..1bab573c8a --- /dev/null +++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ExecRequirePasswordChange.ps1 @@ -0,0 +1,32 @@ +function Invoke-ExecRequirePasswordChange { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Identity.User.ReadWrite + .DESCRIPTION + Requires password change at next sign-in without resetting the password. + Sets passwordProfile.forceChangePasswordNextSignIn via Graph. Not supported for directory-synced users. + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers + + $TenantFilter = $Request.Query.tenantFilter ?? $Request.Body.tenantFilter + $ID = $Request.Query.ID ?? $Request.Body.ID + + try { + $Result = Set-CIPPRequirePasswordChange -UserID $ID -TenantFilter $TenantFilter -APIName $APIName -Headers $Headers -ForceChangePasswordNextSignIn $true + $StatusCode = [HttpStatusCode]::OK + } catch { + $Result = $_.Exception.Message + $StatusCode = [HttpStatusCode]::InternalServerError + } + + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = @{ 'Results' = $Result } + }) +} diff --git a/frontend/src/components/CippComponents/CippUserActions.jsx b/frontend/src/components/CippComponents/CippUserActions.jsx index df67f0ee8c..9685d57447 100644 --- a/frontend/src/components/CippComponents/CippUserActions.jsx +++ b/frontend/src/components/CippComponents/CippUserActions.jsx @@ -902,6 +902,19 @@ export const useCippUserActions = () => { multiPost: false, condition: () => canWriteUser, }, + { + label: 'Require Password Change at Next Logon', + type: 'POST', + icon: , + url: '/api/ExecRequirePasswordChange', + data: { + ID: 'id', + }, + confirmText: + 'Require [userPrincipalName] to change their password at next logon? This does not reset the password. Not supported for directory-synced accounts.', + multiPost: false, + condition: () => canWriteUser, + }, { label: 'Set Password Expiration', type: 'POST', From f49c6025d3efceffcbde332a13c922d87b42d181 Mon Sep 17 00:00:00 2001 From: Roel van der Wegen Date: Fri, 21 Aug 2026 13:31:45 +0200 Subject: [PATCH 224/226] feat(offboarding): enhance Out of Office message handling - Introduced logic to resolve Out of Office (OOO) messages only if they are not empty, preventing automatic replies from being set with empty HTML. - Updated offboarding job to use the resolved OOO message instead of the raw input. - Adjusted tests to verify that OOO messages are correctly passed into job options. - Enhanced documentation to clarify the handling of OOO messages and their configuration in user settings. --- .../Public/Invoke-CIPPOffboardingJob.ps1 | 12 +- .../CIPPCore/Public/Test-CIPPHtmlIsEmpty.ps1 | 27 +++++ .../Public/Test-CIPPOffboardingRequest.ps1 | 2 +- .../Users/Invoke-ListUserSettings.ps1 | 3 + .../Invoke-ExecOffboardUser.Tests.ps1 | 11 ++ ...-ListUserSettings.OffboardingOOO.Tests.ps1 | 109 ++++++++++++++++++ .../Invoke-CIPPOffboardingJob.OOO.Tests.ps1 | 79 +++++++++++++ .../Set-CIPPRequirePasswordChange.Tests.ps1 | 73 ++++++++++++ .../Private/Test-CIPPHtmlIsEmpty.Tests.ps1 | 27 +++++ .../Test-CIPPOffboardingRequest.OOO.Tests.ps1 | 54 +++++++++ .../administration/offboarding-wizard.md | 4 +- .../identity/administration/users/README.md | 2 +- .../shared-features/menu-bar/user-settings.md | 7 ++ .../CippOffboardingDefaultSettings.jsx | 16 ++- .../CippComponents/CippSettingsSideBar.jsx | 1 + .../CippWizard/CippWizardOffboarding.jsx | 22 ++-- .../tenant/administration/tenants/edit.js | 2 + frontend/src/pages/tenant/manage/edit.js | 2 + 18 files changed, 436 insertions(+), 17 deletions(-) create mode 100644 backend/Modules/CIPPCore/Public/Test-CIPPHtmlIsEmpty.ps1 create mode 100644 backend/Tests/Endpoint/Invoke-ListUserSettings.OffboardingOOO.Tests.ps1 create mode 100644 backend/Tests/Private/Invoke-CIPPOffboardingJob.OOO.Tests.ps1 create mode 100644 backend/Tests/Private/Set-CIPPRequirePasswordChange.Tests.ps1 create mode 100644 backend/Tests/Private/Test-CIPPHtmlIsEmpty.Tests.ps1 create mode 100644 backend/Tests/Private/Test-CIPPOffboardingRequest.OOO.Tests.ps1 diff --git a/backend/Modules/CIPPCore/Public/Invoke-CIPPOffboardingJob.ps1 b/backend/Modules/CIPPCore/Public/Invoke-CIPPOffboardingJob.ps1 index d7eef5b06c..3174219365 100644 --- a/backend/Modules/CIPPCore/Public/Invoke-CIPPOffboardingJob.ps1 +++ b/backend/Modules/CIPPCore/Public/Invoke-CIPPOffboardingJob.ps1 @@ -22,6 +22,12 @@ function Invoke-CIPPOffboardingJob { $UserID = $User.id $DisplayName = $User.displayName + # Resolve OOO once; empty TipTap HTML must not enable automatic replies + $OooMessage = $null + if (-not (Test-CIPPHtmlIsEmpty -Html ([string]$Options.OOO))) { + $OooMessage = Get-CIPPTextReplacement -TenantFilter $TenantFilter -Text $Options.OOO + } + # Build dynamic batch of offboarding tasks based on selected options $Batch = [System.Collections.Generic.List[object]]::new() @@ -117,13 +123,13 @@ function Invoke-CIPPOffboardingJob { } } @{ - Condition = { ![string]::IsNullOrEmpty($Options.OOO) } + Condition = { -not [string]::IsNullOrEmpty($OooMessage) } Cmdlet = 'Set-CIPPOutOfOffice' Parameters = @{ tenantFilter = $TenantFilter UserID = $Username - InternalMessage = $Options.OOO - ExternalMessage = $Options.OOO + InternalMessage = $OooMessage + ExternalMessage = $OooMessage APIName = $APIName state = 'Enabled' Headers = $Headers diff --git a/backend/Modules/CIPPCore/Public/Test-CIPPHtmlIsEmpty.ps1 b/backend/Modules/CIPPCore/Public/Test-CIPPHtmlIsEmpty.ps1 new file mode 100644 index 0000000000..eb9402e78e --- /dev/null +++ b/backend/Modules/CIPPCore/Public/Test-CIPPHtmlIsEmpty.ps1 @@ -0,0 +1,27 @@ +function Test-CIPPHtmlIsEmpty { + <# + .SYNOPSIS + Returns true when HTML from a rich-text editor has no meaningful content. + .DESCRIPTION + TipTap and similar editors persist empty documents as placeholder markup such as +

    or


    . Treat those the same as a blank string so callers do not + act on "empty" Out of Office messages. + .PARAMETER Html + The HTML string to inspect. + #> + [CmdletBinding()] + [OutputType([bool])] + param ( + [Parameter(Mandatory = $false)] + [AllowNull()] + [AllowEmptyString()] + [string]$Html + ) + + if ([string]::IsNullOrWhiteSpace($Html)) { + return $true + } + + $Plain = $Html -replace '(?i)', ' ' -replace '<[^>]+>', '' -replace ' ', ' ' -replace '\s+', '' + return [string]::IsNullOrEmpty($Plain) +} diff --git a/backend/Modules/CIPPCore/Public/Test-CIPPOffboardingRequest.ps1 b/backend/Modules/CIPPCore/Public/Test-CIPPOffboardingRequest.ps1 index 0b10b2bb75..0f9b81fa6b 100644 --- a/backend/Modules/CIPPCore/Public/Test-CIPPOffboardingRequest.ps1 +++ b/backend/Modules/CIPPCore/Public/Test-CIPPOffboardingRequest.ps1 @@ -85,7 +85,7 @@ function Test-CIPPOffboardingRequest { if (-not $HasAction -and -not [string]::IsNullOrWhiteSpace([string]($Body.forward.value ?? $Body.forward))) { $HasAction = $true } - if (-not $HasAction -and -not [string]::IsNullOrWhiteSpace([string]$Body.OOO)) { + if (-not $HasAction -and -not (Test-CIPPHtmlIsEmpty -Html ([string]$Body.OOO))) { $HasAction = $true } if (-not $HasAction) { diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListUserSettings.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListUserSettings.ps1 index c1f1523d52..049d046612 100644 --- a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListUserSettings.ps1 +++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListUserSettings.ps1 @@ -72,6 +72,9 @@ function Invoke-ListUserSettings { if (-not $Offboarding) { return $false } foreach ($Property in $Offboarding.PSObject.Properties) { if ($Property.Value -eq $true) { return $true } + if ($Property.Name -eq 'OOO' -and -not (Test-CIPPHtmlIsEmpty -Html ([string]$Property.Value))) { + return $true + } } return $false } diff --git a/backend/Tests/Endpoint/Invoke-ExecOffboardUser.Tests.ps1 b/backend/Tests/Endpoint/Invoke-ExecOffboardUser.Tests.ps1 index 772f6ebaaf..29c067ff56 100644 --- a/backend/Tests/Endpoint/Invoke-ExecOffboardUser.Tests.ps1 +++ b/backend/Tests/Endpoint/Invoke-ExecOffboardUser.Tests.ps1 @@ -105,6 +105,17 @@ Describe 'Invoke-ExecOffboardUser' { } } + It 'carries the Out of Office message into the job options' { + $Ooo = '

    No longer at %tenantname%.

    ' + $Request = New-OffboardRequest -Body @{ OOO = $Ooo } + + $null = Invoke-ExecOffboardUser -Request $Request + + Should -Invoke Add-CIPPScheduledTask -Times 1 -Exactly -ParameterFilter { + $Task.Parameters.options.OOO -eq $Ooo + } + } + It 'strips the routing fields out of the options payload' { # user/tenantFilter/Scheduled are how the request was addressed, not things to do. $Request = New-OffboardRequest -Body @{ Scheduled = [pscustomobject]@{ enabled = $false } } diff --git a/backend/Tests/Endpoint/Invoke-ListUserSettings.OffboardingOOO.Tests.ps1 b/backend/Tests/Endpoint/Invoke-ListUserSettings.OffboardingOOO.Tests.ps1 new file mode 100644 index 0000000000..534f2c0949 --- /dev/null +++ b/backend/Tests/Endpoint/Invoke-ListUserSettings.OffboardingOOO.Tests.ps1 @@ -0,0 +1,109 @@ +# Pester tests for OOO-only offboarding defaults in Invoke-ListUserSettings. +# A non-empty OOO on the user row must win over an all-false allUsers blob. + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $FunctionPath = Join-Path $RepoRoot 'Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListUserSettings.ps1' + $HtmlPath = Join-Path $RepoRoot 'Modules/CIPPCore/Public/Test-CIPPHtmlIsEmpty.ps1' + if (-not (Test-Path $FunctionPath)) { throw "Could not locate Invoke-ListUserSettings.ps1 at $FunctionPath" } + if (-not (Test-Path $HtmlPath)) { throw "Could not locate Test-CIPPHtmlIsEmpty.ps1 at $HtmlPath" } + + class HttpResponseContext { + [object]$StatusCode + [object]$Body + } + $Accelerators = [PSObject].Assembly.GetType('System.Management.Automation.TypeAccelerators') + if (-not ('HttpStatusCode' -as [type])) { + $Accelerators::Add('HttpStatusCode', [System.Net.HttpStatusCode]) + } + + function Get-CippTable { param($tablename) @{ TableName = $tablename } } + function Get-CIPPAzDataTableEntity { param($Context, $Filter) } + function Add-CIPPAzDataTableEntity { param($Context, $Entity, [switch]$Force) } + function Write-Warning { param($Message) } + + . $HtmlPath + . $FunctionPath + + function New-ClientPrincipalHeader { + param([string]$UserDetails = 'admin@partner.com') + $Json = (@{ userDetails = $UserDetails } | ConvertTo-Json -Compress) + [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($Json)) + } + + function New-SettingsEntity { + param([string]$RowKey, [hashtable]$OffboardingDefaults) + $Payload = @{ + direction = 'ltr' + offboardingDefaults = $OffboardingDefaults + } + [pscustomobject]@{ + PartitionKey = 'UserSettings' + RowKey = $RowKey + JSON = ($Payload | ConvertTo-Json -Depth 10 -Compress) + } + } +} + +Describe 'Invoke-ListUserSettings offboarding OOO' { + BeforeEach { + Mock -CommandName Get-CippTable -MockWith { @{ TableName = 'UserSettings' } } + Mock -CommandName Write-Warning -MockWith { } + Mock -CommandName Add-CIPPAzDataTableEntity -MockWith { } + } + + It 'treats user OOO-only defaults as configured when allUsers has no true switches' { + $AllUsers = New-SettingsEntity -RowKey 'allUsers' -OffboardingDefaults @{ + ConvertToShared = $false + RemoveGroups = $false + OOO = '

    ' + } + $UserRow = New-SettingsEntity -RowKey 'admin@partner.com' -OffboardingDefaults @{ + ConvertToShared = $false + OOO = '

    Gone from %tenantname%.

    ' + } + + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { + param($Context, $Filter) + if ($Filter -match "RowKey eq 'allUsers'") { return $AllUsers } + if ($Filter -match "RowKey eq 'admin@partner.com'") { return $UserRow } + if ($Filter -match "UserBookmarks") { return $null } + return $null + } + + $Request = [pscustomobject]@{ + Headers = @{ 'x-ms-client-principal' = (New-ClientPrincipalHeader) } + } + + $Response = Invoke-ListUserSettings -Request $Request + + $Response.StatusCode | Should -Be ([HttpStatusCode]::OK) + $Response.Body.offboardingDefaultsSource | Should -Be 'user' + $Response.Body.offboardingDefaults.OOO | Should -Be '

    Gone from %tenantname%.

    ' + } + + It 'does not treat empty TipTap OOO alone as configured on the user row' { + $AllUsers = New-SettingsEntity -RowKey 'allUsers' -OffboardingDefaults @{ + ConvertToShared = $false + OOO = '' + } + $UserRow = New-SettingsEntity -RowKey 'admin@partner.com' -OffboardingDefaults @{ + ConvertToShared = $false + OOO = '


    ' + } + + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { + param($Context, $Filter) + if ($Filter -match "RowKey eq 'allUsers'") { return $AllUsers } + if ($Filter -match "RowKey eq 'admin@partner.com'") { return $UserRow } + if ($Filter -match "UserBookmarks") { return $null } + return $null + } + + $Response = Invoke-ListUserSettings -Request ([pscustomobject]@{ + Headers = @{ 'x-ms-client-principal' = (New-ClientPrincipalHeader) } + }) + + $Response.Body.offboardingDefaultsSource | Should -Be 'allUsers' + } +} diff --git a/backend/Tests/Private/Invoke-CIPPOffboardingJob.OOO.Tests.ps1 b/backend/Tests/Private/Invoke-CIPPOffboardingJob.OOO.Tests.ps1 new file mode 100644 index 0000000000..f50315b861 --- /dev/null +++ b/backend/Tests/Private/Invoke-CIPPOffboardingJob.OOO.Tests.ps1 @@ -0,0 +1,79 @@ +# Pester tests for OOO handling in Invoke-CIPPOffboardingJob: +# - CIPP %vars% are resolved via Get-CIPPTextReplacement before Set-CIPPOutOfOffice +# - Empty TipTap HTML does not enqueue an OOO task + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $JobPath = Join-Path $RepoRoot 'Modules/CIPPCore/Public/Invoke-CIPPOffboardingJob.ps1' + $HtmlPath = Join-Path $RepoRoot 'Modules/CIPPCore/Public/Test-CIPPHtmlIsEmpty.ps1' + if (-not (Test-Path $JobPath)) { throw "Could not locate Invoke-CIPPOffboardingJob.ps1 at $JobPath" } + if (-not (Test-Path $HtmlPath)) { throw "Could not locate Test-CIPPHtmlIsEmpty.ps1 at $HtmlPath" } + + function New-GraphGetRequest { param($uri, $tenantid) } + function Get-CIPPTextReplacement { param($TenantFilter, $Text, [switch]$EscapeForJson) } + function Start-CIPPOrchestrator { param($InputObject) } + function Write-LogMessage { param($API, $tenant, $message, $sev, $headers, $LogData) } + function Get-CippException { param($Exception) @{ NormalizedError = "$Exception" } } + function Write-Information { param($MessageData) } + + . $HtmlPath + . $JobPath +} + +Describe 'Invoke-CIPPOffboardingJob OOO' { + BeforeEach { + $script:CapturedInput = $null + Mock -CommandName Write-LogMessage -MockWith { } + Mock -CommandName Write-Information -MockWith { } + Mock -CommandName New-GraphGetRequest -MockWith { + [pscustomobject]@{ + id = 'user-id-1' + displayName = 'Pat Lee' + onPremisesSyncEnabled = $false + onPremisesImmutableId = $null + } + } + Mock -CommandName Start-CIPPOrchestrator -MockWith { + $script:CapturedInput = $InputObject + 'orch-1' + } + } + + It 'resolves %vars% and passes the result to Set-CIPPOutOfOffice' { + Mock -CommandName Get-CIPPTextReplacement -MockWith { + $Text -replace '%tenantname%', 'Contoso Ltd' + } + + $Options = [pscustomobject]@{ + OOO = '

    No longer at %tenantname%.

    ' + RevokeSessions = $false + } + + $null = Invoke-CIPPOffboardingJob -TenantFilter 'contoso.com' -Username 'pat@contoso.com' -Options $Options + + Should -Invoke Get-CIPPTextReplacement -Times 1 -Exactly -ParameterFilter { + $TenantFilter -eq 'contoso.com' -and $Text -eq '

    No longer at %tenantname%.

    ' + } + + $OooTask = $script:CapturedInput.Batch | Where-Object { $_.Cmdlet -eq 'Set-CIPPOutOfOffice' } + $OooTask | Should -Not -BeNullOrEmpty + $OooTask.Parameters.InternalMessage | Should -Be '

    No longer at Contoso Ltd.

    ' + $OooTask.Parameters.ExternalMessage | Should -Be '

    No longer at Contoso Ltd.

    ' + $OooTask.Parameters.state | Should -Be 'Enabled' + } + + It 'does not enqueue Set-CIPPOutOfOffice for empty TipTap HTML' { + Mock -CommandName Get-CIPPTextReplacement -MockWith { $Text } + + $Options = [pscustomobject]@{ + OOO = '

    ' + RevokeSessions = $true + } + + $null = Invoke-CIPPOffboardingJob -TenantFilter 'contoso.com' -Username 'pat@contoso.com' -Options $Options + + Should -Invoke Get-CIPPTextReplacement -Times 0 -Exactly + $script:CapturedInput.Batch | Where-Object { $_.Cmdlet -eq 'Set-CIPPOutOfOffice' } | Should -BeNullOrEmpty + $script:CapturedInput.Batch | Where-Object { $_.Cmdlet -eq 'Revoke-CIPPSessions' } | Should -Not -BeNullOrEmpty + } +} diff --git a/backend/Tests/Private/Set-CIPPRequirePasswordChange.Tests.ps1 b/backend/Tests/Private/Set-CIPPRequirePasswordChange.Tests.ps1 new file mode 100644 index 0000000000..82f9176ec6 --- /dev/null +++ b/backend/Tests/Private/Set-CIPPRequirePasswordChange.Tests.ps1 @@ -0,0 +1,73 @@ +# Pester tests for Set-CIPPRequirePasswordChange. + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $FunctionPath = Join-Path $RepoRoot 'Modules/CIPPCore/Public/Set-CIPPRequirePasswordChange.ps1' + if (-not (Test-Path $FunctionPath)) { throw "Could not locate Set-CIPPRequirePasswordChange.ps1 at $FunctionPath" } + + function New-GraphGetRequest { param($uri, $tenantid, $noPagination, $verbose) } + function New-GraphPostRequest { param($uri, $tenantid, $type, $body, $verbose) } + function Write-LogMessage { param($headers, $API, $tenant, $message, $Sev, $LogData) } + function Get-CippException { param($Exception) @{ NormalizedError = "$Exception" } } + + . $FunctionPath +} + +Describe 'Set-CIPPRequirePasswordChange' { + BeforeEach { + Mock -CommandName Write-LogMessage -MockWith { } + Mock -CommandName Get-CippException -MockWith { @{ NormalizedError = 'graph failed' } } + } + + It 'PATCHes forceChangePasswordNextSignIn for cloud users' { + Mock -CommandName New-GraphGetRequest -MockWith { + [pscustomobject]@{ + onPremisesSyncEnabled = $false + displayName = 'Ada Lovelace' + userPrincipalName = 'ada@contoso.com' + } + } + Mock -CommandName New-GraphPostRequest -MockWith { } + + $Result = Set-CIPPRequirePasswordChange -UserID 'user-guid' -TenantFilter 'contoso.com' -ForceChangePasswordNextSignIn $true + + $Result | Should -Match 'required' + $Result | Should -Match 'ada@contoso.com' + Should -Invoke New-GraphPostRequest -Times 1 -ParameterFilter { + $type -eq 'PATCH' -and $body -match 'forceChangePasswordNextSignIn' + } + } + + It 'can clear the must-change flag' { + Mock -CommandName New-GraphGetRequest -MockWith { + [pscustomobject]@{ + onPremisesSyncEnabled = $false + displayName = 'Ada Lovelace' + userPrincipalName = 'ada@contoso.com' + } + } + $script:CapturedBody = $null + Mock -CommandName New-GraphPostRequest -MockWith { $script:CapturedBody = $body } + + $Result = Set-CIPPRequirePasswordChange -UserID 'user-guid' -TenantFilter 'contoso.com' -ForceChangePasswordNextSignIn $false + + $Result | Should -Match 'not required' + $script:CapturedBody | Should -Match '"forceChangePasswordNextSignIn":false' + } + + It 'rejects directory-synced users without PATCHing' { + Mock -CommandName New-GraphGetRequest -MockWith { + [pscustomobject]@{ + onPremisesSyncEnabled = $true + displayName = 'Synced User' + userPrincipalName = 'synced@contoso.com' + } + } + Mock -CommandName New-GraphPostRequest -MockWith { } + + { Set-CIPPRequirePasswordChange -UserID 'synced-guid' -TenantFilter 'contoso.com' } | + Should -Throw -ExpectedMessage '*directory synced*' + + Should -Invoke New-GraphPostRequest -Times 0 + } +} diff --git a/backend/Tests/Private/Test-CIPPHtmlIsEmpty.Tests.ps1 b/backend/Tests/Private/Test-CIPPHtmlIsEmpty.Tests.ps1 new file mode 100644 index 0000000000..730290306d --- /dev/null +++ b/backend/Tests/Private/Test-CIPPHtmlIsEmpty.Tests.ps1 @@ -0,0 +1,27 @@ +# Pester tests for Test-CIPPHtmlIsEmpty — TipTap empty docs must not count as content. + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $FunctionPath = Join-Path $RepoRoot 'Modules/CIPPCore/Public/Test-CIPPHtmlIsEmpty.ps1' + if (-not (Test-Path $FunctionPath)) { throw "Could not locate Test-CIPPHtmlIsEmpty.ps1 at $FunctionPath" } + . $FunctionPath +} + +Describe 'Test-CIPPHtmlIsEmpty' { + It 'treats null and whitespace as empty' { + Test-CIPPHtmlIsEmpty -Html $null | Should -BeTrue + Test-CIPPHtmlIsEmpty -Html '' | Should -BeTrue + Test-CIPPHtmlIsEmpty -Html ' ' | Should -BeTrue + } + + It 'treats TipTap placeholder markup as empty' { + Test-CIPPHtmlIsEmpty -Html '

    ' | Should -BeTrue + Test-CIPPHtmlIsEmpty -Html '


    ' | Should -BeTrue + Test-CIPPHtmlIsEmpty -Html '


    ' | Should -BeTrue + Test-CIPPHtmlIsEmpty -Html '

     

    ' | Should -BeTrue + } + + It 'treats real message HTML as not empty' { + Test-CIPPHtmlIsEmpty -Html '

    This mailbox is no longer monitored at %tenantname%.

    ' | Should -BeFalse + } +} diff --git a/backend/Tests/Private/Test-CIPPOffboardingRequest.OOO.Tests.ps1 b/backend/Tests/Private/Test-CIPPOffboardingRequest.OOO.Tests.ps1 new file mode 100644 index 0000000000..668c6d51be --- /dev/null +++ b/backend/Tests/Private/Test-CIPPOffboardingRequest.OOO.Tests.ps1 @@ -0,0 +1,54 @@ +# Pester tests for OOO / empty TipTap HTML in Test-CIPPOffboardingRequest. +# Real OOO alone must count as an action;

    must not. + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $RequestPath = Join-Path $RepoRoot 'Modules/CIPPCore/Public/Test-CIPPOffboardingRequest.ps1' + $HtmlPath = Join-Path $RepoRoot 'Modules/CIPPCore/Public/Test-CIPPHtmlIsEmpty.ps1' + if (-not (Test-Path $RequestPath)) { throw "Could not locate Test-CIPPOffboardingRequest.ps1 at $RequestPath" } + if (-not (Test-Path $HtmlPath)) { throw "Could not locate Test-CIPPHtmlIsEmpty.ps1 at $HtmlPath" } + + . $HtmlPath + . $RequestPath + + function New-ValidOffboardBody { + param([hashtable]$Extra = @{}) + $Body = [pscustomobject]@{ + tenantFilter = 'contoso.com' + user = @(@{ value = 'pat@contoso.com' }) + } + foreach ($Key in $Extra.Keys) { + $Body | Add-Member -NotePropertyName $Key -NotePropertyValue $Extra[$Key] -Force + } + $Body + } +} + +Describe 'Test-CIPPOffboardingRequest OOO' { + It 'accepts a real Out of Office message as the only action' { + $Result = Test-CIPPOffboardingRequest -Body (New-ValidOffboardBody -Extra @{ + OOO = '

    No longer at %tenantname%.

    ' + }) + + $Result.IsValid | Should -BeTrue + $Result.Errors | Should -BeNullOrEmpty + } + + It 'rejects empty TipTap HTML when no other actions are selected' { + $Result = Test-CIPPOffboardingRequest -Body (New-ValidOffboardBody -Extra @{ + OOO = '

    ' + }) + + $Result.IsValid | Should -BeFalse + $Result.Errors -join ' ' | Should -Match 'No offboarding actions' + } + + It 'rejects blank OOO when no other actions are selected' { + $Result = Test-CIPPOffboardingRequest -Body (New-ValidOffboardBody -Extra @{ + OOO = '' + }) + + $Result.IsValid | Should -BeFalse + $Result.Errors -join ' ' | Should -Match 'No offboarding actions' + } +} diff --git a/docs/user-documentation/identity/administration/offboarding-wizard.md b/docs/user-documentation/identity/administration/offboarding-wizard.md index fb92bdc5ac..a2ceffec4d 100644 --- a/docs/user-documentation/identity/administration/offboarding-wizard.md +++ b/docs/user-documentation/identity/administration/offboarding-wizard.md @@ -35,7 +35,7 @@ A summary of everything selected. Submitting creates the offboarding job. {% endstepper %} {% hint style="info" %} -The options are pre-filled from your saved offboarding defaults each time the tenant changes. A tenant with its own defaults takes precedence over your personal ones, and the wizard states which set it has applied at the top of the Offboarding Settings card. You can manage these defaults using [user-settings.md](../../shared-features/menu-bar/user-settings.md "mention"). +The options are pre-filled from your saved offboarding defaults each time the tenant changes. A tenant with its own defaults takes precedence over your personal ones (the whole tenant defaults blob wins, including an empty Out of Office message), and the wizard states which set it has applied at the top of the Offboarding Settings card. You can manage these defaults using [user-settings.md](../../shared-features/menu-bar/user-settings.md "mention"), or per tenant under Manage Tenant. {% endhint %} ## Offboarding Settings @@ -80,7 +80,7 @@ Converting a mailbox that is at or near 50 GB may fail, and a converted mailbox | Disable Email Forwarding | Clears any forwarding already set on the mailbox. Turning this on empties the forwarding fields below, since the two work against each other. | | Forward Email To | The recipient the user's mail is forwarded to. | | Keep a copy of forwarded mail | Delivers the message to the offboarded mailbox as well as forwarding it. | -| Out of Office Message | The automatic reply set on the mailbox, composed in a rich text editor. | +| Out of Office Message | The automatic reply set on the mailbox, composed in a rich text editor. Leave blank to skip. Supports CIPP `%variable%` tokens such as `%tenantname%` and tenant custom variables; they stay literal in the form and are resolved when the job runs. `%username%` is a reserved Windows-style token and is **not** replaced with the offboarded user. | {% hint style="info" %} When the account is being deleted, its OneDrive is retained for 30 days by default, so granting OneDrive access is still worth doing if the contents may be needed. diff --git a/docs/user-documentation/identity/administration/users/README.md b/docs/user-documentation/identity/administration/users/README.md index d7b3ad2043..6e8758f45e 100644 --- a/docs/user-documentation/identity/administration/users/README.md +++ b/docs/user-documentation/identity/administration/users/README.md @@ -155,7 +155,7 @@ The properties returned are for the Graph resource type `user`. For more informa ## Table Actions -
    ActionDescriptionBulk Action Available
    View UserOpens the user page for the selected user.false
    Edit UserOpens the edit.md page, where properties, licences and group memberships can be changed.false
    Create Template from UserCreates a reusable user template from this account, copying its job title, department, location, licences and group memberships. Prompts for a template name and whether the template becomes the default for the tenant.true
    Research Compromised AccountOpens the bec.md view, which gathers the common indicators of compromise for the account in one place.false
    Create Temporary Access PassIssues a time limited passcode the user can sign in with, typically to enrol a passwordless method. The lifetime is validated against the tenant's policy, one-time use can be requested, and the pass can be set to become valid at a future date and time.true
    Re-require MFA registrationClears the user's registered multi-factor methods so they must register again.true
    Send MFA PushSends an approval request to the user's registered devices, which is useful for confirming their setup works.true
    Set Per-User MFASets the legacy per-user MFA state to Enforced, Enabled or Disabled, independently of any Conditional Access policy.true
    Convert MailboxConverts the mailbox to a User, Shared, Room or Equipment mailbox, keeping its existing content.true
    Enable Online ArchiveTurns on the archive mailbox so older mail can be moved out of the primary mailbox.true
    Set Out of OfficeSets automatic replies to Enabled, Disabled or Scheduled, with separate internal and external messages. When scheduled, the period can also block the user's calendar, decline new invitations, and decline and cancel meetings already booked.true
    Add to GroupAdds the user to one or more groups in the tenant.true
    Manage LicensesAdds, removes or replaces licences on the account, with the option to remove or replace everything currently assigned.true
    Disable Email ForwardingClears any forwarding set on the mailbox, both internal and external.true
    Pre-provision OneDriveCreates the user's OneDrive ahead of their first sign-in, so it is ready when they need it.true
    Set OneDrive External SharingSets how far the user's OneDrive can be shared outside the organisation: no external sharing, signed-in guests only, anyone links, or existing guests only.true
    Add OneDrive ShortcutAdds a shortcut to a chosen SharePoint site into the user's OneDrive.true
    Set Sign In StateBlocks or restores the account's ability to sign in. The current state is pre-selected, and submitting an unchanged state is rejected.true
    Reset PasswordSets a new random password and returns it in the result, optionally requiring a change at the next sign-in.true
    Set Password ExpirationEnables or disables password expiry for the account. With expiry enabled, a password older than the organisation's expiry period prompts the user to change it at their next sign-in.true
    Clear Immutable IDClears the on-premises anchor so the account can be matched to a different directory object. Only offered for accounts that are no longer synchronised but still hold an immutable ID. Greyed out for accounts that are still synchronised, and for those with no immutable ID to clear.true
    Set Source of AuthoritySwitches the account between Cloud Managed and On-Premises Managed. Only offered for accounts that are, or once were, synchronised, and a move back to on-premises takes until the next sync cycle to appear. Greyed out for cloud-native accounts that have never been synchronised.true
    Reprocess License AssignmentsAsks Entra to re-evaluate the group-based licences that apply to the user, adding or removing licences as the group membership dictates.true
    Revoke all user sessionsInvalidates the account's refresh tokens so every device has to sign in again.true
    Delete UserDeletes the account. Deleted accounts remain recoverable from Deleted Items for 30 days.true
    Edit PropertiesOpens the patch-wizard.md with the selected users loaded, for changing the same properties across all of them.true
    More InfoOpens the Extended Info flyout with the full details for the selected row.false
    +
    ActionDescriptionBulk Action Available
    View UserOpens the user page for the selected user.false
    Edit UserOpens the edit.md page, where properties, licences and group memberships can be changed.false
    Create Template from UserCreates a reusable user template from this account, copying its job title, department, location, licences and group memberships. Prompts for a template name and whether the template becomes the default for the tenant.true
    Research Compromised AccountOpens the bec.md view, which gathers the common indicators of compromise for the account in one place.false
    Create Temporary Access PassIssues a time limited passcode the user can sign in with, typically to enrol a passwordless method. The lifetime is validated against the tenant's policy, one-time use can be requested, and the pass can be set to become valid at a future date and time.true
    Re-require MFA registrationClears the user's registered multi-factor methods so they must register again.true
    Send MFA PushSends an approval request to the user's registered devices, which is useful for confirming their setup works.true
    Set Per-User MFASets the legacy per-user MFA state to Enforced, Enabled or Disabled, independently of any Conditional Access policy.true
    Convert MailboxConverts the mailbox to a User, Shared, Room or Equipment mailbox, keeping its existing content.true
    Enable Online ArchiveTurns on the archive mailbox so older mail can be moved out of the primary mailbox.true
    Set Out of OfficeSets automatic replies to Enabled, Disabled or Scheduled, with separate internal and external messages. When scheduled, the period can also block the user's calendar, decline new invitations, and decline and cancel meetings already booked.true
    Add to GroupAdds the user to one or more groups in the tenant.true
    Manage LicensesAdds, removes or replaces licences on the account, with the option to remove or replace everything currently assigned.true
    Disable Email ForwardingClears any forwarding set on the mailbox, both internal and external.true
    Pre-provision OneDriveCreates the user's OneDrive ahead of their first sign-in, so it is ready when they need it.true
    Set OneDrive External SharingSets how far the user's OneDrive can be shared outside the organisation: no external sharing, signed-in guests only, anyone links, or existing guests only.true
    Add OneDrive ShortcutAdds a shortcut to a chosen SharePoint site into the user's OneDrive.true
    Set Sign In StateBlocks or restores the account's ability to sign in. The current state is pre-selected, and submitting an unchanged state is rejected.true
    Reset PasswordSets a new random password and returns it in the result, optionally requiring a change at the next sign-in.true
    Require Password Change at Next LogonRequires the user to change their password at next sign-in without resetting it. Not supported for directory-synced accounts.true
    Set Password ExpirationEnables or disables password expiry for the account. With expiry enabled, a password older than the organisation's expiry period prompts the user to change it at their next sign-in.true
    Clear Immutable IDClears the on-premises anchor so the account can be matched to a different directory object. Only offered for accounts that are no longer synchronised but still hold an immutable ID. Greyed out for accounts that are still synchronised, and for those with no immutable ID to clear.true
    Set Source of AuthoritySwitches the account between Cloud Managed and On-Premises Managed. Only offered for accounts that are, or once were, synchronised, and a move back to on-premises takes until the next sync cycle to appear. Greyed out for cloud-native accounts that have never been synchronised.true
    Reprocess License AssignmentsAsks Entra to re-evaluate the group-based licences that apply to the user, adding or removing licences as the group membership dictates.true
    Revoke all user sessionsInvalidates the account's refresh tokens so every device has to sign in again.true
    Delete UserDeletes the account. Deleted accounts remain recoverable from Deleted Items for 30 days.true
    Edit PropertiesOpens the patch-wizard.md with the selected users loaded, for changing the same properties across all of them.true
    More InfoOpens the Extended Info flyout with the full details for the selected row.false
    {% hint style="info" %} Most of these actions present a confirmation dialog before anything is sent, and any options the action needs are set in that dialog. diff --git a/docs/user-documentation/shared-features/menu-bar/user-settings.md b/docs/user-documentation/shared-features/menu-bar/user-settings.md index 49d248f597..093eb1c361 100644 --- a/docs/user-documentation/shared-features/menu-bar/user-settings.md +++ b/docs/user-documentation/shared-features/menu-bar/user-settings.md @@ -54,9 +54,16 @@ A label on the card indicates which defaults are currently in effect: **Using Te | Remove Teams Phone DID | Removes the phone number assigned to the user in Teams. | | Clear Immutable ID | Clears the user's immutable ID. | | Disable OneDrive Sharing Links | Disables the sharing links the user created in OneDrive. | +| Out of Office Message | Default automatic reply for offboardings. Leave blank to not set. Supports CIPP `%variable%` tokens (for example `%tenantname%` and tenant custom variables), which are resolved when the offboarding job runs. `%username%` is not the offboarded user. | + +An Out of Office message alone is enough for these defaults to count as configured for the user vs all-users precedence. A **Send results to** section chooses where the outcome of an offboarding is reported, with options for Webhook, E-mail, and PSA. +{% hint style="info" %} +If a tenant has its own offboarding defaults saved, those replace your personal defaults entirely for that tenant — including when the tenant message field is empty. +{% endhint %} + ## Portal Links Configuration Chooses which Microsoft portal shortcuts appear in the tenant information flyout. All are enabled by default; switch off any you do not use to shorten the list. diff --git a/frontend/src/components/CippComponents/CippOffboardingDefaultSettings.jsx b/frontend/src/components/CippComponents/CippOffboardingDefaultSettings.jsx index 34fefd8ab5..fc481042f6 100644 --- a/frontend/src/components/CippComponents/CippOffboardingDefaultSettings.jsx +++ b/frontend/src/components/CippComponents/CippOffboardingDefaultSettings.jsx @@ -222,7 +222,21 @@ export const CippOffboardingDefaultSettings = (props) => { ]} cardButton={ - + + Out of Office Message + + + Leave blank to not set. CIPP %variable% tokens (for example %tenantname%) are resolved + when the offboarding job runs. %username% is not the offboarded user. + + + Send results to diff --git a/frontend/src/components/CippComponents/CippSettingsSideBar.jsx b/frontend/src/components/CippComponents/CippSettingsSideBar.jsx index feaebdda60..61da427d63 100644 --- a/frontend/src/components/CippComponents/CippSettingsSideBar.jsx +++ b/frontend/src/components/CippComponents/CippSettingsSideBar.jsx @@ -108,6 +108,7 @@ export const CippSettingsSideBar = (props) => { ClearImmutableId: formValues.offboardingDefaults?.ClearImmutableId, removeCalendarPermissions: formValues.offboardingDefaults?.removeCalendarPermissions, DisableOneDriveSharing: formValues.offboardingDefaults?.DisableOneDriveSharing, + OOO: formValues.offboardingDefaults?.OOO, postExecution: { psa: formValues.offboardingDefaults?.postExecution?.psa, email: formValues.offboardingDefaults?.postExecution?.email, diff --git a/frontend/src/components/CippWizard/CippWizardOffboarding.jsx b/frontend/src/components/CippWizard/CippWizardOffboarding.jsx index 4a4948feee..eb58cd4fab 100644 --- a/frontend/src/components/CippWizard/CippWizardOffboarding.jsx +++ b/frontend/src/components/CippWizard/CippWizardOffboarding.jsx @@ -25,7 +25,8 @@ export const CippWizardOffboarding = (props) => { const currentTenant = formControl.watch('tenantFilter') const selectedUsers = useWatch({ control: formControl.control, name: 'user' }) const [showAlert, setShowAlert] = useState(false) - const userSettingsDefaults = useSettings().userSettingsDefaults + const settings = useSettings() + const userOffboardingDefaults = settings?.offboardingDefaults const disableForwarding = useWatch({ control: formControl.control, name: 'disableForwarding' }) const deleteUser = useWatch({ control: formControl.control, name: 'DeleteUser' }) const convertToShared = useWatch({ control: formControl.control, name: 'ConvertToShared' }) @@ -89,25 +90,24 @@ export const CippWizardOffboarding = (props) => { const tenantDefaults = currentTenant?.addedFields?.offboardingDefaults if (tenantDefaults) { - // Apply tenant defaults + // Apply tenant defaults; always clear OOO when the blob omits it so user defaults do not leak Object.entries(tenantDefaults).forEach(([key, value]) => { formControl.setValue(key, value) }) - // Set the source indicator + formControl.setValue('OOO', tenantDefaults.OOO ?? '') formControl.setValue('HIDDEN_defaultsSource', 'tenant') - } else if (userSettingsDefaults?.offboardingDefaults) { - // Apply user defaults if no tenant defaults - userSettingsDefaults.offboardingDefaults.forEach((setting) => { - formControl.setValue(setting.name, setting.value) + } else if (userOffboardingDefaults) { + Object.entries(userOffboardingDefaults).forEach(([key, value]) => { + formControl.setValue(key, value) }) - // Set the source indicator + formControl.setValue('OOO', userOffboardingDefaults.OOO ?? '') formControl.setValue('HIDDEN_defaultsSource', 'user') } // Mark that we've applied defaults for this tenant formControl.setValue('HIDDEN_appliedDefaultsForTenant', currentTenantId) } - }, [currentTenant?.value, userSettingsDefaults, formControl]) + }, [currentTenant?.value, userOffboardingDefaults, formControl]) useEffect(() => { if (disableForwarding) { @@ -478,6 +478,10 @@ export const CippWizardOffboarding = (props) => { fullWidth formControl={formControl} /> + + CIPP %variable% tokens (for example %tenantname%) stay literal here and are + resolved when the offboarding job runs. %username% is not the offboarded user. + {convertToShared && oversizedMailboxes.length > 0 && ( diff --git a/frontend/src/pages/tenant/administration/tenants/edit.js b/frontend/src/pages/tenant/administration/tenants/edit.js index cc6d593979..f8214e55b6 100644 --- a/frontend/src/pages/tenant/administration/tenants/edit.js +++ b/frontend/src/pages/tenant/administration/tenants/edit.js @@ -71,6 +71,7 @@ const Page = () => { ClearImmutableId: false, DisableOneDriveSharing: false, removeCalendarPermissions: false, + OOO: "", postExecution: { psa: false, email: false, @@ -122,6 +123,7 @@ const Page = () => { ClearImmutableId: false, DisableOneDriveSharing: false, removeCalendarPermissions: false, + OOO: "", postExecution: { psa: false, email: false, diff --git a/frontend/src/pages/tenant/manage/edit.js b/frontend/src/pages/tenant/manage/edit.js index a1aa8e3c88..8c8076e0d4 100644 --- a/frontend/src/pages/tenant/manage/edit.js +++ b/frontend/src/pages/tenant/manage/edit.js @@ -149,6 +149,7 @@ const Page = () => { ClearImmutableId: false, DisableOneDriveSharing: false, removeCalendarPermissions: false, + OOO: '', } let offboardingDefaults = {} @@ -190,6 +191,7 @@ const Page = () => { ClearImmutableId: false, DisableOneDriveSharing: false, removeCalendarPermissions: false, + OOO: '', } offboardingFormControl.reset({ offboardingDefaults: defaultOffboardingValues }) From b60a21624070771d02a07e67d239b307c31eae7e Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Fri, 21 Aug 2026 14:01:26 +0200 Subject: [PATCH 225/226] versions up. --- backend/version_latest.txt | 2 +- frontend/package.json | 4 ++-- frontend/public/version.json | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/backend/version_latest.txt b/backend/version_latest.txt index 8cfd6c02cf..e3cbcda795 100644 --- a/backend/version_latest.txt +++ b/backend/version_latest.txt @@ -1 +1 @@ -10.8.5 \ No newline at end of file +10.9.0 \ No newline at end of file diff --git a/frontend/package.json b/frontend/package.json index 3c6c596b1c..89ce0d7871 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "cipp", - "version": "10.8.5", + "version": "10.9.0", "author": "CIPP Contributors", "homepage": "https://cipp.app/", "bugs": { @@ -153,4 +153,4 @@ "resolutions": { "vite": "7.3.6" } -} \ No newline at end of file +} diff --git a/frontend/public/version.json b/frontend/public/version.json index 734751ab5a..b4196392f6 100644 --- a/frontend/public/version.json +++ b/frontend/public/version.json @@ -1,3 +1,3 @@ { - "version": "10.8.5" -} \ No newline at end of file + "version": "10.9.0" +} From 5b10ea4ae13bb3b2e39dab8a6250e60b6580da61 Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Fri, 21 Aug 2026 14:11:17 +0200 Subject: [PATCH 226/226] remove workflow, fix tests --- .../workflows/Check_for_Version_Update.yml | 27 ------------------- .../Set-CIPPDBCacheIntunePolicies.Tests.ps1 | 1 + ...t-CIPPIntunePolicy.AppProtection.Tests.ps1 | 2 +- 3 files changed, 2 insertions(+), 28 deletions(-) delete mode 100644 .github/workflows/Check_for_Version_Update.yml diff --git a/.github/workflows/Check_for_Version_Update.yml b/.github/workflows/Check_for_Version_Update.yml deleted file mode 100644 index c80f7390f2..0000000000 --- a/.github/workflows/Check_for_Version_Update.yml +++ /dev/null @@ -1,27 +0,0 @@ ---- -name: Check for Version Update -on: - pull_request: - branches: [master, main] - workflow_dispatch: -jobs: - build: - if: github.repository_owner == 'CyberDrain' - name: "Check for Version Update" - runs-on: ubuntu-slim - # Both actions read the PR's changed-file list; neither writes anything. - permissions: - contents: read - pull-requests: read - steps: - - name: Check for Changed Files - uses: brettcannon/check-for-changed-files@v1.2.1 - with: - file-pattern: public/version.json - failure-message: "You have not updated version.json. This is a required file to update at each PR. Please sync your latest changes and update the version number." - - name: Prevent changes to workflow files - uses: DovnarAlexander/github-action-file-detection@v0.3.0 - with: - wildcard: ".github/workflows/*.yml" - exit_code_found: 1 - exit_code_not_found: 0 diff --git a/backend/Tests/Private/Set-CIPPDBCacheIntunePolicies.Tests.ps1 b/backend/Tests/Private/Set-CIPPDBCacheIntunePolicies.Tests.ps1 index 5a279a85c8..7266d1c329 100644 --- a/backend/Tests/Private/Set-CIPPDBCacheIntunePolicies.Tests.ps1 +++ b/backend/Tests/Private/Set-CIPPDBCacheIntunePolicies.Tests.ps1 @@ -170,6 +170,7 @@ Describe 'Set-CIPPDBCacheIntunePolicies' { (Get-CIPPIntunePolicyListDefinitions).Id 'WindowsAutopilotDeploymentProfiles' 'DeviceEnrollmentConfigurations' + 'AppleUserInitiatedEnrollmentProfiles' 'DeviceManagementScripts' 'MobileApps' ) diff --git a/backend/Tests/Private/Set-CIPPIntunePolicy.AppProtection.Tests.ps1 b/backend/Tests/Private/Set-CIPPIntunePolicy.AppProtection.Tests.ps1 index 30ce1896d9..c60a8f2c8f 100644 --- a/backend/Tests/Private/Set-CIPPIntunePolicy.AppProtection.Tests.ps1 +++ b/backend/Tests/Private/Set-CIPPIntunePolicy.AppProtection.Tests.ps1 @@ -9,7 +9,7 @@ BeforeAll { # Stubs mirror the real signatures so signature drift fails loudly here. function New-GraphGETRequest { [CmdletBinding()] param($uri, $tenantid, $AsApp, $ComplexFilter) } - function New-GraphPOSTRequest { [CmdletBinding()] param($uri, $tenantid, $type, $body) } + function New-GraphPOSTRequest { [CmdletBinding()] param($uri, $tenantid, $type, $body, $AddedHeaders) } function Write-LogMessage { [CmdletBinding()] param($message, $tenant, $API, $tenantId, $headers, $user, $sev, $Sev2, $LogData) } function Get-CippException { [CmdletBinding()] param($Exception) } function Get-CIPPTextReplacement { [CmdletBinding()] param([string]$TenantFilter, $Text, [switch]$EscapeForJson) }