diff --git a/Config/BaselineStandards/Copilot (M365) Standards/CopilotLimitedMode.json b/Config/BaselineStandards/Copilot (M365) Standards/CopilotLimitedMode.json new file mode 100644 index 0000000000000..27d9612f1627d --- /dev/null +++ b/Config/BaselineStandards/Copilot (M365) Standards/CopilotLimitedMode.json @@ -0,0 +1,46 @@ +{ + "name": "CopilotLimitedMode", + "label": "Set Copilot Limited Mode", + "cat": "Copilot (M365) Standards", + "tag": [], + "impact": "Medium Impact", + "helpText": "Enables or disables Copilot limited mode, scoped to a named group when enabling.", + "executiveText": "Restricts what Copilot will discuss for a chosen population - limiting responses on sensitive topics while the organization builds its AI governance posture.", + "docsDescription": "Grades the Copilot limited mode flag and, when enabling, that the scoping group matches the configured name. The Copilot admin settings API is delegated-only. An enabled posture whose group cannot resolve reports No Data rather than grading against nothing.", + "impactColour": "warning", + "addedDate": "2026-08-16", + "powershellEquivalent": "Graph: PATCH copilot/admin/settings/limitedMode", + "recommendedBy": [], + "requiredCapabilities": [], + "disabledFeatures": { + "report": false, + "warn": false, + "remediate": false + }, + "secureScoreImpact": 0, + "compare": "subset", + "variables": { + "LimitedModeEnabled": { + "label": "Enable limited mode", + "type": "switch", + "default": false + }, + "GroupName": { + "label": "Scoping group name (required when enabling)", + "omitWhenBlank": true, + "type": "textField" + } + }, + "read": { + "requiredCaches": [ + "CopilotAdminSettings", + "Groups" + ], + "cacheType": "CopilotAdminSettings" + }, + "prepare": "Get-CIPPBaselineCopilotLimitedModeState", + "remediate": { + "executor": "CopilotLimitedMode", + "limitedModeEnabled": "%LimitedModeEnabled%" + } +} diff --git a/Config/BaselineStandards/Copilot (M365) Standards/CopilotSettings.json b/Config/BaselineStandards/Copilot (M365) Standards/CopilotSettings.json new file mode 100644 index 0000000000000..43e2a9d63aa37 --- /dev/null +++ b/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/Config/BaselineStandards/Defender Standards/AntiPhishPolicy.json b/Config/BaselineStandards/Defender Standards/AntiPhishPolicy.json new file mode 100644 index 0000000000000..ba61502c781ee --- /dev/null +++ b/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/Config/BaselineStandards/Defender Standards/AntiSpamSafeList.json b/Config/BaselineStandards/Defender Standards/AntiSpamSafeList.json new file mode 100644 index 0000000000000..2acef633ab9d4 --- /dev/null +++ b/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/Config/BaselineStandards/Defender Standards/AtpPolicyForO365.json b/Config/BaselineStandards/Defender Standards/AtpPolicyForO365.json index 2a19f98ef08c8..6063a93404e5d 100644 --- a/Config/BaselineStandards/Defender Standards/AtpPolicyForO365.json +++ b/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/Config/BaselineStandards/Defender Standards/DefenderASRPolicy.json b/Config/BaselineStandards/Defender Standards/DefenderASRPolicy.json new file mode 100644 index 0000000000000..95fb4d4756b0d --- /dev/null +++ b/Config/BaselineStandards/Defender Standards/DefenderASRPolicy.json @@ -0,0 +1,210 @@ +{ + "name": "DefenderASRPolicy", + "label": "Defender Attack Surface Reduction Rules", + "cat": "Defender Standards", + "tag": [], + "impact": "High Impact", + "helpText": "Deploys and enforces Microsoft Defender Attack Surface Reduction (ASR) rules via Intune. Controls 20 individual ASR rules that protect against common attack vectors.", + "executiveText": "Blocks the techniques malware abuses most - obfuscated scripts, Office macro exploitation, credential theft, ransomware behaviour - across every managed device.", + "docsDescription": "Grades the fixed-name 'ASR Default rules' policy: the mode plus each rule's presence in the rules group, exactly the classic's semantics - a rule present counts as enabled, a rule the baseline turns off must be absent. Remediation deletes and recreates through Set-CIPPDefenderASRPolicy.", + "impactColour": "error", + "addedDate": "2026-08-16", + "powershellEquivalent": "Graph API - deviceManagement/configurationPolicies", + "recommendedBy": [], + "requiredCapabilities": [], + "disabledFeatures": { + "report": false, + "warn": false, + "remediate": false + }, + "secureScoreImpact": 0, + "compare": "subset", + "variables": { + "Mode": { + "type": "autoComplete", + "creatable": false, + "label": "ASR Rules Mode", + "omitWhenBlank": true, + "options": [ + { + "value": "block", + "label": "Block" + }, + { + "value": "audit", + "label": "Audit" + }, + { + "value": "warn", + "label": "Warn" + } + ] + }, + "BlockObfuscatedScripts": { + "type": "switch", + "label": "Block execution of obfuscated scripts", + "omitWhenBlank": true, + "default": true + }, + "BlockAdobeChild": { + "type": "switch", + "label": "Block Adobe Reader from creating child processes", + "omitWhenBlank": true, + "default": true + }, + "BlockWin32Macro": { + "type": "switch", + "label": "Block Win32 API calls from Office macros", + "omitWhenBlank": true, + "default": true + }, + "BlockCredentialStealing": { + "type": "switch", + "label": "Block credential stealing from LSASS", + "omitWhenBlank": true, + "default": true + }, + "BlockPSExec": { + "type": "switch", + "label": "Block process creations from PSExec and WMI", + "omitWhenBlank": true, + "default": true + }, + "WMIPersistence": { + "type": "switch", + "label": "Block persistence through WMI event subscription", + "omitWhenBlank": true, + "default": true + }, + "BlockSystemTools": { + "type": "switch", + "label": "Block use of copied or impersonated system tools", + "omitWhenBlank": true, + "default": true + }, + "BlockOfficeExes": { + "type": "switch", + "label": "Block Office apps from creating executable content", + "omitWhenBlank": true, + "default": true + }, + "BlockOfficeApps": { + "type": "switch", + "label": "Block Office apps from injecting code into other processes", + "omitWhenBlank": true, + "default": true + }, + "BlockSafeMode": { + "type": "switch", + "label": "Block rebooting machine in safe mode", + "omitWhenBlank": true, + "default": false + }, + "BlockYoungExe": { + "type": "switch", + "label": "Block executables that do not meet prevalence/age/trusted list criteria", + "omitWhenBlank": true, + "default": true + }, + "blockJSVB": { + "type": "switch", + "label": "Block JavaScript or VBScript from launching downloads", + "omitWhenBlank": true, + "default": true + }, + "BlockWebshellForServers": { + "type": "switch", + "label": "Block webshell creation for servers", + "omitWhenBlank": true, + "default": true + }, + "blockOfficeComChild": { + "type": "switch", + "label": "Block Office Communication app child processes", + "omitWhenBlank": true, + "default": true + }, + "blockOfficeChild": { + "type": "switch", + "label": "Block all Office apps from creating child processes", + "omitWhenBlank": true, + "default": true + }, + "BlockUntrustedUSB": { + "type": "switch", + "label": "Block untrusted/unsigned processes from USB", + "omitWhenBlank": true, + "default": true + }, + "EnableRansomwareVac": { + "type": "switch", + "label": "Use advanced protection against ransomware", + "omitWhenBlank": true, + "default": true + }, + "BlockExesMail": { + "type": "switch", + "label": "Block executable content from email client and webmail", + "omitWhenBlank": true, + "default": true + }, + "BlockUnsignedDrivers": { + "type": "switch", + "label": "Block abuse of exploited vulnerable signed drivers", + "omitWhenBlank": true, + "default": true + }, + "AssignTo": { + "type": "autoComplete", + "creatable": false, + "label": "Policy Assignment", + "omitWhenBlank": true, + "options": [ + { + "value": "none", + "label": "Do not assign" + }, + { + "value": "allLicensedUsers", + "label": "All users" + }, + { + "value": "AllDevices", + "label": "All devices" + }, + { + "value": "AllDevicesAndUsers", + "label": "All users and devices" + } + ] + } + }, + "read": { + "cacheType": "IntuneConfigurationPolicies" + }, + "prepare": "Get-CIPPBaselineDefenderASRPolicyState", + "remediate": { + "executor": "DefenderASRPolicy", + "mode": "%Mode%", + "blockObfuscatedScripts": "%BlockObfuscatedScripts%", + "blockAdobeChild": "%BlockAdobeChild%", + "blockWin32Macro": "%BlockWin32Macro%", + "blockCredentialStealing": "%BlockCredentialStealing%", + "blockPSExec": "%BlockPSExec%", + "wmiPersistence": "%WMIPersistence%", + "blockSystemTools": "%BlockSystemTools%", + "blockOfficeExes": "%BlockOfficeExes%", + "blockOfficeApps": "%BlockOfficeApps%", + "blockSafeMode": "%BlockSafeMode%", + "blockYoungExe": "%BlockYoungExe%", + "blockJSVB": "%blockJSVB%", + "blockWebshellForServers": "%BlockWebshellForServers%", + "blockOfficeComChild": "%blockOfficeComChild%", + "blockOfficeChild": "%blockOfficeChild%", + "blockUntrustedUSB": "%BlockUntrustedUSB%", + "enableRansomwareVac": "%EnableRansomwareVac%", + "blockExesMail": "%BlockExesMail%", + "blockUnsignedDrivers": "%BlockUnsignedDrivers%", + "assignTo": "%AssignTo%" + } +} diff --git a/Config/BaselineStandards/Defender Standards/DefenderAVPolicy.json b/Config/BaselineStandards/Defender Standards/DefenderAVPolicy.json new file mode 100644 index 0000000000000..3ded704126208 --- /dev/null +++ b/Config/BaselineStandards/Defender Standards/DefenderAVPolicy.json @@ -0,0 +1,413 @@ +{ + "name": "DefenderAVPolicy", + "label": "Defender Antivirus Policy", + "cat": "Defender Standards", + "tag": [], + "impact": "High Impact", + "helpText": "Deploys and enforces a Microsoft Defender Antivirus configuration policy via Intune. Controls scanning behaviour, real-time protection, cloud protection, network protection, signature updates, CPU priority, and threat remediation actions.", + "executiveText": "Ensures every company device runs a consistent, hardened antivirus configuration, protecting against malware while balancing performance.", + "docsDescription": "Grades the fixed-name 'Default AV Policy' settings catalog policy field by field from the cached settingInstance tree. The four threat remediation actions and the four choice settings (network protection, cloud block level, on-access protection, sample consent) grade only when configured, because the recreate omits blank ones; blank integer fields grade the write-side defaults (50/8/0). Remediation deletes the drifted policy and recreates it through the Set-CIPPDefenderAVPolicy helper - settings catalog policies replace whole, never patch.", + "impactColour": "error", + "addedDate": "2026-08-16", + "powershellEquivalent": "Graph API - deviceManagement/configurationPolicies", + "recommendedBy": [], + "requiredCapabilities": [], + "disabledFeatures": { + "report": false, + "warn": false, + "remediate": false + }, + "secureScoreImpact": 0, + "compare": "subset", + "variables": { + "ScanArchives": { + "type": "switch", + "label": "Archive Scanning", + "omitWhenBlank": true, + "default": true + }, + "AllowBehavior": { + "type": "switch", + "label": "Behavior Monitoring", + "omitWhenBlank": true, + "default": true + }, + "AllowCloudProtection": { + "type": "switch", + "label": "Cloud Protection", + "omitWhenBlank": true, + "default": true + }, + "AllowEmailScanning": { + "type": "switch", + "label": "Email Scanning", + "omitWhenBlank": true, + "default": false + }, + "AllowFullScanNetwork": { + "type": "switch", + "label": "Full Scan on Network Drives", + "omitWhenBlank": true, + "default": false + }, + "AllowFullScanRemovable": { + "type": "switch", + "label": "Full Scan on Removable Drives", + "omitWhenBlank": true, + "default": true + }, + "AllowScriptScan": { + "type": "switch", + "label": "Script Scanning", + "omitWhenBlank": true, + "default": true + }, + "AllowDownloadable": { + "type": "switch", + "label": "Downloads Scanning (IOAV Protection)", + "omitWhenBlank": true, + "default": true + }, + "AllowRealTime": { + "type": "switch", + "label": "Real-time Monitoring", + "omitWhenBlank": true, + "default": true + }, + "AllowNetwork": { + "type": "switch", + "label": "Network Files Scanning", + "omitWhenBlank": true, + "default": false + }, + "AllowUI": { + "type": "switch", + "label": "User UI Access", + "omitWhenBlank": true, + "default": true + }, + "CheckSigs": { + "type": "switch", + "label": "Check Signatures Before Scan", + "omitWhenBlank": true, + "default": false + }, + "DisableCatchupFullScan": { + "type": "switch", + "label": "Disable Catchup Full Scan", + "omitWhenBlank": true, + "default": true + }, + "DisableCatchupQuickScan": { + "type": "switch", + "label": "Disable Catchup Quick Scan", + "omitWhenBlank": true, + "default": true + }, + "LowCPU": { + "type": "switch", + "label": "Low CPU Priority", + "omitWhenBlank": true, + "default": true + }, + "MeteredConnectionUpdates": { + "type": "switch", + "label": "Metered Connection Updates", + "omitWhenBlank": true, + "default": false + }, + "DisableLocalAdminMerge": { + "type": "switch", + "label": "Disable Local Admin Merge", + "omitWhenBlank": true, + "default": true + }, + "AvgCPULoadFactor": { + "type": "number", + "label": "Avg CPU Load Factor (%) (0-100, default 50)", + "omitWhenBlank": true + }, + "SignatureUpdateInterval": { + "type": "number", + "label": "Signature Update Interval (hours) (0-24, default 8)", + "omitWhenBlank": true + }, + "CloudExtendedTimeout": { + "type": "number", + "label": "Cloud Extended Timeout (seconds) (0-50, default 0)", + "omitWhenBlank": true + }, + "AllowOnAccessProtection": { + "type": "autoComplete", + "creatable": false, + "label": "Allow On Access Protection", + "omitWhenBlank": true, + "options": [ + { + "value": "0", + "label": "Not Allowed" + }, + { + "value": "1", + "label": "Allowed (Default)" + } + ] + }, + "SubmitSamplesConsent": { + "type": "autoComplete", + "creatable": false, + "label": "Submit Samples Consent", + "omitWhenBlank": true, + "options": [ + { + "value": "0", + "label": "Always prompt" + }, + { + "value": "1", + "label": "Send safe samples automatically (Default)" + }, + { + "value": "2", + "label": "Never send" + }, + { + "value": "3", + "label": "Send all samples automatically" + } + ] + }, + "EnableNetworkProtection": { + "type": "autoComplete", + "creatable": false, + "label": "Network Protection", + "omitWhenBlank": true, + "options": [ + { + "value": "0", + "label": "Disabled (Default)" + }, + { + "value": "1", + "label": "Block mode" + }, + { + "value": "2", + "label": "Audit mode" + } + ] + }, + "CloudBlockLevel": { + "type": "autoComplete", + "creatable": false, + "label": "Cloud Block Level", + "omitWhenBlank": true, + "options": [ + { + "value": "0", + "label": "Default" + }, + { + "value": "2", + "label": "High" + }, + { + "value": "4", + "label": "High Plus" + }, + { + "value": "6", + "label": "Zero Tolerance" + } + ] + }, + "RemediationLow": { + "type": "autoComplete", + "creatable": false, + "label": "Threat Remediation - Low Severity", + "omitWhenBlank": true, + "options": [ + { + "value": "clean", + "label": "Clean" + }, + { + "value": "quarantine", + "label": "Quarantine" + }, + { + "value": "remove", + "label": "Remove" + }, + { + "value": "allow", + "label": "Allow" + }, + { + "value": "userDefined", + "label": "User Defined" + }, + { + "value": "block", + "label": "Block" + } + ] + }, + "RemediationModerate": { + "type": "autoComplete", + "creatable": false, + "label": "Threat Remediation - Moderate Severity", + "omitWhenBlank": true, + "options": [ + { + "value": "clean", + "label": "Clean" + }, + { + "value": "quarantine", + "label": "Quarantine" + }, + { + "value": "remove", + "label": "Remove" + }, + { + "value": "allow", + "label": "Allow" + }, + { + "value": "userDefined", + "label": "User Defined" + }, + { + "value": "block", + "label": "Block" + } + ] + }, + "RemediationHigh": { + "type": "autoComplete", + "creatable": false, + "label": "Threat Remediation - High Severity", + "omitWhenBlank": true, + "options": [ + { + "value": "clean", + "label": "Clean" + }, + { + "value": "quarantine", + "label": "Quarantine" + }, + { + "value": "remove", + "label": "Remove" + }, + { + "value": "allow", + "label": "Allow" + }, + { + "value": "userDefined", + "label": "User Defined" + }, + { + "value": "block", + "label": "Block" + } + ] + }, + "RemediationSevere": { + "type": "autoComplete", + "creatable": false, + "label": "Threat Remediation - Severe", + "omitWhenBlank": true, + "options": [ + { + "value": "clean", + "label": "Clean" + }, + { + "value": "quarantine", + "label": "Quarantine" + }, + { + "value": "remove", + "label": "Remove" + }, + { + "value": "allow", + "label": "Allow" + }, + { + "value": "userDefined", + "label": "User Defined" + }, + { + "value": "block", + "label": "Block" + } + ] + }, + "AssignTo": { + "type": "autoComplete", + "creatable": false, + "label": "Policy Assignment", + "omitWhenBlank": true, + "options": [ + { + "value": "none", + "label": "Do not assign" + }, + { + "value": "allLicensedUsers", + "label": "All users" + }, + { + "value": "AllDevices", + "label": "All devices" + }, + { + "value": "AllDevicesAndUsers", + "label": "All users and devices" + } + ] + } + }, + "read": { + "cacheType": "IntuneConfigurationPolicies" + }, + "prepare": "Get-CIPPBaselineDefenderAVPolicyState", + "remediate": { + "executor": "DefenderAVPolicy", + "scanArchives": "%ScanArchives%", + "allowBehavior": "%AllowBehavior%", + "allowCloudProtection": "%AllowCloudProtection%", + "allowEmailScanning": "%AllowEmailScanning%", + "allowFullScanNetwork": "%AllowFullScanNetwork%", + "allowFullScanRemovable": "%AllowFullScanRemovable%", + "allowScriptScan": "%AllowScriptScan%", + "allowDownloadable": "%AllowDownloadable%", + "allowRealTime": "%AllowRealTime%", + "allowNetwork": "%AllowNetwork%", + "allowUI": "%AllowUI%", + "checkSigs": "%CheckSigs%", + "disableCatchupFullScan": "%DisableCatchupFullScan%", + "disableCatchupQuickScan": "%DisableCatchupQuickScan%", + "lowCPU": "%LowCPU%", + "meteredConnectionUpdates": "%MeteredConnectionUpdates%", + "disableLocalAdminMerge": "%DisableLocalAdminMerge%", + "avgCPULoadFactor": "%AvgCPULoadFactor%", + "signatureUpdateInterval": "%SignatureUpdateInterval%", + "cloudExtendedTimeout": "%CloudExtendedTimeout%", + "allowOnAccessProtection": "%AllowOnAccessProtection%", + "submitSamplesConsent": "%SubmitSamplesConsent%", + "enableNetworkProtection": "%EnableNetworkProtection%", + "cloudBlockLevel": "%CloudBlockLevel%", + "remediationLow": "%RemediationLow%", + "remediationModerate": "%RemediationModerate%", + "remediationHigh": "%RemediationHigh%", + "remediationSevere": "%RemediationSevere%", + "assignTo": "%AssignTo%" + } +} diff --git a/Config/BaselineStandards/Defender Standards/DefenderCompliancePolicy.json b/Config/BaselineStandards/Defender Standards/DefenderCompliancePolicy.json new file mode 100644 index 0000000000000..2aa8e13fc81f0 --- /dev/null +++ b/Config/BaselineStandards/Defender Standards/DefenderCompliancePolicy.json @@ -0,0 +1,155 @@ +{ + "name": "DefenderCompliancePolicy", + "label": "Defender for Endpoint - Intune Compliance Connector", + "cat": "Defender Standards", + "tag": [], + "impact": "High Impact", + "helpText": "Configures the Microsoft Defender for Endpoint connector with Intune, enabling compliance evaluation for mobile and desktop platforms and controlling partner-data blocking per platform.", + "executiveText": "Establishes the link between Microsoft Defender for Endpoint and Intune so device risk data feeds compliance policies - a foundational Zero Trust control.", + "docsDescription": "Reads the MDE mobile threat defense connector live (one small singleton, no cache) and grades every platform toggle. Two classic rules hold: connecting Windows forces the Windows partner-data block on (Microsoft enforces it), and the MDE attach flag always grades true. Remediation enables the connector first, then writes the FULL settings object.", + "impactColour": "error", + "addedDate": "2026-08-16", + "powershellEquivalent": "Graph API - deviceManagement/mobileThreatDefenseConnectors", + "recommendedBy": [], + "requiredCapabilities": [], + "disabledFeatures": { + "report": false, + "warn": false, + "remediate": false + }, + "secureScoreImpact": 0, + "compare": "subset", + "variables": { + "ConnectAndroid": { + "type": "switch", + "label": "Connect Android devices to MDE", + "omitWhenBlank": true, + "default": false + }, + "ConnectAndroidCompliance": { + "type": "switch", + "label": "Connect Android 6.0.0+ (App-based MAM)", + "omitWhenBlank": true, + "default": false + }, + "androidDeviceBlockedOnMissingPartnerData": { + "type": "switch", + "label": "Block Android if partner data unavailable", + "omitWhenBlank": true, + "default": false + }, + "grantMobileThreatDefensePartnerRole": { + "type": "switch", + "label": "Grant MTD role to MDE on enrolled Android COBO/COPE devices", + "omitWhenBlank": true, + "default": false + }, + "ConnectIos": { + "type": "switch", + "label": "Connect iOS/iPadOS devices to MDE", + "omitWhenBlank": true, + "default": false + }, + "ConnectIosCompliance": { + "type": "switch", + "label": "Connect iOS/iPadOS devices for app protection policy evaluation (MAM)", + "omitWhenBlank": true, + "default": false + }, + "appSync": { + "type": "switch", + "label": "Enable App Sync for iOS/iPadOS devices", + "omitWhenBlank": true, + "default": false + }, + "allowPartnerToCollectIosPersonalApplicationMetadata": { + "type": "switch", + "label": "Send full application inventory on personally-owned iOS/iPadOS devices", + "omitWhenBlank": true, + "default": false + }, + "iosDeviceBlockedOnMissingPartnerData": { + "type": "switch", + "label": "Block iOS if partner data unavailable", + "omitWhenBlank": true, + "default": false + }, + "allowPartnerToCollectIosCertificateMetadata": { + "type": "switch", + "label": "Enable Certificate Sync for iOS/iPadOS devices", + "omitWhenBlank": true, + "default": false + }, + "allowPartnerToCollectIosPersonalCertificateMetadata": { + "type": "switch", + "label": "Send full certificate inventory on personally-owned iOS/iPadOS devices", + "omitWhenBlank": true, + "default": false + }, + "ConnectMac": { + "type": "switch", + "label": "Connect macOS devices to MDE", + "omitWhenBlank": true, + "default": false + }, + "macDeviceBlockedOnMissingPartnerData": { + "type": "switch", + "label": "Block macOS if partner data unavailable", + "omitWhenBlank": true, + "default": false + }, + "ConnectWindows": { + "type": "switch", + "label": "Connect Windows 10.0.15063+ to MDE (forces the Windows partner-data block on)", + "omitWhenBlank": true, + "default": false + }, + "windowsMobileApplicationManagementEnabled": { + "type": "switch", + "label": "Connect Windows (MAM)", + "omitWhenBlank": true, + "default": false + }, + "windowsDeviceBlockedOnMissingPartnerData": { + "type": "switch", + "label": "Block Windows if partner data unavailable", + "omitWhenBlank": true, + "default": false + }, + "BlockunsupportedOS": { + "type": "switch", + "label": "Block unsupported OS versions", + "omitWhenBlank": true, + "default": false + }, + "AllowMEMEnforceCompliance": { + "type": "switch", + "label": "Allow MEM enforcement of compliance", + "omitWhenBlank": true, + "default": false + } + }, + "read": {}, + "prepare": "Get-CIPPBaselineDefenderCompliancePolicyState", + "remediate": { + "executor": "DefenderCompliancePolicy", + "connectAndroid": "%ConnectAndroid%", + "connectAndroidCompliance": "%ConnectAndroidCompliance%", + "androidDeviceBlockedOnMissingPartnerData": "%androidDeviceBlockedOnMissingPartnerData%", + "grantMobileThreatDefensePartnerRole": "%grantMobileThreatDefensePartnerRole%", + "connectIos": "%ConnectIos%", + "connectIosCompliance": "%ConnectIosCompliance%", + "appSync": "%appSync%", + "allowPartnerToCollectIosPersonalApplicationMetadata": "%allowPartnerToCollectIosPersonalApplicationMetadata%", + "iosDeviceBlockedOnMissingPartnerData": "%iosDeviceBlockedOnMissingPartnerData%", + "allowPartnerToCollectIosCertificateMetadata": "%allowPartnerToCollectIosCertificateMetadata%", + "allowPartnerToCollectIosPersonalCertificateMetadata": "%allowPartnerToCollectIosPersonalCertificateMetadata%", + "connectMac": "%ConnectMac%", + "macDeviceBlockedOnMissingPartnerData": "%macDeviceBlockedOnMissingPartnerData%", + "connectWindows": "%ConnectWindows%", + "windowsMobileApplicationManagementEnabled": "%windowsMobileApplicationManagementEnabled%", + "windowsDeviceBlockedOnMissingPartnerData": "%windowsDeviceBlockedOnMissingPartnerData%", + "blockUnsupportedOS": "%BlockunsupportedOS%", + "allowMEMEnforceCompliance": "%AllowMEMEnforceCompliance%" + } +} diff --git a/Config/BaselineStandards/Defender Standards/DefenderEDRPolicy.json b/Config/BaselineStandards/Defender Standards/DefenderEDRPolicy.json new file mode 100644 index 0000000000000..f58a7ca4bc414 --- /dev/null +++ b/Config/BaselineStandards/Defender Standards/DefenderEDRPolicy.json @@ -0,0 +1,70 @@ +{ + "name": "DefenderEDRPolicy", + "label": "Defender EDR Configuration", + "cat": "Defender Standards", + "tag": [], + "impact": "High Impact", + "helpText": "Deploys and enforces a Microsoft Defender for Endpoint EDR configuration policy via Intune. Controls auto-configuration from the MDE connector and sample sharing.", + "executiveText": "Ensures consistent Endpoint Detection and Response onboarding across managed Windows devices, so security teams see every endpoint.", + "docsDescription": "Grades the fixed-name 'EDR Configuration' policy: sample sharing and whether the configuration type is auto-from-connector. Remediation deletes and recreates through Set-CIPPDefenderEDRPolicy.", + "impactColour": "error", + "addedDate": "2026-08-16", + "powershellEquivalent": "Graph API - deviceManagement/configurationPolicies", + "recommendedBy": [], + "requiredCapabilities": [], + "disabledFeatures": { + "report": false, + "warn": false, + "remediate": false + }, + "secureScoreImpact": 0, + "compare": "subset", + "variables": { + "Config": { + "type": "switch", + "label": "Auto-configure from MDE connector", + "omitWhenBlank": true, + "default": true + }, + "SampleSharing": { + "type": "switch", + "label": "Enable sample sharing", + "omitWhenBlank": true, + "default": true + }, + "AssignTo": { + "type": "autoComplete", + "creatable": false, + "label": "Policy Assignment", + "omitWhenBlank": true, + "options": [ + { + "value": "none", + "label": "Do not assign" + }, + { + "value": "allLicensedUsers", + "label": "All users" + }, + { + "value": "AllDevices", + "label": "All devices" + }, + { + "value": "AllDevicesAndUsers", + "label": "All users and devices" + } + ] + } + }, + "read": { + "cacheType": "IntuneConfigurationPolicies" + }, + "prepare": "Get-CIPPBaselineDefenderEDRPolicyState", + "remediate": { + "executor": "DefenderEDRPolicy", + "config": "%Config%", + "sampleSharing": "%SampleSharing%", + "assignTo": "%AssignTo%" + } +} diff --git a/Config/BaselineStandards/Defender Standards/DefenderExclusionPolicy.json b/Config/BaselineStandards/Defender Standards/DefenderExclusionPolicy.json new file mode 100644 index 0000000000000..3bc9d030f7c28 --- /dev/null +++ b/Config/BaselineStandards/Defender Standards/DefenderExclusionPolicy.json @@ -0,0 +1,74 @@ +{ + "name": "DefenderExclusionPolicy", + "label": "Defender AV Exclusion Policy", + "cat": "Defender Standards", + "tag": [], + "impact": "Medium Impact", + "helpText": "Deploys and enforces a Microsoft Defender Antivirus exclusion policy via Intune for file extensions, paths, and processes.", + "executiveText": "Keeps Defender exclusions consistent and centrally managed, preventing performance issues and false positives without ad-hoc local exclusions.", + "docsDescription": "Grades the fixed-name 'Default AV Exclusion Policy': the three exclusion collections as sorted sets, so order never matters and any missing or extra entry is drift. Remediation deletes and recreates through Set-CIPPDefenderExclusionPolicy with only the configured collections.", + "impactColour": "warning", + "addedDate": "2026-08-16", + "powershellEquivalent": "Graph API - deviceManagement/configurationPolicies", + "recommendedBy": [], + "requiredCapabilities": [], + "disabledFeatures": { + "report": false, + "warn": false, + "remediate": false + }, + "secureScoreImpact": 0, + "compare": "subset", + "variables": { + "excludedExtensions": { + "type": "textField", + "label": "Excluded Extensions (comma-separated, e.g. txt,log,tmp)", + "omitWhenBlank": true + }, + "excludedPaths": { + "type": "textField", + "label": "Excluded Paths (comma-separated, e.g. C:\\Temp)", + "omitWhenBlank": true + }, + "excludedProcesses": { + "type": "textField", + "label": "Excluded Processes (comma-separated, e.g. notepad.exe)", + "omitWhenBlank": true + }, + "AssignTo": { + "type": "autoComplete", + "creatable": false, + "label": "Policy Assignment", + "omitWhenBlank": true, + "options": [ + { + "value": "none", + "label": "Do not assign" + }, + { + "value": "allLicensedUsers", + "label": "All users" + }, + { + "value": "AllDevices", + "label": "All devices" + }, + { + "value": "AllDevicesAndUsers", + "label": "All users and devices" + } + ] + } + }, + "read": { + "cacheType": "IntuneConfigurationPolicies" + }, + "prepare": "Get-CIPPBaselineDefenderExclusionPolicyState", + "remediate": { + "executor": "DefenderExclusionPolicy", + "excludedExtensions": "%excludedExtensions%", + "excludedPaths": "%excludedPaths%", + "excludedProcesses": "%excludedProcesses%", + "assignTo": "%AssignTo%" + } +} diff --git a/Config/BaselineStandards/Defender Standards/EmptyFilterIPAllowList.json b/Config/BaselineStandards/Defender Standards/EmptyFilterIPAllowList.json new file mode 100644 index 0000000000000..ce6bdd83ebc73 --- /dev/null +++ b/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/Config/BaselineStandards/Defender Standards/MalwareFilterPolicy.json b/Config/BaselineStandards/Defender Standards/MalwareFilterPolicy.json new file mode 100644 index 0000000000000..ca9397d2860e8 --- /dev/null +++ b/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/Config/BaselineStandards/Defender Standards/QuarantineRequestAlert.json b/Config/BaselineStandards/Defender Standards/QuarantineRequestAlert.json new file mode 100644 index 0000000000000..fd5d6def28f81 --- /dev/null +++ b/Config/BaselineStandards/Defender Standards/QuarantineRequestAlert.json @@ -0,0 +1,68 @@ +{ + "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. 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. 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, Set-ProtectionAlert and Remove-ProtectionAlert", + "recommendedBy": [], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ], + "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": { + "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 + } + }, + "read": { + "cacheType": "ExoProtectionAlert", + "filter": [ + { + "property": "Name", + "value": "CIPP User requested to release a quarantined message" + } + ] + }, + "remediate": { + "executor": "QuarantineRequestAlert", + "state": "%State%", + "notifyUser": "%NotifyUser%", + "allowExtraAddresses": "%AllowExtraAddresses%" + }, + "prepare": "Get-CIPPBaselineQuarantineRequestAlertState", + "expected": { + "NotifyUserPresent": true + } +} diff --git a/Config/BaselineStandards/Defender Standards/SafeAttachmentPolicy.json b/Config/BaselineStandards/Defender Standards/SafeAttachmentPolicy.json new file mode 100644 index 0000000000000..2ddc6d13407e5 --- /dev/null +++ b/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/Config/BaselineStandards/Defender Standards/SafeLinksPolicy.json b/Config/BaselineStandards/Defender Standards/SafeLinksPolicy.json new file mode 100644 index 0000000000000..0fba393249114 --- /dev/null +++ b/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/Config/BaselineStandards/Defender Standards/SharePointMassDeletionAlert.json b/Config/BaselineStandards/Defender Standards/SharePointMassDeletionAlert.json new file mode 100644 index 0000000000000..d2fe941eb9ef6 --- /dev/null +++ b/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/Config/BaselineStandards/Defender Standards/SpamFilterPolicy.json b/Config/BaselineStandards/Defender Standards/SpamFilterPolicy.json new file mode 100644 index 0000000000000..165fade56cdd8 --- /dev/null +++ b/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/Config/BaselineStandards/Defender Standards/TeamsZAP.json b/Config/BaselineStandards/Defender Standards/TeamsZAP.json new file mode 100644 index 0000000000000..32d80145e59ed --- /dev/null +++ b/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/Config/BaselineStandards/Entra (AAD) Standards/AdminSSPR.json b/Config/BaselineStandards/Entra (AAD) Standards/AdminSSPR.json index c4a4c5db00461..66a49b9e5d8ec 100644 --- a/Config/BaselineStandards/Entra (AAD) Standards/AdminSSPR.json +++ b/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/Config/BaselineStandards/Entra (AAD) Standards/AppDeploy.json b/Config/BaselineStandards/Entra (AAD) Standards/AppDeploy.json new file mode 100644 index 0000000000000..6ac7ece8dadd3 --- /dev/null +++ b/Config/BaselineStandards/Entra (AAD) Standards/AppDeploy.json @@ -0,0 +1,68 @@ +{ + "name": "AppDeploy", + "label": "Deploy Application", + "cat": "Entra (AAD) Standards", + "tag": [], + "impact": "Low Impact", + "helpText": "Deploys selected applications to the tenant, from App Approval templates or by copying permissions from a source application.", + "executiveText": "Automatically deploys approved business applications across every managed tenant, keeping access and permissions consistent.", + "docsDescription": "Grades an empty missing-apps list against the ServicePrincipals cache. Copy mode checks configured app ids; template mode resolves each App Approval template to its type-specific identity (display name for manifests, gallery template id, or app id). Remediation deploys per template type, copying or consenting permissions as the classic did.", + "impactColour": "info", + "addedDate": "2026-08-16", + "powershellEquivalent": "Portal or Graph API", + "recommendedBy": [], + "requiredCapabilities": [], + "disabledFeatures": { + "report": false, + "warn": false, + "remediate": false + }, + "secureScoreImpact": 0, + "compare": "subset", + "variables": { + "mode": { + "type": "autoComplete", + "creatable": false, + "label": "App Approval Mode", + "omitWhenBlank": true, + "options": [ + { + "value": "template", + "label": "Template" + }, + { + "value": "copy", + "label": "Copy Permissions" + } + ] + }, + "templateIds": { + "type": "autoComplete", + "multiple": true, + "creatable": false, + "label": "Select Applications (App Approval Templates)", + "omitWhenBlank": true, + "api": { + "url": "/api/ListAppApprovalTemplates", + "labelField": "TemplateName", + "valueField": "TemplateId", + "queryKey": "StdAppApprovalTemplateList" + } + }, + "appids": { + "type": "textField", + "label": "Application IDs, comma separated (Copy Permissions mode)", + "omitWhenBlank": true + } + }, + "read": { + "cacheType": "ServicePrincipals" + }, + "prepare": "Get-CIPPBaselineAppDeployState", + "remediate": { + "executor": "AppDeploy", + "mode": "%mode%", + "templateIds": "%templateIds%", + "appids": "%appids%" + } +} diff --git a/Config/BaselineStandards/Entra (AAD) Standards/AppManagementPolicy.json b/Config/BaselineStandards/Entra (AAD) Standards/AppManagementPolicy.json new file mode 100644 index 0000000000000..cc64e0923dbb9 --- /dev/null +++ b/Config/BaselineStandards/Entra (AAD) Standards/AppManagementPolicy.json @@ -0,0 +1,73 @@ +{ + "name": "AppManagementPolicy", + "label": "App Management Policy - Credential Restrictions", + "cat": "Entra (AAD) Standards", + "tag": [], + "impact": "Medium Impact", + "helpText": "Restricts how credentials can be added to applications and service principals: block password secrets, block custom passwords, and cap credential lifetimes.", + "executiveText": "Enforces modern credential hygiene on application registrations - blocking long-lived or user-chosen application passwords limits the blast radius of leaked app secrets, a common attack path.", + "docsDescription": "Grades and sets the tenant default app management policy. Configured settings build the restriction set the classic standard built - password addition mirrors onto symmetric key addition, day counts become ISO durations, and application restrictions mirror onto service principal restrictions. Settings left empty are not enforced.", + "impactColour": "warning", + "addedDate": "2026-08-16", + "powershellEquivalent": "Update-MgPolicyDefaultAppManagementPolicy", + "recommendedBy": [], + "requiredCapabilities": [], + "disabledFeatures": { + "report": false, + "warn": false, + "remediate": false + }, + "secureScoreImpact": 0, + "compare": "subset", + "variables": { + "passwordCredentialsPasswordAddition": { + "type": "autoComplete", + "creatable": false, + "label": "Restrict Password Addition", + "omitWhenBlank": true, + "options": [ + { + "label": "Enabled (block new password secrets)", + "value": "enabled" + }, + { + "label": "Disabled", + "value": "disabled" + } + ] + }, + "passwordCredentialsCustomPasswordAddition": { + "type": "autoComplete", + "creatable": false, + "label": "Restrict Custom Passwords", + "omitWhenBlank": true, + "options": [ + { + "label": "Enabled (block custom passwords)", + "value": "enabled" + }, + { + "label": "Disabled", + "value": "disabled" + } + ] + }, + "passwordCredentialsMaxLifetime": { + "type": "number", + "label": "Password Credentials Max Lifetime (Days)", + "omitWhenBlank": true + }, + "keyCredentialsMaxLifetime": { + "type": "number", + "label": "Key Credentials Max Lifetime (Days)", + "omitWhenBlank": true + } + }, + "read": { + "cacheType": "DefaultAppManagementPolicy" + }, + "prepare": "Get-CIPPBaselineAppManagementPolicyState", + "remediate": { + "executor": "AppManagementPolicy" + } +} diff --git a/Config/BaselineStandards/Entra (AAD) Standards/AuthMethodsSettings.json b/Config/BaselineStandards/Entra (AAD) Standards/AuthMethodsSettings.json new file mode 100644 index 0000000000000..62bd935a822df --- /dev/null +++ b/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/Config/BaselineStandards/Entra (AAD) Standards/AuthenticationMethods.json b/Config/BaselineStandards/Entra (AAD) Standards/AuthenticationMethods.json new file mode 100644 index 0000000000000..75563d8a71bc9 --- /dev/null +++ b/Config/BaselineStandards/Entra (AAD) Standards/AuthenticationMethods.json @@ -0,0 +1,274 @@ +{ + "name": "AuthenticationMethods", + "label": "Configure Authentication Methods", + "cat": "Entra (AAD) Standards", + "tag": [], + "impact": "High Impact", + "helpText": "Manages the enabled state, group targeting and per-method settings of every authentication method the baseline configures. Methods left unconfigured are never touched.", + "executiveText": "Standardizes which sign-in methods are available and to whom - enabling phishing-resistant methods and retiring weak ones consistently across the fleet.", + "docsDescription": "Grades each configured method: enabled state, include targeting (a named group or all users), and method-specific settings - Microsoft Authenticator feature states, Temporary Access Pass lifetimes, QR code settings, Email OTP external access and exclusions. The graded value is one list naming every drift. Remediation writes only the drifted methods through the shared authentication policy helper.", + "impactColour": "error", + "addedDate": "2026-08-16", + "powershellEquivalent": "Set-CIPPAuthenticationPolicy", + "recommendedBy": [], + "requiredCapabilities": [ + "AAD_PREMIUM", + "AAD_PREMIUM_P2" + ], + "disabledFeatures": { + "report": false, + "warn": false, + "remediate": false + }, + "secureScoreImpact": 0, + "compare": "subset", + "variables": { + "MicrosoftAuthenticatorEnabled": { + "label": "Microsoft Authenticator: manage this method", + "omitWhenBlank": true, + "type": "switch" + }, + "MicrosoftAuthenticatorGroup": { + "label": "Microsoft Authenticator: target group (blank = all users)", + "omitWhenBlank": true, + "type": "textField" + }, + "FIDO2Enabled": { + "label": "FIDO2 Security Keys: manage this method", + "omitWhenBlank": true, + "type": "switch" + }, + "FIDO2Group": { + "label": "FIDO2 Security Keys: target group (blank = all users)", + "omitWhenBlank": true, + "type": "textField" + }, + "TAPEnabled": { + "label": "Temporary Access Pass: manage this method", + "omitWhenBlank": true, + "type": "switch" + }, + "TAPGroup": { + "label": "Temporary Access Pass: target group (blank = all users)", + "omitWhenBlank": true, + "type": "textField" + }, + "SoftwareOathEnabled": { + "label": "Software OATH Tokens: manage this method", + "omitWhenBlank": true, + "type": "switch" + }, + "SoftwareOathGroup": { + "label": "Software OATH Tokens: target group (blank = all users)", + "omitWhenBlank": true, + "type": "textField" + }, + "HardwareOathEnabled": { + "label": "Hardware OATH Tokens: manage this method", + "omitWhenBlank": true, + "type": "switch" + }, + "HardwareOathGroup": { + "label": "Hardware OATH Tokens: target group (blank = all users)", + "omitWhenBlank": true, + "type": "textField" + }, + "SMSEnabled": { + "label": "SMS: manage this method", + "omitWhenBlank": true, + "type": "switch" + }, + "SMSGroup": { + "label": "SMS: target group (blank = all users)", + "omitWhenBlank": true, + "type": "textField" + }, + "VoiceEnabled": { + "label": "Voice Call: manage this method", + "omitWhenBlank": true, + "type": "switch" + }, + "VoiceGroup": { + "label": "Voice Call: target group (blank = all users)", + "omitWhenBlank": true, + "type": "textField" + }, + "EmailEnabled": { + "label": "Email OTP: manage this method", + "omitWhenBlank": true, + "type": "switch" + }, + "EmailGroup": { + "label": "Email OTP: target group (blank = all users)", + "omitWhenBlank": true, + "type": "textField" + }, + "x509CertificateEnabled": { + "label": "Certificate-Based Authentication: manage this method", + "omitWhenBlank": true, + "type": "switch" + }, + "x509CertificateGroup": { + "label": "Certificate-Based Authentication: target group (blank = all users)", + "omitWhenBlank": true, + "type": "textField" + }, + "QRCodePinEnabled": { + "label": "QR Code Pin: manage this method", + "omitWhenBlank": true, + "type": "switch" + }, + "QRCodePinGroup": { + "label": "QR Code Pin: target group (blank = all users)", + "omitWhenBlank": true, + "type": "textField" + }, + "EmailExcludeGroup": { + "label": "Email OTP: exclude group", + "omitWhenBlank": true, + "type": "textField" + }, + "MicrosoftAuthenticatorSoftwareOath": { + "label": "Authenticator: allow software OATH", + "omitWhenBlank": true, + "type": "switch" + }, + "MicrosoftAuthenticatorDisplayAppInfo": { + "creatable": false, + "options": [ + { + "value": "default", + "label": "Default" + }, + { + "value": "enabled", + "label": "Enabled" + }, + { + "value": "disabled", + "label": "Disabled" + } + ], + "omitWhenBlank": true, + "type": "autoComplete", + "label": "Authenticator: DisplayAppInfo" + }, + "MicrosoftAuthenticatorDisplayLocation": { + "creatable": false, + "options": [ + { + "value": "default", + "label": "Default" + }, + { + "value": "enabled", + "label": "Enabled" + }, + { + "value": "disabled", + "label": "Disabled" + } + ], + "omitWhenBlank": true, + "type": "autoComplete", + "label": "Authenticator: DisplayLocation" + }, + "MicrosoftAuthenticatorCompanionApp": { + "creatable": false, + "options": [ + { + "value": "default", + "label": "Default" + }, + { + "value": "enabled", + "label": "Enabled" + }, + { + "value": "disabled", + "label": "Disabled" + } + ], + "omitWhenBlank": true, + "type": "autoComplete", + "label": "Authenticator: CompanionApp" + }, + "TAPDefaultLifetime": { + "label": "TAP: default lifetime (minutes)", + "omitWhenBlank": true, + "type": "number" + }, + "TAPMinLifetime": { + "label": "TAP: minimum lifetime (minutes)", + "omitWhenBlank": true, + "type": "number" + }, + "TAPMaxLifetime": { + "label": "TAP: maximum lifetime (minutes)", + "omitWhenBlank": true, + "type": "number" + }, + "TAPDefaultLength": { + "label": "TAP: default length", + "omitWhenBlank": true, + "type": "number" + }, + "QRCodeLifetimeInDays": { + "label": "QR: lifetime (days)", + "omitWhenBlank": true, + "type": "number" + }, + "QRCodePinLength": { + "label": "QR: pin length", + "omitWhenBlank": true, + "type": "number" + }, + "TAPUsableOnce": { + "creatable": false, + "options": [ + { + "value": "true", + "label": "Yes (one-time use)" + }, + { + "value": "false", + "label": "No (reusable)" + } + ], + "omitWhenBlank": true, + "type": "autoComplete", + "label": "TAP: usable once" + }, + "EmailAllowExternalIdToUseEmailOtp": { + "creatable": false, + "options": [ + { + "value": "default", + "label": "Default" + }, + { + "value": "enabled", + "label": "Enabled" + }, + { + "value": "disabled", + "label": "Disabled" + } + ], + "omitWhenBlank": true, + "type": "autoComplete", + "label": "Email OTP for external users" + } + }, + "read": { + "requiredCaches": [ + "AuthenticationMethodsPolicy", + "Groups" + ], + "cacheType": "AuthenticationMethodsPolicy" + }, + "prepare": "Get-CIPPBaselineAuthenticationMethodsState", + "remediate": { + "executor": "AuthenticationMethods" + } +} diff --git a/Config/BaselineStandards/Entra (AAD) Standards/BitLockerKeysForOwnedDevice.json b/Config/BaselineStandards/Entra (AAD) Standards/BitLockerKeysForOwnedDevice.json index 66eac8ab72969..bafe1f818baa3 100644 --- a/Config/BaselineStandards/Entra (AAD) Standards/BitLockerKeysForOwnedDevice.json +++ b/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/Config/BaselineStandards/Entra (AAD) Standards/CollaborationDomainRestriction.json b/Config/BaselineStandards/Entra (AAD) Standards/CollaborationDomainRestriction.json new file mode 100644 index 0000000000000..2ad6bc80933d4 --- /dev/null +++ b/Config/BaselineStandards/Entra (AAD) Standards/CollaborationDomainRestriction.json @@ -0,0 +1,37 @@ +{ + "name": "CollaborationDomainRestriction", + "label": "Restrict B2B Collaboration to Allowed Domains", + "cat": "Entra (AAD) Standards", + "tag": [], + "impact": "High Impact", + "helpText": "Restricts B2B guest invitations to an allow-list of domains. Setting an allow-list clears any block-list, matching portal behaviour.", + "executiveText": "Limits which external organizations employees can invite as guests, reducing data exposure to unsanctioned partners while keeping approved collaboration frictionless.", + "docsDescription": "Grades the sorted allowed-domains list on the B2B management policy. Remediation rebuilds the domain policy on top of the existing definition so sibling settings such as AutoRedeemPolicy survive.", + "impactColour": "error", + "addedDate": "2026-08-16", + "powershellEquivalent": "Graph: PATCH policies/b2bManagementPolicies", + "recommendedBy": [], + "requiredCapabilities": [], + "disabledFeatures": { + "report": false, + "warn": false, + "remediate": false + }, + "secureScoreImpact": 0, + "compare": "subset", + "variables": { + "allowedDomains": { + "label": "Allowed domains (comma separated)", + "required": true, + "type": "textField" + } + }, + "read": { + "cacheType": "B2BManagementPolicy" + }, + "prepare": "Get-CIPPBaselineCollaborationDomainRestrictionState", + "remediate": { + "executor": "CollaborationDomainRestriction", + "allowedDomains": "%allowedDomains%" + } +} diff --git a/Config/BaselineStandards/Entra (AAD) Standards/CustomBannedPasswordList.json b/Config/BaselineStandards/Entra (AAD) Standards/CustomBannedPasswordList.json new file mode 100644 index 0000000000000..8e8c0ad3f8abc --- /dev/null +++ b/Config/BaselineStandards/Entra (AAD) Standards/CustomBannedPasswordList.json @@ -0,0 +1,39 @@ +{ + "name": "CustomBannedPasswordList", + "label": "Custom Banned Password List", + "cat": "Entra (AAD) Standards", + "tag": [], + "impact": "Medium Impact", + "helpText": "Adds the configured words (4-16 characters) to the tenant banned password list and enables the check. Words already on the tenant list are kept.", + "executiveText": "Blocks passwords built from organization-specific words - company names, products, locations - that attackers guess first and generic banned lists never cover.", + "docsDescription": "Grades whether the banned password check is enabled and which configured words are missing from the tenant list; words on the list that the baseline never mentioned are left alone. Remediation merges additively, deduplicated and capped at 1000.", + "impactColour": "warning", + "addedDate": "2026-08-16", + "powershellEquivalent": "Graph: PATCH settings (directory setting 5cf42378)", + "recommendedBy": [], + "requiredCapabilities": [ + "AAD_PREMIUM", + "AAD_PREMIUM_P2" + ], + "disabledFeatures": { + "report": false, + "warn": false, + "remediate": false + }, + "secureScoreImpact": 0, + "compare": "subset", + "variables": { + "BannedWords": { + "label": "Banned words (comma or semicolon separated)", + "required": true, + "type": "textField" + } + }, + "read": { + "cacheType": "Settings" + }, + "prepare": "Get-CIPPBaselineCustomBannedPasswordListState", + "remediate": { + "executor": "CustomBannedPasswordList" + } +} diff --git a/Config/BaselineStandards/Entra (AAD) Standards/DisableAppCreation.json b/Config/BaselineStandards/Entra (AAD) Standards/DisableAppCreation.json index 731d7a2c507d4..435081e3ff38e 100644 --- a/Config/BaselineStandards/Entra (AAD) Standards/DisableAppCreation.json +++ b/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/Config/BaselineStandards/Entra (AAD) Standards/DisableGuests.json b/Config/BaselineStandards/Entra (AAD) Standards/DisableGuests.json new file mode 100644 index 0000000000000..2af5ca4a2f59a --- /dev/null +++ b/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/Config/BaselineStandards/Entra (AAD) Standards/DisableInactiveUsers.json b/Config/BaselineStandards/Entra (AAD) Standards/DisableInactiveUsers.json new file mode 100644 index 0000000000000..85b04a3a9e57a --- /dev/null +++ b/Config/BaselineStandards/Entra (AAD) Standards/DisableInactiveUsers.json @@ -0,0 +1,69 @@ +{ + "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" + } + } + }, + "excludedUsers": { + "type": "autoComplete", + "multiple": true, + "creatable": true, + "omitWhenBlank": true, + "label": "Excluded accounts (UPNs never disabled - breakglass, service accounts)" + } + }, + "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/Config/BaselineStandards/Entra (AAD) Standards/DisableM365GroupUsers.json b/Config/BaselineStandards/Entra (AAD) Standards/DisableM365GroupUsers.json new file mode 100644 index 0000000000000..92dd479098d64 --- /dev/null +++ b/Config/BaselineStandards/Entra (AAD) Standards/DisableM365GroupUsers.json @@ -0,0 +1,47 @@ +{ + "name": "DisableM365GroupUsers", + "label": "Disable M365 Group creation by users", + "cat": "Entra (AAD) Standards", + "tag": [], + "impact": "Low Impact", + "helpText": "Prevents standard users from creating Microsoft 365 groups, optionally exempting members of a named group.", + "executiveText": "Stops group sprawl by routing Microsoft 365 group creation through IT or a designated team, keeping ownership, naming and lifecycle under control.", + "docsDescription": "Grades EnableGroupCreation on the Group.Unified directory setting and, when an exempt group is configured, that GroupCreationAllowedGroupId points at it (resolved by display name per tenant). Remediation can create the exempt group and instantiates the directory setting from its template when the tenant has none.", + "impactColour": "info", + "addedDate": "2026-08-16", + "powershellEquivalent": "Graph: PATCH settings (Group.Unified)", + "recommendedBy": [], + "requiredCapabilities": [], + "disabledFeatures": { + "report": false, + "warn": false, + "remediate": false + }, + "secureScoreImpact": 0, + "compare": "subset", + "variables": { + "AllowedGroupName": { + "label": "Optional: group whose members may still create M365 groups", + "omitWhenBlank": true, + "type": "textField" + }, + "CreateGroup": { + "label": "Create the allowed group if it does not exist", + "type": "switch", + "default": false + } + }, + "read": { + "requiredCaches": [ + "Settings", + "Groups" + ], + "cacheType": "Settings" + }, + "prepare": "Get-CIPPBaselineDisableM365GroupUsersState", + "remediate": { + "executor": "DisableM365GroupUsers", + "allowedGroupName": "%AllowedGroupName%", + "createGroup": "%CreateGroup%" + } +} diff --git a/Config/BaselineStandards/Entra (AAD) Standards/DisableSecurityGroupUsers.json b/Config/BaselineStandards/Entra (AAD) Standards/DisableSecurityGroupUsers.json index ce3c118fb913b..cc62a43f463a3 100644 --- a/Config/BaselineStandards/Entra (AAD) Standards/DisableSecurityGroupUsers.json +++ b/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/Config/BaselineStandards/Entra (AAD) Standards/DisableSelfServiceLicenses.json b/Config/BaselineStandards/Entra (AAD) Standards/DisableSelfServiceLicenses.json new file mode 100644 index 0000000000000..b381d021c282f --- /dev/null +++ b/Config/BaselineStandards/Entra (AAD) Standards/DisableSelfServiceLicenses.json @@ -0,0 +1,45 @@ +{ + "name": "DisableSelfServiceLicenses", + "label": "Disable Self Service Licensing", + "cat": "Entra (AAD) Standards", + "tag": [], + "impact": "Medium Impact", + "helpText": "Disables self-service purchasing for every product (with optional product-id exclusions), email-based subscription signup, and optionally trial autoclaim.", + "executiveText": "Stops employees buying Microsoft licenses on personal cards outside procurement - keeping licensing centralized, supported and cost-controlled.", + "docsDescription": "Grades every self-service purchasable product against Disabled (excluded product ids stay Enabled), email-based subscription signup against off, and - when trials are disabled - the autoclaim policy. Requires the Billing Administrator GDAP role to read the product list. Remediation writes each drifted product on its own endpoint.", + "impactColour": "warning", + "addedDate": "2026-08-16", + "powershellEquivalent": "MSCommerce / licensing.m365.microsoft.com policy API", + "recommendedBy": [], + "requiredCapabilities": [], + "disabledFeatures": { + "report": false, + "warn": false, + "remediate": false + }, + "secureScoreImpact": 0, + "compare": "subset", + "variables": { + "Exclusions": { + "label": "Excluded product IDs, comma or semicolon separated (stay enabled)", + "omitWhenBlank": true, + "type": "textField" + }, + "DisableTrials": { + "label": "Also disable trial autoclaim", + "type": "switch", + "default": false + } + }, + "read": { + "requiredCaches": [ + "SelfServicePurchaseProducts", + "AuthorizationPolicy" + ], + "cacheType": "SelfServicePurchaseProducts" + }, + "prepare": "Get-CIPPBaselineDisableSelfServiceLicensesState", + "remediate": { + "executor": "DisableSelfServiceLicenses" + } +} diff --git a/Config/BaselineStandards/Entra (AAD) Standards/DisableTenantCreation.json b/Config/BaselineStandards/Entra (AAD) Standards/DisableTenantCreation.json index 0127453ec593f..f41e4f23f4270 100644 --- a/Config/BaselineStandards/Entra (AAD) Standards/DisableTenantCreation.json +++ b/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/Config/BaselineStandards/Entra (AAD) Standards/EmailAsAlternateLoginId.json b/Config/BaselineStandards/Entra (AAD) Standards/EmailAsAlternateLoginId.json new file mode 100644 index 0000000000000..1d44c904c49d1 --- /dev/null +++ b/Config/BaselineStandards/Entra (AAD) Standards/EmailAsAlternateLoginId.json @@ -0,0 +1,37 @@ +{ + "name": "EmailAsAlternateLoginId", + "label": "Email as an Alternate Login ID", + "cat": "Entra (AAD) Standards", + "tag": [], + "impact": "Medium Impact", + "helpText": "Explicitly enables or disables signing in with a verified email address as an alternate to the UPN.", + "executiveText": "Controls whether employees can sign in with their email address when it differs from their account name - an explicit setting keeps sign-in behaviour deliberate rather than inheriting Microsoft defaults that can change.", + "docsDescription": "Grades the org-default home realm discovery policy: it must exist, explicitly carry the AlternateIdLogin setting, and match the configured direction. Remediation patches the existing policy or creates the org default.", + "impactColour": "warning", + "addedDate": "2026-08-16", + "powershellEquivalent": "Update-MgPolicyHomeRealmDiscoveryPolicy", + "recommendedBy": [], + "requiredCapabilities": [], + "disabledFeatures": { + "report": false, + "warn": false, + "remediate": false + }, + "secureScoreImpact": 0, + "compare": "subset", + "variables": { + "Enabled": { + "label": "Enable Email as Alternate Login ID", + "type": "switch", + "default": false + } + }, + "read": { + "cacheType": "HomeRealmDiscoveryPolicy" + }, + "prepare": "Get-CIPPBaselineEmailAsAlternateLoginIdState", + "remediate": { + "executor": "EmailAsAlternateLoginId", + "enabled": "%Enabled%" + } +} diff --git a/Config/BaselineStandards/Entra (AAD) Standards/EnableAppConsentRequests.json b/Config/BaselineStandards/Entra (AAD) Standards/EnableAppConsentRequests.json new file mode 100644 index 0000000000000..436f27c085b1b --- /dev/null +++ b/Config/BaselineStandards/Entra (AAD) Standards/EnableAppConsentRequests.json @@ -0,0 +1,62 @@ +{ + "name": "EnableAppConsentRequests", + "label": "Enable App Consent Admin Requests", + "cat": "Entra (AAD) Standards", + "tag": [ + "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, 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 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", + "recommendedBy": [ + "CIS" + ], + "requiredCapabilities": [], + "disabledFeatures": { + "report": false, + "warn": false, + "remediate": false + }, + "secureScoreImpact": 0, + "compare": "subset", + "variables": { + "ReviewerRoles": { + "type": "autoComplete", + "creatable": false, + "label": "App Consent Reviewer Role", + "omitWhenBlank": true, + "api": { + "url": "/api/ListGraphRequest", + "data": { + "Endpoint": "roleManagement/directory/roleDefinitions", + "$select": "id,displayName", + "$top": 999 + }, + "dataKey": "Results", + "labelField": "displayName", + "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": { + "cacheType": "AdminConsentRequestPolicy" + }, + "prepare": "Get-CIPPBaselineEnableAppConsentRequestsState", + "remediate": { + "executor": "EnableAppConsentRequests", + "reviewerRoles": "%ReviewerRoles%", + "reviewerUsers": "%ReviewerUsers%" + } +} diff --git a/Config/BaselineStandards/Entra (AAD) Standards/EnableFIDO2.json b/Config/BaselineStandards/Entra (AAD) Standards/EnableFIDO2.json index 4cc41c0e96518..b2fac9b15eb07 100644 --- a/Config/BaselineStandards/Entra (AAD) Standards/EnableFIDO2.json +++ b/Config/BaselineStandards/Entra (AAD) Standards/EnableFIDO2.json @@ -55,18 +55,6 @@ ] }, "remediate": { - "executor": "GraphRequest", - "requests": [ - { - "method": "PATCH", - "uri": "policies/authenticationMethodsPolicy/authenticationMethodConfigurations/Fido2", - "body": { - "@odata.type": "#microsoft.graph.fido2AuthenticationMethodConfiguration", - "state": "enabled", - "isAttestationEnforced": true, - "isSelfServiceRegistrationAllowed": true - } - } - ] + "executor": "EnableFIDO2" } } diff --git a/Config/BaselineStandards/Entra (AAD) Standards/EnforcePrivateGroups.json b/Config/BaselineStandards/Entra (AAD) Standards/EnforcePrivateGroups.json new file mode 100644 index 0000000000000..fa51f6c566420 --- /dev/null +++ b/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/Config/BaselineStandards/Entra (AAD) Standards/ExternalMFATrusted.json b/Config/BaselineStandards/Entra (AAD) Standards/ExternalMFATrusted.json new file mode 100644 index 0000000000000..f64e2b305f711 --- /dev/null +++ b/Config/BaselineStandards/Entra (AAD) Standards/ExternalMFATrusted.json @@ -0,0 +1,41 @@ +{ + "name": "ExternalMFATrusted", + "label": "Sets the Cross-tenant Access Setting to Trust External MFA", + "cat": "Entra (AAD) Standards", + "tag": [], + "impact": "Low Impact", + "helpText": "Sets the default state for whether MFA performed in an external tenant is trusted for inbound B2B access.", + "executiveText": "Controls whether guests who already completed multifactor authentication in their home organization must repeat it when accessing this tenant. Trusting external MFA smooths partner collaboration; not trusting it enforces this organization's own MFA on every guest.", + "docsDescription": "Grades and sets inboundTrust.isMfaAccepted on the default cross-tenant access policy. Remediation reads the live policy and rewrites the merged inboundTrust object, preserving the compliant-device and hybrid-join trust flags alongside it.", + "impactColour": "info", + "addedDate": "2026-08-16", + "powershellEquivalent": "Update-MgPolicyCrossTenantAccessPolicyDefault", + "recommendedBy": [], + "requiredCapabilities": [ + "AAD_PREMIUM", + "AAD_PREMIUM_P2" + ], + "disabledFeatures": { + "report": false, + "warn": false, + "remediate": false + }, + "secureScoreImpact": 0, + "compare": "subset", + "variables": { + "state": { + "type": "switch", + "label": "Trust MFA performed in external tenants", + "default": false, + "recommended": true + } + }, + "read": { + "cacheType": "CrossTenantAccessPolicy" + }, + "prepare": "Get-CIPPBaselineExternalMFATrustedState", + "remediate": { + "executor": "ExternalMFATrusted", + "trusted": "%state%" + } +} diff --git a/Config/BaselineStandards/Entra (AAD) Standards/FIDO2PasskeyProfiles.json b/Config/BaselineStandards/Entra (AAD) Standards/FIDO2PasskeyProfiles.json new file mode 100644 index 0000000000000..3593b1ff14beb --- /dev/null +++ b/Config/BaselineStandards/Entra (AAD) Standards/FIDO2PasskeyProfiles.json @@ -0,0 +1,105 @@ +{ + "name": "FIDO2PasskeyProfiles", + "label": "Configure FIDO2 Passkey Profiles", + "cat": "Entra (AAD) Standards", + "tag": [], + "impact": "Medium Impact", + "helpText": "Configures the default passkey profile: allowed passkey types, attestation enforcement, and optional AAGUID key restrictions. Other profiles are never touched.", + "executiveText": "Controls which passkey authenticators the organization accepts - device-bound versus synced, and optionally an explicit allow or block list of authenticator models.", + "docsDescription": "Grades the default passkey profile's passkey types, attestation enforcement, key restriction enforcement, restriction type and sorted AAGUID set. Remediation rewrites the default profile in place, resending every other profile untouched. Attestation must be disabled to allow synced passkeys.", + "impactColour": "warning", + "addedDate": "2026-08-16", + "powershellEquivalent": "Graph: PATCH authenticationMethodConfigurations/Fido2", + "recommendedBy": [], + "requiredCapabilities": [ + "AAD_PREMIUM", + "AAD_PREMIUM_P2" + ], + "disabledFeatures": { + "report": false, + "warn": false, + "remediate": false + }, + "secureScoreImpact": 0, + "compare": "subset", + "variables": { + "PasskeyTypes": { + "creatable": false, + "options": [ + { + "value": "deviceBound", + "label": "Device-bound only" + }, + { + "value": "synced", + "label": "Synced only" + }, + { + "value": "deviceBound,synced", + "label": "Both device-bound and synced" + } + ], + "required": true, + "type": "autoComplete", + "label": "Allowed Passkey Types" + }, + "AttestationEnforcement": { + "creatable": false, + "options": [ + { + "value": "disabled", + "label": "Disabled (required for synced passkeys)" + }, + { + "value": "registrationOnly", + "label": "Registration only" + } + ], + "required": true, + "type": "autoComplete", + "label": "Attestation Enforcement" + }, + "EnforceKeyRestrictions": { + "label": "Enforce AAGUID Key Restrictions", + "type": "switch", + "default": false + }, + "EnforcementType": { + "creatable": false, + "options": [ + { + "value": "allow", + "label": "Allow listed AAGUIDs only" + }, + { + "value": "block", + "label": "Block listed AAGUIDs" + } + ], + "omitWhenBlank": true, + "type": "autoComplete", + "label": "Key Restriction Type" + }, + "AAGUIDs": { + "label": "AAGUIDs (comma-separated)", + "omitWhenBlank": true, + "type": "textField" + } + }, + "read": { + "requiredCaches": [ + "AuthenticationMethodsPolicy", + "Fido2Configuration" + ], + "cacheType": "AuthenticationMethodsPolicy" + }, + "prepare": "Get-CIPPBaselineFIDO2PasskeyProfilesState", + "remediate": { + "executor": "FIDO2PasskeyProfiles", + "passkeyTypes": "%PasskeyTypes%", + "attestationEnforcement": "%AttestationEnforcement%", + "enforceKeyRestrictions": "%EnforceKeyRestrictions%", + "enforcementType": "%EnforcementType%", + "aaGuids": "%AAGUIDs%" + } +} diff --git a/Config/BaselineStandards/Entra (AAD) Standards/GuestInvite.json b/Config/BaselineStandards/Entra (AAD) Standards/GuestInvite.json index 0e068158c57e5..279c3913c88bc 100644 --- a/Config/BaselineStandards/Entra (AAD) Standards/GuestInvite.json +++ b/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/Config/BaselineStandards/Entra (AAD) Standards/NudgeMFA.json b/Config/BaselineStandards/Entra (AAD) Standards/NudgeMFA.json new file mode 100644 index 0000000000000..aa69fd8b8dff2 --- /dev/null +++ b/Config/BaselineStandards/Entra (AAD) Standards/NudgeMFA.json @@ -0,0 +1,90 @@ +{ + "name": "NudgeMFA", + "label": "Sets the state for the request to setup Authenticator", + "cat": "Entra (AAD) Standards", + "tag": [], + "impact": "Low Impact", + "helpText": "Configures the registration campaign that nudges users to set up the Authenticator app at sign-in, including snooze behaviour and optional group targeting.", + "executiveText": "Drives MFA registration to completion by prompting unregistered users at sign-in, with controlled snoozing so adoption happens without hard-blocking work.", + "docsDescription": "Grades the registration campaign state, snooze duration, post-snooze enforcement, targeted method and the include/exclude target sets. Blank targeting fields keep the tenant's current targets - the literal AllUsers entry targets everyone explicitly. Remediation goes through the shared registration campaign helper.", + "impactColour": "info", + "addedDate": "2026-08-16", + "powershellEquivalent": "Set-CIPPRegistrationCampaign", + "recommendedBy": [], + "requiredCapabilities": [ + "AAD_PREMIUM", + "AAD_PREMIUM_P2" + ], + "disabledFeatures": { + "report": false, + "warn": false, + "remediate": false + }, + "secureScoreImpact": 0, + "compare": "subset", + "variables": { + "state": { + "creatable": false, + "options": [ + { + "value": "enabled", + "label": "Enabled" + }, + { + "value": "disabled", + "label": "Disabled" + }, + { + "value": "default", + "label": "Microsoft managed" + } + ], + "required": true, + "type": "autoComplete", + "label": "Campaign state" + }, + "snoozeDurationInDays": { + "label": "Snooze duration in days (0-14)", + "type": "number", + "default": 1 + }, + "enforceRegistrationAfterAllowedSnoozes": { + "label": "Enforce registration after allowed snoozes", + "type": "switch", + "default": true + }, + "targetedAuthenticationMethod": { + "creatable": false, + "options": [ + { + "value": "microsoftAuthenticator", + "label": "Microsoft Authenticator" + } + ], + "omitWhenBlank": true, + "type": "autoComplete", + "label": "Targeted method" + }, + "includeTargets": { + "label": "Include groups, comma separated (blank = keep current; AllUsers = everyone)", + "omitWhenBlank": true, + "type": "textField" + }, + "excludeTargets": { + "label": "Exclude groups, comma separated (blank = keep current)", + "omitWhenBlank": true, + "type": "textField" + } + }, + "read": { + "requiredCaches": [ + "AuthenticationMethodsPolicy", + "Groups" + ], + "cacheType": "AuthenticationMethodsPolicy" + }, + "prepare": "Get-CIPPBaselineNudgeMFAState", + "remediate": { + "executor": "NudgeMFA" + } +} diff --git a/Config/BaselineStandards/Entra (AAD) Standards/OauthConsent.json b/Config/BaselineStandards/Entra (AAD) Standards/OauthConsent.json new file mode 100644 index 0000000000000..156277de11685 --- /dev/null +++ b/Config/BaselineStandards/Entra (AAD) Standards/OauthConsent.json @@ -0,0 +1,36 @@ +{ + "name": "OauthConsent", + "label": "Require admin consent for applications (Prevent OAuth phishing)", + "cat": "Entra (AAD) Standards", + "tag": [], + "impact": "Medium Impact", + "helpText": "Routes user application consent through the CIPP consent policy so only allow-listed applications can be consented to without admin review.", + "executiveText": "Blocks the most common OAuth phishing path by requiring admin review before applications gain access to company data, while allow-listed business apps keep working frictionlessly.", + "docsDescription": "Grades that the default user role's permission grant policy is the CIPP consent policy and that every expected include exists - the fixed Office 365 Management delegated include plus delegated and application includes per allowed app. Includes added by hand are not graded; remediation only ever adds. All writes are delegated - the authorization policy rejects app-only tokens.", + "impactColour": "warning", + "addedDate": "2026-08-16", + "powershellEquivalent": "Graph: PATCH policies/authorizationPolicy", + "recommendedBy": [], + "requiredCapabilities": [], + "disabledFeatures": { + "report": false, + "warn": false, + "remediate": false + }, + "secureScoreImpact": 0, + "compare": "subset", + "variables": { + "AllowedApps": { + "label": "Allowed application IDs, comma separated (optional)", + "omitWhenBlank": true, + "type": "textField" + } + }, + "read": { + "cacheType": "AuthorizationPolicy" + }, + "prepare": "Get-CIPPBaselineOauthConsentState", + "remediate": { + "executor": "OauthConsent" + } +} diff --git a/Config/BaselineStandards/Entra (AAD) Standards/OauthConsentLowSec.json b/Config/BaselineStandards/Entra (AAD) Standards/OauthConsentLowSec.json index 187c05538803d..0985f8b93dc7c 100644 --- a/Config/BaselineStandards/Entra (AAD) Standards/OauthConsentLowSec.json +++ b/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/Config/BaselineStandards/Entra (AAD) Standards/PWcompanionAppAllowedState.json b/Config/BaselineStandards/Entra (AAD) Standards/PWcompanionAppAllowedState.json new file mode 100644 index 0000000000000..d4f37adfc7dcb --- /dev/null +++ b/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/Config/BaselineStandards/Entra (AAD) Standards/PWdisplayAppInformationRequiredState.json b/Config/BaselineStandards/Entra (AAD) Standards/PWdisplayAppInformationRequiredState.json new file mode 100644 index 0000000000000..c29aaa70ad2dd --- /dev/null +++ b/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/Config/BaselineStandards/Entra (AAD) Standards/PasswordExpireDisabled.json b/Config/BaselineStandards/Entra (AAD) Standards/PasswordExpireDisabled.json new file mode 100644 index 0000000000000..eb8a37a81378e --- /dev/null +++ b/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/Config/BaselineStandards/Entra (AAD) Standards/PerUserMFA.json b/Config/BaselineStandards/Entra (AAD) Standards/PerUserMFA.json new file mode 100644 index 0000000000000..c820e34c1b696 --- /dev/null +++ b/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/Config/BaselineStandards/Entra (AAD) Standards/SecurityDefaults.json b/Config/BaselineStandards/Entra (AAD) Standards/SecurityDefaults.json new file mode 100644 index 0000000000000..e6711e89a3164 --- /dev/null +++ b/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/Config/BaselineStandards/Entra (AAD) Standards/SmartLockout.json b/Config/BaselineStandards/Entra (AAD) Standards/SmartLockout.json new file mode 100644 index 0000000000000..d19cef09c82f5 --- /dev/null +++ b/Config/BaselineStandards/Entra (AAD) Standards/SmartLockout.json @@ -0,0 +1,69 @@ +{ + "name": "SmartLockout", + "label": "Configure Smart Lockout", + "cat": "Entra (AAD) Standards", + "tag": [], + "impact": "Medium Impact", + "helpText": "Sets the smart lockout threshold and duration, and optionally on-premises password protection mode.", + "executiveText": "Tunes how quickly repeated failed sign-ins lock an account and for how long, balancing brute-force protection against helpdesk load from accidental lockouts.", + "docsDescription": "Grades the four lockout values on the password-rule directory setting. Remediation creates the setting with defaults when absent or patches only the lockout values, leaving the banned-password values to their own standard.", + "impactColour": "warning", + "addedDate": "2026-08-16", + "powershellEquivalent": "Graph: PATCH settings (directory setting 5cf42378)", + "recommendedBy": [], + "requiredCapabilities": [ + "AAD_PREMIUM", + "AAD_PREMIUM_P2" + ], + "disabledFeatures": { + "report": false, + "warn": false, + "remediate": false + }, + "secureScoreImpact": 0, + "compare": "subset", + "variables": { + "LockoutDurationInSeconds": { + "label": "Lockout Duration (seconds)", + "type": "number", + "default": 60 + }, + "LockoutThreshold": { + "label": "Lockout Threshold (failed attempts)", + "type": "number", + "default": 10 + }, + "EnableBannedPasswordCheckOnPremises": { + "label": "Enable On-Premises Password Protection", + "type": "switch", + "default": false + }, + "BannedPasswordCheckOnPremisesMode": { + "label": "On-Premises Mode", + "creatable": false, + "type": "autoComplete", + "default": "Audit", + "options": [ + { + "value": "Audit", + "label": "Audit" + }, + { + "value": "Enforce", + "label": "Enforce" + } + ] + } + }, + "read": { + "cacheType": "Settings" + }, + "prepare": "Get-CIPPBaselineSmartLockoutState", + "remediate": { + "executor": "SmartLockout", + "lockoutDurationInSeconds": "%LockoutDurationInSeconds%", + "lockoutThreshold": "%LockoutThreshold%", + "enableBannedPasswordCheckOnPremises": "%EnableBannedPasswordCheckOnPremises%", + "bannedPasswordCheckOnPremisesMode": "%BannedPasswordCheckOnPremisesMode%" + } +} diff --git a/Config/BaselineStandards/Entra (AAD) Standards/StaleEntraDevices.json b/Config/BaselineStandards/Entra (AAD) Standards/StaleEntraDevices.json new file mode 100644 index 0000000000000..dd1e9d2946740 --- /dev/null +++ b/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/Config/BaselineStandards/Entra (AAD) Standards/UndoOauth.json b/Config/BaselineStandards/Entra (AAD) Standards/UndoOauth.json index 977bbf2c29380..1ccc22241a897 100644 --- a/Config/BaselineStandards/Entra (AAD) Standards/UndoOauth.json +++ b/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/Config/BaselineStandards/Entra (AAD) Standards/UserPreferredLanguage.json b/Config/BaselineStandards/Entra (AAD) Standards/UserPreferredLanguage.json new file mode 100644 index 0000000000000..7297ba79e9f69 --- /dev/null +++ b/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/Config/BaselineStandards/Entra (AAD) Standards/intuneDeviceRegLocalAdmins.json b/Config/BaselineStandards/Entra (AAD) Standards/intuneDeviceRegLocalAdmins.json new file mode 100644 index 0000000000000..88fe41d2eb972 --- /dev/null +++ b/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/Config/BaselineStandards/Entra (AAD) Standards/intuneRestrictUserDeviceJoin.json b/Config/BaselineStandards/Entra (AAD) Standards/intuneRestrictUserDeviceJoin.json new file mode 100644 index 0000000000000..9543e96ee6f39 --- /dev/null +++ b/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/Config/BaselineStandards/Entra (AAD) Standards/intuneRestrictUserDeviceRegistration.json b/Config/BaselineStandards/Entra (AAD) Standards/intuneRestrictUserDeviceRegistration.json new file mode 100644 index 0000000000000..2ac7f85115764 --- /dev/null +++ b/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/Config/BaselineStandards/Entra (AAD) Standards/laps.json b/Config/BaselineStandards/Entra (AAD) Standards/laps.json new file mode 100644 index 0000000000000..75b3bfe4301ff --- /dev/null +++ b/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/Config/BaselineStandards/Exchange Standards/AddDKIM.json b/Config/BaselineStandards/Exchange Standards/AddDKIM.json new file mode 100644 index 0000000000000..9e8c3e49ffb7c --- /dev/null +++ b/Config/BaselineStandards/Exchange Standards/AddDKIM.json @@ -0,0 +1,40 @@ +{ + "name": "AddDKIM", + "label": "Enable DKIM for all domains", + "cat": "Exchange Standards", + "tag": [], + "impact": "Low Impact", + "helpText": "Enables DKIM signing for every accepted domain that supports it. Service domains (onmicrosoft, signature services) are excluded.", + "executiveText": "Cryptographically signs outbound email for every domain so recipients can verify it genuinely came from the organization - a core defence against spoofing of the company brand.", + "docsDescription": "Grades which accepted domains lack enabled DKIM signing, excluding the service domains that never get DKIM through Exchange. Remediation creates missing 2048-bit configs and enables disabled ones in one bulk batch.", + "impactColour": "info", + "addedDate": "2026-08-16", + "powershellEquivalent": "New-DkimSigningConfig or Set-DkimSigningConfig", + "recommendedBy": [], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ], + "disabledFeatures": { + "report": false, + "warn": false, + "remediate": false + }, + "secureScoreImpact": 0, + "compare": "subset", + "variables": {}, + "read": { + "requiredCaches": [ + "ExoAcceptedDomains", + "ExoDkimSigningConfig" + ], + "cacheType": "ExoAcceptedDomains" + }, + "prepare": "Get-CIPPBaselineAddDKIMState", + "remediate": { + "executor": "AddDKIM" + } +} diff --git a/Config/BaselineStandards/Exchange Standards/AutoAddProxy.json b/Config/BaselineStandards/Exchange Standards/AutoAddProxy.json new file mode 100644 index 0000000000000..2228924a8747a --- /dev/null +++ b/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/Config/BaselineStandards/Exchange Standards/AutoExpandArchive.json b/Config/BaselineStandards/Exchange Standards/AutoExpandArchive.json index 329e8faf3aaf2..4d108ab7dc817 100644 --- a/Config/BaselineStandards/Exchange Standards/AutoExpandArchive.json +++ b/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/Config/BaselineStandards/Exchange Standards/ColleagueImpersonationAlert.json b/Config/BaselineStandards/Exchange Standards/ColleagueImpersonationAlert.json new file mode 100644 index 0000000000000..3461f079bd70a --- /dev/null +++ b/Config/BaselineStandards/Exchange Standards/ColleagueImpersonationAlert.json @@ -0,0 +1,65 @@ +{ + "name": "ColleagueImpersonationAlert", + "label": "Colleague Impersonation Alert Transport Rules", + "cat": "Exchange Standards", + "tag": [], + "impact": "Medium Impact", + "helpText": "Creates/updates 5x Exchange Online transport rules (A-E, F-J, K-O, P-T, U-Z) that prepend an HTML disclaimer banner to inbound emails where the sender display name matches a mailbox in the organisation. Accepted tenant domains are exempt automatically; manually configured exemptions on existing rules are preserved.", + "executiveText": "Protects staff from display-name impersonation attacks by injecting a visible warning banner on emails that appear to come from a colleague but originate externally.", + "docsDescription": "The expected state is DYNAMIC: each rule's header patterns are computed from the tenant's mailbox display names (user+shared, enabled, not keyword-excluded), so the grade is per-rule exists-and-patterns-match. The optional separator adds each user's short name as a second pattern. Remediation merge-writes: configured exempt senders plus the rule's existing ones, accepted-domain exemptions plus the rule's existing ones - manual operator exemptions are never stripped. The disclaimer HTML falls back to an existing rule's when left blank.", + "impactColour": "warning", + "addedDate": "2026-08-16", + "powershellEquivalent": "New-TransportRule / Set-TransportRule", + "recommendedBy": [], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ], + "disabledFeatures": { + "report": false, + "warn": false, + "remediate": false + }, + "secureScoreImpact": 0, + "compare": "subset", + "variables": { + "disclaimerHtml": { + "type": "textField", + "label": "Disclaimer HTML - the full HTML for the warning banner", + "required": true + }, + "displayNameSeparator": { + "type": "textField", + "label": "Display name separator - optional, for example |", + "omitWhenBlank": true + }, + "excludedMailboxes": { + "type": "autoComplete", + "multiple": true, + "creatable": true, + "label": "Exclude mailboxes by keyword (e.g. (Leaver))", + "omitWhenBlank": true + }, + "additionalExemptSenders": { + "type": "autoComplete", + "multiple": true, + "creatable": true, + "label": "Additional exempt sender addresses", + "omitWhenBlank": true + } + }, + "read": { + "cacheType": "ExoTransportRules" + }, + "prepare": "Get-CIPPBaselineColleagueImpersonationAlertState", + "remediate": { + "executor": "ColleagueImpersonationAlert", + "disclaimerHtml": "%disclaimerHtml%", + "displayNameSeparator": "%displayNameSeparator%", + "excludedMailboxes": "%excludedMailboxes%", + "additionalExemptSenders": "%additionalExemptSenders%" + } +} diff --git a/Config/BaselineStandards/Exchange Standards/DelegateSentItems.json b/Config/BaselineStandards/Exchange Standards/DelegateSentItems.json new file mode 100644 index 0000000000000..80f7c353758ed --- /dev/null +++ b/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/Config/BaselineStandards/Exchange Standards/DeployMailContact.json b/Config/BaselineStandards/Exchange Standards/DeployMailContact.json new file mode 100644 index 0000000000000..272860fcf5cf7 --- /dev/null +++ b/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/Config/BaselineStandards/Exchange Standards/DisableBasicAuthSMTP.json b/Config/BaselineStandards/Exchange Standards/DisableBasicAuthSMTP.json index d7beb7c101e05..667beaf6e0ef9 100644 --- a/Config/BaselineStandards/Exchange Standards/DisableBasicAuthSMTP.json +++ b/Config/BaselineStandards/Exchange Standards/DisableBasicAuthSMTP.json @@ -40,20 +40,15 @@ } }, "expected": { - "SmtpClientAuthenticationDisabled": "%disabled%" + "SmtpClientAuthenticationDisabled": "%disabled%", + "UsersWithSmtpAuthEnabled": [] }, "read": { "cacheType": "ExoTransportConfig" }, + "prepare": "Get-CIPPBaselineDisableBasicAuthSMTPState", "remediate": { - "executor": "ExoRequest", - "cmdlets": [ - { - "cmdlet": "Set-TransportConfig", - "params": { - "SmtpClientAuthenticationDisabled": "%disabled%" - } - } - ] + "executor": "DisableBasicAuthSMTP", + "disabled": "%disabled%" } } diff --git a/Config/BaselineStandards/Exchange Standards/DisableExchangeOnlinePowerShell.json b/Config/BaselineStandards/Exchange Standards/DisableExchangeOnlinePowerShell.json new file mode 100644 index 0000000000000..7f397bc213df1 --- /dev/null +++ b/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/Config/BaselineStandards/Exchange Standards/DisableExternalCalendarSharing.json b/Config/BaselineStandards/Exchange Standards/DisableExternalCalendarSharing.json new file mode 100644 index 0000000000000..ba66dc1044fbe --- /dev/null +++ b/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/Config/BaselineStandards/Exchange Standards/DisableOutlookAddins.json b/Config/BaselineStandards/Exchange Standards/DisableOutlookAddins.json new file mode 100644 index 0000000000000..fec734c2d0569 --- /dev/null +++ b/Config/BaselineStandards/Exchange Standards/DisableOutlookAddins.json @@ -0,0 +1,34 @@ +{ + "name": "DisableOutlookAddins", + "label": "Disable users from installing add-ins in Outlook", + "cat": "Exchange Standards", + "tag": [], + "impact": "Medium Impact", + "helpText": "Disables the ability for users to install add-ins in Outlook. This is to prevent users from installing malicious add-ins.", + "executiveText": "Prevents employees from installing third-party add-ins in Outlook without administrative approval, reducing security risks from potentially malicious extensions.", + "docsDescription": "Grades whether any of the three app-install roles (My Custom Apps, My Marketplace Apps, My ReadWriteMailbox Apps) remain on the default role assignment policy - the policy reads live, one small object. Remediation removes each role's management role assignments by GUID, continuing past per-role failures. NOTE: removal is one-way through CIPP, as in the classic - re-enabling requires recreating the assignments in Exchange.", + "impactColour": "warning", + "addedDate": "2026-08-16", + "powershellEquivalent": "Get-ManagementRoleAssignment | Remove-ManagementRoleAssignment", + "recommendedBy": [], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ], + "disabledFeatures": { + "report": false, + "warn": false, + "remediate": false + }, + "secureScoreImpact": 0, + "compare": "subset", + "variables": {}, + "read": {}, + "prepare": "Get-CIPPBaselineDisableOutlookAddinsState", + "remediate": { + "executor": "DisableOutlookAddins" + } +} diff --git a/Config/BaselineStandards/Exchange Standards/DisableResourceMailbox.json b/Config/BaselineStandards/Exchange Standards/DisableResourceMailbox.json new file mode 100644 index 0000000000000..afe04ac324742 --- /dev/null +++ b/Config/BaselineStandards/Exchange Standards/DisableResourceMailbox.json @@ -0,0 +1,58 @@ +{ + "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", + "collectorArgs": { + "Types": "None" + } + }, + "prepare": "Get-CIPPBaselineDisableResourceMailboxState", + "remediate": { + "executor": "GraphBulkSweep", + "refreshCache": [ + "Users" + ], + "writes": [ + { + "method": "PATCH", + "uri": "users/%id%", + "body": { + "accountEnabled": false + } + } + ] + } +} diff --git a/Config/BaselineStandards/Exchange Standards/DisableSharedMailbox.json b/Config/BaselineStandards/Exchange Standards/DisableSharedMailbox.json new file mode 100644 index 0000000000000..b38a4c6a02414 --- /dev/null +++ b/Config/BaselineStandards/Exchange Standards/DisableSharedMailbox.json @@ -0,0 +1,55 @@ +{ + "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", + "collectorArgs": { + "Types": "None" + } + }, + "prepare": "Get-CIPPBaselineDisableSharedMailboxState", + "remediate": { + "executor": "GraphBulkSweep", + "refreshCache": [ + "Users" + ], + "writes": [ + { + "method": "PATCH", + "uri": "users/%id%", + "body": { + "accountEnabled": false + } + } + ] + } +} diff --git a/Config/BaselineStandards/Exchange Standards/DisableViva.json b/Config/BaselineStandards/Exchange Standards/DisableViva.json new file mode 100644 index 0000000000000..c7f96176ba4d0 --- /dev/null +++ b/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/Config/BaselineStandards/Exchange Standards/EnableExchangeCloudManagement.json b/Config/BaselineStandards/Exchange Standards/EnableExchangeCloudManagement.json new file mode 100644 index 0000000000000..64bda7d3aabce --- /dev/null +++ b/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/Config/BaselineStandards/Exchange Standards/EnableLitigationHold.json b/Config/BaselineStandards/Exchange Standards/EnableLitigationHold.json new file mode 100644 index 0000000000000..32d5a6a647190 --- /dev/null +++ b/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/Config/BaselineStandards/Exchange Standards/EnableOnlineArchiving.json b/Config/BaselineStandards/Exchange Standards/EnableOnlineArchiving.json new file mode 100644 index 0000000000000..daead3303b99e --- /dev/null +++ b/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/Config/BaselineStandards/Exchange Standards/GlobalQuarantineNotifications.json b/Config/BaselineStandards/Exchange Standards/GlobalQuarantineNotifications.json new file mode 100644 index 0000000000000..3ec15d45f4109 --- /dev/null +++ b/Config/BaselineStandards/Exchange Standards/GlobalQuarantineNotifications.json @@ -0,0 +1,58 @@ +{ + "name": "GlobalQuarantineNotifications", + "label": "Set Global Quarantine Notification Interval", + "cat": "Exchange Standards", + "tag": [], + "impact": "Low Impact", + "helpText": "Sets how often quarantine notification emails are sent to users.", + "executiveText": "Controls how frequently users are notified about quarantined messages, balancing awareness of blocked mail against notification fatigue.", + "docsDescription": "Grades the global quarantine policy notification interval, normalized to hours on both sides. The Microsoft default policy cannot be modified, so remediation creates the custom DefaultGlobalTag when needed.", + "impactColour": "info", + "addedDate": "2026-08-16", + "powershellEquivalent": "Set-QuarantinePolicy -EndUserSpamNotificationFrequency", + "recommendedBy": [], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ], + "disabledFeatures": { + "report": false, + "warn": false, + "remediate": false + }, + "secureScoreImpact": 0, + "compare": "subset", + "variables": { + "NotificationInterval": { + "type": "autoComplete", + "required": true, + "creatable": false, + "options": [ + { + "label": "4 hours", + "value": "04:00:00" + }, + { + "label": "1 day/Daily", + "value": "1.00:00:00" + }, + { + "label": "7 days/Weekly", + "value": "7.00:00:00" + } + ], + "label": "Notification interval" + } + }, + "read": { + "cacheType": "ExoGlobalQuarantinePolicy" + }, + "prepare": "Get-CIPPBaselineGlobalQuarantineNotificationsState", + "remediate": { + "executor": "GlobalQuarantineNotifications", + "notificationInterval": "%NotificationInterval%" + } +} diff --git a/Config/BaselineStandards/Exchange Standards/GlobalQuarantineSettings.json b/Config/BaselineStandards/Exchange Standards/GlobalQuarantineSettings.json new file mode 100644 index 0000000000000..e8dbb70a1eae7 --- /dev/null +++ b/Config/BaselineStandards/Exchange Standards/GlobalQuarantineSettings.json @@ -0,0 +1,67 @@ +{ + "name": "GlobalQuarantineSettings", + "label": "Set Global Quarantine Notification Settings", + "cat": "Exchange Standards", + "tag": [], + "impact": "Low Impact", + "helpText": "Sets the sender branding on global quarantine notifications: display name, subject, disclaimer, sender address and organization branding. Only configured fields are enforced.", + "executiveText": "Brands the quarantine notification emails users receive so they recognize legitimate messages from the organization, reducing the risk of users ignoring or distrusting real quarantine alerts.", + "docsDescription": "Grades the global quarantine policy notification branding. Per-language text fields grade as contains - the configured text must be among the per-language values - and remediation writes the text into every configured language. The Microsoft default policy cannot be modified, so remediation creates the custom DefaultGlobalTag when needed. Fields left empty are not graded or written.", + "impactColour": "info", + "addedDate": "2026-08-16", + "powershellEquivalent": "New-QuarantinePolicy or Set-QuarantinePolicy", + "recommendedBy": [], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ], + "disabledFeatures": { + "report": false, + "warn": false, + "remediate": false + }, + "secureScoreImpact": 0, + "compare": "subset", + "variables": { + "SenderName": { + "omitWhenBlank": true, + "type": "textField", + "label": "Sender Display Name" + }, + "CustomSubject": { + "omitWhenBlank": true, + "type": "textField", + "label": "Subject" + }, + "CustomDisclaimer": { + "omitWhenBlank": true, + "type": "textField", + "label": "Disclaimer (max 200 characters)" + }, + "FromAddress": { + "omitWhenBlank": true, + "type": "textField", + "label": "Sender Address (must be an internal mailbox)" + }, + "OrganizationBrandingEnabled": { + "default": false, + "type": "switch", + "label": "Use Organization Branding (logo)" + } + }, + "read": { + "cacheType": "ExoGlobalQuarantinePolicy" + }, + "prepare": "Get-CIPPBaselineGlobalQuarantineSettingsState", + "remediate": { + "executor": "GlobalQuarantineSettings", + "senderName": "%SenderName%", + "customSubject": "%CustomSubject%", + "customDisclaimer": "%CustomDisclaimer%", + "fromAddress": "%FromAddress%", + "organizationBrandingEnabled": "%OrganizationBrandingEnabled%" + } +} diff --git a/Config/BaselineStandards/Exchange Standards/LegacyEmailReportAddins.json b/Config/BaselineStandards/Exchange Standards/LegacyEmailReportAddins.json new file mode 100644 index 0000000000000..44aba480fe1e3 --- /dev/null +++ b/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/Config/BaselineStandards/Exchange Standards/MailboxRecipientLimits.json b/Config/BaselineStandards/Exchange Standards/MailboxRecipientLimits.json new file mode 100644 index 0000000000000..d7e94c0f9c6a4 --- /dev/null +++ b/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/Config/BaselineStandards/Exchange Standards/OMEBranding.json b/Config/BaselineStandards/Exchange Standards/OMEBranding.json new file mode 100644 index 0000000000000..e7e564b77d032 --- /dev/null +++ b/Config/BaselineStandards/Exchange Standards/OMEBranding.json @@ -0,0 +1,119 @@ +{ + "name": "OMEBranding", + "label": "Set Encrypted Message Branding (OME)", + "cat": "Exchange Standards", + "tag": [], + "impact": "Low Impact", + "helpText": "Applies the organization's branding to encrypted messages and the secure reading portal: colors, texts, logo, and recipient sign-in options. Only configured fields are enforced.", + "executiveText": "Brands encrypted email so recipients recognize legitimate secure messages from the organization - consistent branding makes phishing lookalikes easier to spot and secure mail more trusted.", + "docsDescription": "Grades the OME configuration's branding fields against the configured values - fields left empty are not graded or written. A configured logo grades as permanent drift and re-uploads on every remediation run, because Exchange never exposes the current image for comparison; this matches the classic standard.", + "impactColour": "info", + "addedDate": "2026-08-16", + "powershellEquivalent": "Set-OMEConfiguration", + "recommendedBy": [], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ], + "disabledFeatures": { + "report": false, + "warn": false, + "remediate": false + }, + "secureScoreImpact": 0, + "compare": "subset", + "variables": { + "BackgroundColor": { + "type": "textField", + "label": "Background Color (hex, e.g. #ffffff)", + "omitWhenBlank": true + }, + "LogoUrl": { + "type": "textField", + "label": "Logo Image URL (max 40KB, 170x70px)", + "omitWhenBlank": true + }, + "IntroductionText": { + "type": "textField", + "label": "Text next to the sender's name and address", + "omitWhenBlank": true + }, + "ReadButtonText": { + "type": "textField", + "label": "Read Button Text", + "omitWhenBlank": true + }, + "EmailText": { + "type": "textField", + "label": "Email text below the button", + "omitWhenBlank": true + }, + "PrivacyStatementUrl": { + "type": "textField", + "label": "Privacy Statement URL", + "omitWhenBlank": true + }, + "DisclaimerText": { + "type": "textField", + "label": "Disclaimer Statement", + "omitWhenBlank": true + }, + "PortalText": { + "type": "textField", + "label": "Portal header text (max 128 chars)", + "omitWhenBlank": true + }, + "OTPEnabled": { + "type": "autoComplete", + "creatable": false, + "label": "One-Time Pass Code sign-in", + "omitWhenBlank": true, + "options": [ + { + "label": "Enabled", + "value": true + }, + { + "label": "Disabled", + "value": false + } + ] + }, + "SocialIdSignIn": { + "type": "autoComplete", + "creatable": false, + "label": "Social ID Sign-In", + "omitWhenBlank": true, + "options": [ + { + "label": "Enabled", + "value": true + }, + { + "label": "Disabled", + "value": false + } + ] + } + }, + "read": { + "cacheType": "ExoOMEConfiguration" + }, + "prepare": "Get-CIPPBaselineOMEBrandingState", + "remediate": { + "executor": "OMEBranding", + "backgroundColor": "%BackgroundColor%", + "logoUrl": "%LogoUrl%", + "introductionText": "%IntroductionText%", + "readButtonText": "%ReadButtonText%", + "emailText": "%EmailText%", + "privacyStatementUrl": "%PrivacyStatementUrl%", + "disclaimerText": "%DisclaimerText%", + "portalText": "%PortalText%", + "otpEnabled": "%OTPEnabled%", + "socialIdSignIn": "%SocialIdSignIn%" + } +} diff --git a/Config/BaselineStandards/Exchange Standards/OutBoundSpamAlert.json b/Config/BaselineStandards/Exchange Standards/OutBoundSpamAlert.json index dc757e2e5a842..6234e572dfce6 100644 --- a/Config/BaselineStandards/Exchange Standards/OutBoundSpamAlert.json +++ b/Config/BaselineStandards/Exchange Standards/OutBoundSpamAlert.json @@ -37,7 +37,7 @@ "OutboundSpamContact": { "type": "textField", "label": "Outbound spam contact", - "default": "" + "required": true } }, "expected": { diff --git a/Config/BaselineStandards/Exchange Standards/PhishSimSpoofIntelligence.json b/Config/BaselineStandards/Exchange Standards/PhishSimSpoofIntelligence.json new file mode 100644 index 0000000000000..c086c1114a898 --- /dev/null +++ b/Config/BaselineStandards/Exchange Standards/PhishSimSpoofIntelligence.json @@ -0,0 +1,49 @@ +{ + "name": "PhishSimSpoofIntelligence", + "label": "Allow Phishing Simulations in Spoof Intelligence", + "cat": "Exchange Standards", + "tag": [], + "impact": "Medium Impact", + "helpText": "Allows the configured phishing simulation sending infrastructures through spoof intelligence. Optionally removes entries outside the configured list.", + "executiveText": "Lets sanctioned phishing simulation platforms deliver their test emails without being blocked as spoofing, so security awareness campaigns actually reach users.", + "docsDescription": "Grades which configured sending infrastructures are missing from the spoof intelligence allow list; entries added by hand are left alone unless strict ownership is enabled, in which case entries outside the configured set grade and remediate as removals. Each allowed domain is written for both Internal and External spoof types.", + "impactColour": "warning", + "addedDate": "2026-08-16", + "powershellEquivalent": "New-TenantAllowBlockListSpoofItems", + "recommendedBy": [], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ], + "disabledFeatures": { + "report": false, + "warn": false, + "remediate": false + }, + "secureScoreImpact": 0, + "compare": "subset", + "variables": { + "AllowedDomains": { + "type": "autoComplete", + "required": true, + "creatable": true, + "label": "Allowed sending infrastructures (domains)", + "multiple": true + }, + "RemoveExtraDomains": { + "default": false, + "type": "switch", + "label": "Remove entries outside the configured list" + } + }, + "read": { + "cacheType": "ExoTenantAllowBlockListSpoofItems" + }, + "prepare": "Get-CIPPBaselinePhishSimSpoofIntelligenceState", + "remediate": { + "executor": "PhishSimSpoofIntelligence" + } +} diff --git a/Config/BaselineStandards/Exchange Standards/PhishingSimulations.json b/Config/BaselineStandards/Exchange Standards/PhishingSimulations.json new file mode 100644 index 0000000000000..4fd2581573ca7 --- /dev/null +++ b/Config/BaselineStandards/Exchange Standards/PhishingSimulations.json @@ -0,0 +1,70 @@ +{ + "name": "PhishingSimulations", + "label": "Configure Phishing Simulation Overrides", + "cat": "Exchange Standards", + "tag": [], + "impact": "Medium Impact", + "helpText": "Configures the phishing simulation override policy, its sender rule, and the advanced-delivery URL allow list for the configured simulation platform.", + "executiveText": "Ensures sanctioned phishing simulation campaigns bypass filtering end to end - sender infrastructure, domains and landing URLs - so awareness testing measures user behaviour rather than mail filtering.", + "docsDescription": "Grades three legs separately: the override policy exists enabled, the override rule carries the configured sender IP ranges and domains, and the simulation URLs are on the Url advanced-delivery allow list. Lists are additive unless strict ownership is enabled. Remediation writes only the drifted legs, using add/remove deltas on the existing rule.", + "impactColour": "warning", + "addedDate": "2026-08-16", + "powershellEquivalent": "New-PhishSimOverridePolicy, New-ExoPhishSimOverrideRule or New-TenantAllowBlockListItems", + "recommendedBy": [], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ], + "disabledFeatures": { + "report": false, + "warn": false, + "remediate": false + }, + "secureScoreImpact": 0, + "compare": "subset", + "variables": { + "Domains": { + "type": "autoComplete", + "required": true, + "creatable": true, + "label": "Phishing Simulation Domains", + "multiple": true + }, + "SenderIpRanges": { + "type": "autoComplete", + "required": true, + "creatable": true, + "label": "Phishing Simulation Sender IP Ranges", + "multiple": true + }, + "PhishingSimUrls": { + "omitWhenBlank": true, + "type": "autoComplete", + "creatable": true, + "label": "Phishing Simulation URLs", + "multiple": true + }, + "RemoveExtraUrls": { + "default": false, + "type": "switch", + "label": "Remove entries outside the configured lists" + } + }, + "read": { + "requiredCaches": [ + "ExoPhishSimOverridePolicy", + "ExoPhishSimOverrideRule", + "ExoPhishSimUrlAllowItems" + ], + "cacheType": "ExoPhishSimConfig" + }, + "prepare": "Get-CIPPBaselinePhishingSimulationsState", + "remediate": { + "executor": "PhishingSimulations", + "domains": "%Domains%", + "senderIpRanges": "%SenderIpRanges%" + } +} diff --git a/Config/BaselineStandards/Exchange Standards/ProfilePhotos.json b/Config/BaselineStandards/Exchange Standards/ProfilePhotos.json new file mode 100644 index 0000000000000..efdecb0cf6e34 --- /dev/null +++ b/Config/BaselineStandards/Exchange Standards/ProfilePhotos.json @@ -0,0 +1,58 @@ +{ + "name": "ProfilePhotos", + "label": "Allow users to change their profile photos", + "cat": "Exchange Standards", + "tag": [], + "impact": "Low Impact", + "helpText": "Enables or disables users changing their own profile photos, across both the Graph photo policy and the default OWA mailbox policy.", + "executiveText": "Standardizes whether staff manage their own directory photos or IT controls them for consistency and appropriateness.", + "docsDescription": "Grades both surfaces - the Graph photo update settings (disabled means only Global and User admins may change photos) and the default OWA mailbox policy's SetPhotoEnabled - because either alone leaves users a side door. Remediation writes both.", + "impactColour": "info", + "addedDate": "2026-08-16", + "powershellEquivalent": "Set-OwaMailboxPolicy and Graph photoUpdateSettings", + "recommendedBy": [], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ], + "disabledFeatures": { + "report": false, + "warn": false, + "remediate": false + }, + "secureScoreImpact": 0, + "compare": "subset", + "variables": { + "state": { + "label": "Profile photo changes", + "type": "autoComplete", + "creatable": false, + "required": true, + "options": [ + { + "value": "enabled", + "label": "Enabled (users may change)" + }, + { + "value": "disabled", + "label": "Disabled (admins only)" + } + ] + } + }, + "read": { + "requiredCaches": [ + "PhotoUpdateSettings", + "OwaMailboxPolicy" + ], + "cacheType": "PhotoUpdateSettings" + }, + "prepare": "Get-CIPPBaselineProfilePhotosState", + "remediate": { + "executor": "ProfilePhotos", + "state": "%state%" + } +} diff --git a/Config/BaselineStandards/Exchange Standards/RetentionPolicyTag.json b/Config/BaselineStandards/Exchange Standards/RetentionPolicyTag.json new file mode 100644 index 0000000000000..771f621bd45a8 --- /dev/null +++ b/Config/BaselineStandards/Exchange Standards/RetentionPolicyTag.json @@ -0,0 +1,47 @@ +{ + "name": "RetentionPolicyTag", + "label": "Retention Policy Tag for Deleted Items", + "cat": "Exchange Standards", + "tag": [], + "impact": "High Impact", + "helpText": "Creates the CIPP Deleted Items retention tag, permanently deleting items after the configured number of days, and links it into the Default MRM Policy.", + "executiveText": "Automatically purges deleted email after the configured window, reducing data exposure and storage growth while keeping mailbox behaviour consistent across the fleet.", + "docsDescription": "Grades six facts about the fixed CIPP Deleted Items tag: existence, retention enabled, PermanentlyDelete action, the configured age limit, the DeletedItems type, and its link into the Default MRM Policy. The link write resends the full existing link list - other tags are never unlinked.", + "impactColour": "error", + "addedDate": "2026-08-16", + "powershellEquivalent": "New-RetentionPolicyTag or Set-RetentionPolicyTag", + "recommendedBy": [], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ], + "disabledFeatures": { + "report": false, + "warn": false, + "remediate": false + }, + "secureScoreImpact": 0, + "compare": "subset", + "variables": { + "AgeLimitForRetention": { + "type": "number", + "required": true, + "label": "Retention Days" + } + }, + "read": { + "requiredCaches": [ + "ExoRetentionPolicyTags", + "ExoRetentionPolicies" + ], + "cacheType": "ExoRetentionPolicyTags" + }, + "prepare": "Get-CIPPBaselineRetentionPolicyTagState", + "remediate": { + "executor": "RetentionPolicyTag", + "ageLimitForRetention": "%AgeLimitForRetention%" + } +} diff --git a/Config/BaselineStandards/Exchange Standards/RotateDKIM.json b/Config/BaselineStandards/Exchange Standards/RotateDKIM.json new file mode 100644 index 0000000000000..d78392d3c6301 --- /dev/null +++ b/Config/BaselineStandards/Exchange Standards/RotateDKIM.json @@ -0,0 +1,36 @@ +{ + "name": "RotateDKIM", + "label": "Rotate DKIM keys that are 1024 bit to 2048 bit", + "cat": "Exchange Standards", + "tag": [], + "impact": "Low Impact", + "helpText": "Rotates enabled DKIM configurations still using 1024-bit keys to 2048-bit.", + "executiveText": "Upgrades weak email signing keys to current cryptographic strength, keeping the organization's email authentication resistant to forgery.", + "docsDescription": "Grades enabled DKIM configurations whose selector keys are still 1024-bit. Remediation rotates each to 2048-bit; rotation is asynchronous in Exchange, so the row stays at drift until the new key publishes.", + "impactColour": "info", + "addedDate": "2026-08-16", + "powershellEquivalent": "Rotate-DkimSigningConfig", + "recommendedBy": [], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ], + "disabledFeatures": { + "report": false, + "warn": false, + "remediate": false + }, + "secureScoreImpact": 0, + "compare": "subset", + "variables": {}, + "read": { + "cacheType": "ExoDkimSigningConfig" + }, + "prepare": "Get-CIPPBaselineRotateDKIMState", + "remediate": { + "executor": "RotateDKIM" + } +} diff --git a/Config/BaselineStandards/Exchange Standards/SafeSendersDisable.json b/Config/BaselineStandards/Exchange Standards/SafeSendersDisable.json new file mode 100644 index 0000000000000..9917f305b6b70 --- /dev/null +++ b/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/Config/BaselineStandards/Exchange Standards/SendReceiveLimitTenant.json b/Config/BaselineStandards/Exchange Standards/SendReceiveLimitTenant.json new file mode 100644 index 0000000000000..22cb5cd4b3fc8 --- /dev/null +++ b/Config/BaselineStandards/Exchange Standards/SendReceiveLimitTenant.json @@ -0,0 +1,49 @@ +{ + "name": "SendReceiveLimitTenant", + "label": "Set send/receive size limits", + "cat": "Exchange Standards", + "tag": [], + "impact": "Low Impact", + "helpText": "Sets the maximum send and receive message sizes on every mailbox plan, which new mailboxes inherit.", + "executiveText": "Standardizes maximum email sizes across the organization so mail flow behaves predictably and oversized attachments are handled the same way everywhere.", + "docsDescription": "Grades which mailbox plans are off the configured send/receive limits - Unlimited always counts as off-limits - and remediation sets the limits on each offending plan.", + "impactColour": "info", + "addedDate": "2026-08-16", + "powershellEquivalent": "Set-MailboxPlan", + "recommendedBy": [], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ], + "disabledFeatures": { + "report": false, + "warn": false, + "remediate": false + }, + "secureScoreImpact": 0, + "compare": "subset", + "variables": { + "SendLimit": { + "default": 35, + "type": "number", + "label": "Send limit in MB (default 35)" + }, + "ReceiveLimit": { + "default": 36, + "type": "number", + "label": "Receive limit in MB (default 36)" + } + }, + "read": { + "cacheType": "ExoMailboxPlans" + }, + "prepare": "Get-CIPPBaselineSendReceiveLimitTenantState", + "remediate": { + "executor": "SendReceiveLimitTenant", + "sendLimit": "%SendLimit%", + "receiveLimit": "%ReceiveLimit%" + } +} diff --git a/Config/BaselineStandards/Exchange Standards/SpoofWarn.json b/Config/BaselineStandards/Exchange Standards/SpoofWarn.json new file mode 100644 index 0000000000000..d259955cfa467 --- /dev/null +++ b/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/Config/BaselineStandards/Exchange Standards/TeamsMeetingsByDefault.json b/Config/BaselineStandards/Exchange Standards/TeamsMeetingsByDefault.json index 6871ea1fcaecd..3153192383da8 100644 --- a/Config/BaselineStandards/Exchange Standards/TeamsMeetingsByDefault.json +++ b/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/Config/BaselineStandards/Exchange Standards/UserSubmissions.json b/Config/BaselineStandards/Exchange Standards/UserSubmissions.json new file mode 100644 index 0000000000000..d4613f5223e4b --- /dev/null +++ b/Config/BaselineStandards/Exchange Standards/UserSubmissions.json @@ -0,0 +1,63 @@ +{ + "name": "UserSubmissions", + "label": "Set the state of the built-in Report button in Outlook", + "cat": "Exchange Standards", + "tag": [], + "impact": "Medium Impact", + "helpText": "Enables or disables the built-in Report button in Outlook, optionally routing reported messages to a custom mailbox as well as Microsoft.", + "executiveText": "Governs how employees report suspicious email: enabling the built-in report button routes phishing reports to Microsoft and optionally to the security team, turning users into a detection layer.", + "docsDescription": "Grades the report submission policy and rule against the chosen posture: enabled (reports to Microsoft), enabled with a custom address (all three report types route to it and the rule is enabled), or disabled. The configured address supports tenant %variable% replacement. Remediation creates or updates the default policy and rule, removing the rule when reporting is turned off.", + "impactColour": "warning", + "addedDate": "2026-08-16", + "powershellEquivalent": "New-ReportSubmissionPolicy or Set-ReportSubmissionPolicy", + "recommendedBy": [], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ], + "disabledFeatures": { + "report": false, + "warn": false, + "remediate": false + }, + "secureScoreImpact": 0, + "compare": "subset", + "variables": { + "state": { + "type": "autoComplete", + "required": true, + "creatable": false, + "options": [ + { + "label": "Enabled", + "value": "enable" + }, + { + "label": "Disabled", + "value": "disable" + } + ], + "label": "Report button state" + }, + "email": { + "omitWhenBlank": true, + "type": "textField", + "label": "Destination email address (optional)" + } + }, + "read": { + "requiredCaches": [ + "ReportSubmissionPolicy", + "ReportSubmissionRule" + ], + "cacheType": "ReportSubmissionPolicy" + }, + "prepare": "Get-CIPPBaselineUserSubmissionsState", + "remediate": { + "executor": "UserSubmissions", + "state": "%state%" + } +} diff --git a/Config/BaselineStandards/Exchange Standards/calDefault.json b/Config/BaselineStandards/Exchange Standards/calDefault.json new file mode 100644 index 0000000000000..c787d69663df2 --- /dev/null +++ b/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/Config/BaselineStandards/Global Standards/ActivityBasedTimeout.json b/Config/BaselineStandards/Global Standards/ActivityBasedTimeout.json index d9dabd10e7623..aef903da30837 100644 --- a/Config/BaselineStandards/Global Standards/ActivityBasedTimeout.json +++ b/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", @@ -57,6 +61,9 @@ "read": { "cacheType": "ActivityBasedTimeoutPolicy" }, - "custom": true, - "customFunction": "Invoke-CIPPBaselineActivityBasedTimeout" + "prepare": "Get-CIPPBaselineActivityBasedTimeoutState", + "remediate": { + "executor": "ActivityBasedTimeout", + "timeout": "%timeout%" + } } diff --git a/Config/BaselineStandards/Global Standards/AddDMARCToMOERA.json b/Config/BaselineStandards/Global Standards/AddDMARCToMOERA.json new file mode 100644 index 0000000000000..039f34d41c048 --- /dev/null +++ b/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/Config/BaselineStandards/Global Standards/AnonReportDisable.json b/Config/BaselineStandards/Global Standards/AnonReportDisable.json new file mode 100644 index 0000000000000..b5c9d3fff7950 --- /dev/null +++ b/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/Config/BaselineStandards/Global Standards/Branding.json b/Config/BaselineStandards/Global Standards/Branding.json new file mode 100644 index 0000000000000..1129a7ca25027 --- /dev/null +++ b/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/Config/BaselineStandards/Global Standards/DisableGuestDirectory.json b/Config/BaselineStandards/Global Standards/DisableGuestDirectory.json index 018be8354e315..f9230b63c68fe 100644 --- a/Config/BaselineStandards/Global Standards/DisableGuestDirectory.json +++ b/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/Config/BaselineStandards/Global Standards/EnableCustomerLockbox.json b/Config/BaselineStandards/Global Standards/EnableCustomerLockbox.json index bad82ff398665..a13d6a184e192 100644 --- a/Config/BaselineStandards/Global Standards/EnableCustomerLockbox.json +++ b/Config/BaselineStandards/Global Standards/EnableCustomerLockbox.json @@ -20,7 +20,9 @@ "CIS" ], "requiredCapabilities": [ - "CustomerLockbox" + "LOCKBOX_ENTERPRISE", + "LOCKBOX_ENTERPRISE_GOV", + "CustomerLockboxA_Enterprise" ], "secureScoreImpact": 0, "compare": "subset", diff --git a/Config/BaselineStandards/Global Standards/EnableNamePronunciation.json b/Config/BaselineStandards/Global Standards/EnableNamePronunciation.json new file mode 100644 index 0000000000000..0ea10cc81fb38 --- /dev/null +++ b/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/Config/BaselineStandards/Global Standards/EnablePronouns.json b/Config/BaselineStandards/Global Standards/EnablePronouns.json new file mode 100644 index 0000000000000..603c9cd581f40 --- /dev/null +++ b/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/Config/BaselineStandards/Global Standards/FormsPhishingProtection.json b/Config/BaselineStandards/Global Standards/FormsPhishingProtection.json new file mode 100644 index 0000000000000..ddc211eeb49d7 --- /dev/null +++ b/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/Config/BaselineStandards/Global Standards/MailContacts.json b/Config/BaselineStandards/Global Standards/MailContacts.json new file mode 100644 index 0000000000000..440f06a382aa5 --- /dev/null +++ b/Config/BaselineStandards/Global Standards/MailContacts.json @@ -0,0 +1,55 @@ +{ + "name": "MailContacts", + "label": "Set contact e-mails", + "cat": "Global Standards", + "tag": [], + "impact": "Low Impact", + "helpText": "Sets the organization's notification contacts: technical, security, marketing and general/privacy. Only configured contacts are enforced.", + "executiveText": "Keeps Microsoft's service, security and privacy notifications flowing to the right mailboxes instead of a former employee's.", + "docsDescription": "Grades only the configured contacts: marketing as contains, security+technical as a set against the technical notification list, and the general contact against the privacy profile. Remediation writes only the configured members.", + "impactColour": "info", + "addedDate": "2026-08-16", + "powershellEquivalent": "Graph: PATCH organization", + "recommendedBy": [], + "requiredCapabilities": [], + "disabledFeatures": { + "report": false, + "warn": false, + "remediate": false + }, + "secureScoreImpact": 0, + "compare": "subset", + "variables": { + "GeneralContact": { + "type": "textField", + "label": "General/privacy contact email", + "omitWhenBlank": true + }, + "SecurityContact": { + "type": "textField", + "label": "Security contact email", + "omitWhenBlank": true + }, + "MarketingContact": { + "type": "textField", + "label": "Marketing contact email", + "omitWhenBlank": true + }, + "TechContact": { + "type": "textField", + "label": "Technical contact email", + "omitWhenBlank": true + } + }, + "read": { + "cacheType": "Organization" + }, + "prepare": "Get-CIPPBaselineMailContactsState", + "remediate": { + "executor": "MailContacts", + "generalContact": "%GeneralContact%", + "securityContact": "%SecurityContact%", + "marketingContact": "%MarketingContact%", + "techContact": "%TechContact%" + } +} diff --git a/Config/BaselineStandards/Global Standards/PhishProtection.json b/Config/BaselineStandards/Global Standards/PhishProtection.json new file mode 100644 index 0000000000000..72dfd0b025fff --- /dev/null +++ b/Config/BaselineStandards/Global Standards/PhishProtection.json @@ -0,0 +1,32 @@ +{ + "name": "PhishProtection", + "label": "Enable Phishing Protection system via branding CSS", + "cat": "Global Standards", + "tag": [], + "impact": "Low Impact", + "helpText": "Adds branding to the logon page that only appears if the url is not login.microsoftonline.com. This potentially prevents AITM attacks via EvilNginx, and automatically generates alerts if a clone of your login page is found.", + "executiveText": "Adds a hidden canary to the company sign-in page that exposes cloned phishing pages and alerts when one is found, protecting staff credentials from adversary-in-the-middle attacks.", + "docsDescription": "Grades whether the default branding localization's custom CSS contains the tenant's clone-detection canary (the clone.cipp.app background-image URL carrying this instance's CIPPURL from the Config table). The branding singleton reads live. Remediation strips a known malformed variant, creates the default localization when missing (Accept-Language 0, tolerating already-exists), and APPENDS the canary to the existing CSS - operator customizations are never overwritten. Report and warn are disabled as in the classic: this standard acts through remediation only.", + "impactColour": "info", + "addedDate": "2026-08-16", + "powershellEquivalent": "Portal only", + "recommendedBy": [], + "requiredCapabilities": [ + "AAD_PREMIUM", + "AAD_PREMIUM_P2", + "OFFICE_BUSINESS" + ], + "disabledFeatures": { + "report": true, + "warn": true, + "remediate": false + }, + "secureScoreImpact": 0, + "compare": "subset", + "variables": {}, + "read": {}, + "prepare": "Get-CIPPBaselinePhishProtectionState", + "remediate": { + "executor": "PhishProtection" + } +} diff --git a/Config/BaselineStandards/Global Standards/RestrictThirdPartyStorageServices.json b/Config/BaselineStandards/Global Standards/RestrictThirdPartyStorageServices.json new file mode 100644 index 0000000000000..ec97a3f715940 --- /dev/null +++ b/Config/BaselineStandards/Global Standards/RestrictThirdPartyStorageServices.json @@ -0,0 +1,38 @@ +{ + "name": "RestrictThirdPartyStorageServices", + "label": "Restrict third-party storage services in Microsoft 365 on the web", + "cat": "Global Standards", + "tag": [], + "impact": "Medium Impact", + "helpText": "Restricts third-party storage services in Microsoft 365 on the web by managing the Microsoft 365 on the web service principal. This disables integrations with services like Dropbox, Google Drive, and Box.", + "executiveText": "Prevents employees from using external cloud storage services within Microsoft 365, keeping company data inside controlled corporate systems.", + "docsDescription": "Grades one fact from the ServicePrincipals cache: the Microsoft 365 on the web service principal (appId c1f33bc0-bdb4-4248-ba9b-096807ddb43e) exists AND is disabled; a missing principal grades unrestricted, the platform default. Remediation PATCHes the appId-addressed upsert endpoint with Prefer: create-if-missing - the plain path does not resolve this principal, and the upsert creates-then-disables it where it never existed.", + "impactColour": "warning", + "addedDate": "2026-08-16", + "powershellEquivalent": "New-MgServicePrincipal and Update-MgServicePrincipal", + "recommendedBy": [], + "requiredCapabilities": [ + "SHAREPOINTWAC", + "SHAREPOINTSTANDARD", + "SHAREPOINTENTERPRISE", + "SHAREPOINTENTERPRISE_EDU", + "SHAREPOINTENTERPRISE_GOV", + "ONEDRIVE_BASIC", + "ONEDRIVE_ENTERPRISE" + ], + "disabledFeatures": { + "report": false, + "warn": false, + "remediate": false + }, + "secureScoreImpact": 0, + "compare": "subset", + "variables": {}, + "read": { + "cacheType": "ServicePrincipals" + }, + "prepare": "Get-CIPPBaselineRestrictThirdPartyStorageServicesState", + "remediate": { + "executor": "RestrictThirdPartyStorageServices" + } +} diff --git a/Config/BaselineStandards/Global Standards/SecureScoreRemediation.json b/Config/BaselineStandards/Global Standards/SecureScoreRemediation.json new file mode 100644 index 0000000000000..10a78dd04b01d --- /dev/null +++ b/Config/BaselineStandards/Global Standards/SecureScoreRemediation.json @@ -0,0 +1,79 @@ +{ + "name": "SecureScoreRemediation", + "label": "Set Secure Score control states", + "cat": "Global Standards", + "tag": [], + "impact": "Low Impact", + "helpText": "Marks the configured Secure Score controls as default, ignored, reviewed, or resolved by a third party.", + "executiveText": "Keeps the Secure Score honest across tenants by consistently marking controls that are handled elsewhere or deliberately ignored, so the score reflects real posture.", + "docsDescription": "Grades each configured control's EFFECTIVE state - the newest controlStateUpdates entry, or default when none exist. Controls outside the configured lists are never graded. Remediation bulk-patches the drifted controls; Defender (scid_) controls cannot be set through this endpoint and are skipped with a log line.", + "impactColour": "info", + "addedDate": "2026-08-16", + "powershellEquivalent": "Graph: PATCH security/secureScoreControlProfiles", + "recommendedBy": [], + "requiredCapabilities": [], + "disabledFeatures": { + "report": false, + "warn": false, + "remediate": false + }, + "secureScoreImpact": 0, + "compare": "subset", + "variables": { + "Default": { + "label": "Controls to set back to Default", + "type": "autoComplete", + "creatable": true, + "omitWhenBlank": true, + "multiple": true, + "api": { + "url": "/secureScore.json", + "labelField": "title", + "valueField": "id" + } + }, + "Ignored": { + "label": "Controls to mark Ignored", + "type": "autoComplete", + "creatable": true, + "omitWhenBlank": true, + "multiple": true, + "api": { + "url": "/secureScore.json", + "labelField": "title", + "valueField": "id" + } + }, + "ThirdParty": { + "label": "Controls resolved by third party", + "type": "autoComplete", + "creatable": true, + "omitWhenBlank": true, + "multiple": true, + "api": { + "url": "/secureScore.json", + "labelField": "title", + "valueField": "id" + } + }, + "Reviewed": { + "label": "Controls to mark Reviewed", + "type": "autoComplete", + "creatable": true, + "omitWhenBlank": true, + "multiple": true, + "api": { + "url": "/secureScore.json", + "labelField": "title", + "valueField": "id" + } + } + }, + "read": { + "cacheType": "SecureScoreControlProfiles" + }, + "prepare": "Get-CIPPBaselineSecureScoreRemediationState", + "remediate": { + "executor": "SecureScoreRemediation" + } +} diff --git a/Config/BaselineStandards/Intune Standards/AppleEnrollmentTypeProfile.json b/Config/BaselineStandards/Intune Standards/AppleEnrollmentTypeProfile.json new file mode 100644 index 0000000000000..41f77af5b0009 --- /dev/null +++ b/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/Config/BaselineStandards/Intune Standards/AutopilotProfile.json b/Config/BaselineStandards/Intune Standards/AutopilotProfile.json new file mode 100644 index 0000000000000..aad00277eb730 --- /dev/null +++ b/Config/BaselineStandards/Intune Standards/AutopilotProfile.json @@ -0,0 +1,132 @@ +{ + "name": "AutopilotProfile", + "label": "Enable Autopilot Profile", + "cat": "Intune Standards", + "tag": [], + "impact": "Low Impact", + "helpText": "Assigns the appropriate Autopilot profile to streamline device deployment, including unique name templates, language options, and local admin privileges.", + "executiveText": "Standardizes how new devices are provisioned, so every laptop arrives configured the same way with no manual setup.", + "docsDescription": "One instance per profile display name. Grades the classic's exact field set from the cached Autopilot profiles; self-deploying mode forces White Glove off and skips the user type, and an empty language matches an empty locale. Remediation deploys through Set-CIPPDefaultAPDeploymentProfile.", + "impactColour": "info", + "addedDate": "2026-08-16", + "powershellEquivalent": "Graph API - deviceManagement/windowsAutopilotDeploymentProfiles", + "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 + }, + "DeviceNameTemplate": { + "type": "textField", + "label": "Unique Device Name Template", + "omitWhenBlank": true + }, + "Languages": { + "type": "autoComplete", + "creatable": true, + "label": "Language tag (e.g. os-default, en-US)", + "omitWhenBlank": true, + "api": { + "url": "/languageList.json", + "labelField": "languageTag", + "valueField": "tag" + } + }, + "CollectHash": { + "type": "switch", + "label": "Convert all targeted devices to Autopilot", + "omitWhenBlank": true, + "default": true + }, + "AssignToAllDevices": { + "type": "switch", + "label": "Assign to all devices", + "omitWhenBlank": true, + "default": true + }, + "SelfDeployingMode": { + "type": "switch", + "label": "Enable Self-deploying Mode", + "omitWhenBlank": true, + "default": true + }, + "HideTerms": { + "type": "switch", + "label": "Hide Terms and Conditions", + "omitWhenBlank": true, + "default": true + }, + "HidePrivacy": { + "type": "switch", + "label": "Hide Privacy Settings", + "omitWhenBlank": true, + "default": true + }, + "HideChangeAccount": { + "type": "switch", + "label": "Hide Change Account Options", + "omitWhenBlank": true, + "default": true + }, + "NotLocalAdmin": { + "type": "switch", + "label": "Setup user as a standard user (not local admin)", + "omitWhenBlank": true, + "default": true + }, + "AllowWhiteGlove": { + "type": "switch", + "label": "Allow White Glove OOBE", + "omitWhenBlank": true, + "default": true + }, + "AutoKeyboard": { + "type": "switch", + "label": "Automatically configure keyboard", + "omitWhenBlank": true, + "default": true + } + }, + "read": { + "cacheType": "IntuneWindowsAutopilotDeploymentProfiles" + }, + "prepare": "Get-CIPPBaselineAutopilotProfileState", + "remediate": { + "executor": "AutopilotProfile", + "displayName": "%DisplayName%", + "description": "%Description%", + "deviceNameTemplate": "%DeviceNameTemplate%", + "languages": "%Languages%", + "collectHash": "%CollectHash%", + "assignToAllDevices": "%AssignToAllDevices%", + "selfDeployingMode": "%SelfDeployingMode%", + "hideTerms": "%HideTerms%", + "hidePrivacy": "%HidePrivacy%", + "hideChangeAccount": "%HideChangeAccount%", + "notLocalAdmin": "%NotLocalAdmin%", + "allowWhiteGlove": "%AllowWhiteGlove%", + "autoKeyboard": "%AutoKeyboard%" + }, + "multiple": true, + "instanceIdentity": "DisplayName" +} diff --git a/Config/BaselineStandards/Intune Standards/AutopilotStatusPage.json b/Config/BaselineStandards/Intune Standards/AutopilotStatusPage.json new file mode 100644 index 0000000000000..5be8f5ab75896 --- /dev/null +++ b/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/Config/BaselineStandards/Intune Standards/DefaultPlatformRestrictions.json b/Config/BaselineStandards/Intune Standards/DefaultPlatformRestrictions.json new file mode 100644 index 0000000000000..fc6d8b5ac222a --- /dev/null +++ b/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/Config/BaselineStandards/Intune Standards/DeployCheckChromeExtension.json b/Config/BaselineStandards/Intune Standards/DeployCheckChromeExtension.json new file mode 100644 index 0000000000000..091ef378f2791 --- /dev/null +++ b/Config/BaselineStandards/Intune Standards/DeployCheckChromeExtension.json @@ -0,0 +1,217 @@ +{ + "name": "DeployCheckChromeExtension", + "label": "Deploy Check by CyberDrain Browser Extension", + "cat": "Intune Standards", + "tag": [], + "impact": "Low Impact", + "helpText": "Deploys the Check by CyberDrain browser extension via a Win32 script app in Intune for both Chrome and Edge with configurable settings.", + "executiveText": "Automatically protects staff browsers against phishing with the Check extension, consistently configured and force-installed fleet-wide.", + "docsDescription": "Grades presence of the 'Check by CyberDrain - Browser Extension' Win32 app, the classic's check. Settings drift is handled by the EXECUTOR: the app description carries a config fingerprint and every remediation run rebuilds the scripts, redeploying only when the fingerprint changed - which is why this standard runs checkBeforeRun false.", + "impactColour": "info", + "addedDate": "2026-08-16", + "powershellEquivalent": "Add-CIPPW32ScriptApplication", + "recommendedBy": [], + "requiredCapabilities": [ + "INTUNE_A", + "MDM_Services", + "EMS", + "SCCM", + "MICROSOFTINTUNEPLAN1" + ], + "disabledFeatures": { + "report": false, + "warn": false, + "remediate": false + }, + "secureScoreImpact": 0, + "compare": "subset", + "variables": { + "showNotifications": { + "type": "switch", + "label": "Show notifications", + "omitWhenBlank": true, + "default": true + }, + "enableValidPageBadge": { + "type": "switch", + "label": "Enable valid page badge", + "omitWhenBlank": true, + "default": false + }, + "enablePageBlocking": { + "type": "switch", + "label": "Enable page blocking", + "omitWhenBlank": true, + "default": true + }, + "forceToolbarPin": { + "type": "switch", + "label": "Force pin extension to toolbar", + "omitWhenBlank": true, + "default": false + }, + "enableCippReporting": { + "type": "switch", + "label": "Enable CIPP reporting", + "omitWhenBlank": true, + "default": true + }, + "customRulesUrl": { + "type": "textField", + "label": "Custom Rules URL", + "omitWhenBlank": true + }, + "updateInterval": { + "type": "number", + "label": "Update interval (hours, default 24)", + "omitWhenBlank": true + }, + "enableDebugLogging": { + "type": "switch", + "label": "Enable debug logging", + "omitWhenBlank": true, + "default": false + }, + "enableGenericWebhook": { + "type": "switch", + "label": "Enable generic webhook", + "omitWhenBlank": true, + "default": false + }, + "webhookUrl": { + "type": "textField", + "label": "Webhook URL", + "omitWhenBlank": true + }, + "webhookEvents": { + "type": "autoComplete", + "multiple": true, + "creatable": true, + "label": "Webhook Events (e.g. pageBlocked)", + "omitWhenBlank": true + }, + "urlAllowlist": { + "type": "autoComplete", + "multiple": true, + "creatable": true, + "label": "URL Allowlist (wildcards allowed)", + "omitWhenBlank": true + }, + "domainSquattingEnabled": { + "type": "switch", + "label": "Enable domain squatting detection", + "omitWhenBlank": true, + "default": true + }, + "companyName": { + "type": "textField", + "label": "Company Name", + "omitWhenBlank": true + }, + "productName": { + "type": "textField", + "label": "Product Name", + "omitWhenBlank": true + }, + "supportEmail": { + "type": "textField", + "label": "Support Email", + "omitWhenBlank": true + }, + "supportUrl": { + "type": "textField", + "label": "Support URL", + "omitWhenBlank": true + }, + "privacyPolicyUrl": { + "type": "textField", + "label": "Privacy Policy URL", + "omitWhenBlank": true + }, + "aboutUrl": { + "type": "textField", + "label": "About URL", + "omitWhenBlank": true + }, + "primaryColor": { + "type": "textField", + "label": "Primary Color (hex, default #F77F00)", + "omitWhenBlank": true + }, + "logoUrl": { + "type": "textField", + "label": "Logo URL", + "omitWhenBlank": true + }, + "AssignTo": { + "type": "autoComplete", + "creatable": false, + "label": "Who should this app be assigned to?", + "omitWhenBlank": true, + "options": [ + { + "value": "On", + "label": "Do not assign" + }, + { + "value": "allLicensedUsers", + "label": "Assign to all users" + }, + { + "value": "AllDevices", + "label": "Assign to all devices" + }, + { + "value": "AllDevicesAndUsers", + "label": "Assign to all users and devices" + }, + { + "value": "customGroup", + "label": "Assign to Custom Group" + } + ] + }, + "customGroup": { + "type": "textField", + "label": "Custom group name (if Custom Group selected, wildcards allowed)", + "omitWhenBlank": true + }, + "excludeGroup": { + "type": "textField", + "label": "Exclude group name(s), comma separated (wildcards allowed)", + "omitWhenBlank": true + } + }, + "read": { + "cacheType": "IntuneMobileApps" + }, + "prepare": "Get-CIPPBaselineDeployCheckChromeExtensionState", + "remediate": { + "executor": "DeployCheckChromeExtension", + "showNotifications": "%showNotifications%", + "enableValidPageBadge": "%enableValidPageBadge%", + "enablePageBlocking": "%enablePageBlocking%", + "forceToolbarPin": "%forceToolbarPin%", + "enableCippReporting": "%enableCippReporting%", + "customRulesUrl": "%customRulesUrl%", + "updateInterval": "%updateInterval%", + "enableDebugLogging": "%enableDebugLogging%", + "enableGenericWebhook": "%enableGenericWebhook%", + "webhookUrl": "%webhookUrl%", + "webhookEvents": "%webhookEvents%", + "urlAllowlist": "%urlAllowlist%", + "domainSquattingEnabled": "%domainSquattingEnabled%", + "companyName": "%companyName%", + "productName": "%productName%", + "supportEmail": "%supportEmail%", + "supportUrl": "%supportUrl%", + "privacyPolicyUrl": "%privacyPolicyUrl%", + "aboutUrl": "%aboutUrl%", + "primaryColor": "%primaryColor%", + "logoUrl": "%logoUrl%", + "assignTo": "%AssignTo%", + "customGroup": "%customGroup%", + "excludeGroup": "%excludeGroup%" + }, + "checkBeforeRun": false +} diff --git a/Config/BaselineStandards/Intune Standards/DevicePrepProfile.json b/Config/BaselineStandards/Intune Standards/DevicePrepProfile.json new file mode 100644 index 0000000000000..df7e5f6b0bccf --- /dev/null +++ b/Config/BaselineStandards/Intune Standards/DevicePrepProfile.json @@ -0,0 +1,158 @@ +{ + "name": "DevicePrepProfile", + "label": "Deploy Device Prep Profile", + "cat": "Intune Standards", + "tag": [], + "impact": "High Impact", + "helpText": "Creates and manages a Windows Autopilot Device Preparation profile for streamlined device enrollment, including an optional device security group.", + "executiveText": "Automates modern Windows device preparation with consistent join, account, and timeout settings, and keeps the profile correctly assigned.", + "docsDescription": "One instance per profile name. Grades the enrollment settings from the cached policy plus - separately - the assignment through Compare-CIPPIntuneAssignments, so a half-deployed profile is visible. Remediation repairs a wrong assignment IN PLACE and only recreates the profile when settings drift; the device security group resolves by name and is created with the Intune Provisioning Client as owner when allowed.", + "impactColour": "error", + "addedDate": "2026-08-16", + "powershellEquivalent": "Graph API - deviceManagement/configurationPolicies", + "recommendedBy": [], + "requiredCapabilities": [ + "INTUNE_A", + "MDM_Services", + "EMS", + "SCCM", + "MICROSOFTINTUNEPLAN1" + ], + "disabledFeatures": { + "report": false, + "warn": false, + "remediate": false + }, + "secureScoreImpact": 0, + "compare": "subset", + "variables": { + "ProfileName": { + "type": "textField", + "label": "Profile Display Name", + "required": true + }, + "ProfileDescription": { + "type": "textField", + "label": "Profile Description", + "omitWhenBlank": true + }, + "DeploymentType": { + "type": "autoComplete", + "creatable": false, + "label": "Deployment Type", + "omitWhenBlank": true, + "options": [ + { + "value": "0", + "label": "Single user" + }, + { + "value": "1", + "label": "Shared" + } + ] + }, + "JoinType": { + "type": "autoComplete", + "creatable": false, + "label": "Join Type", + "omitWhenBlank": true, + "options": [ + { + "value": "0", + "label": "Microsoft Entra join" + }, + { + "value": "1", + "label": "Microsoft Entra hybrid join" + } + ] + }, + "AccountType": { + "type": "autoComplete", + "creatable": false, + "label": "Account Type", + "omitWhenBlank": true, + "options": [ + { + "value": "0", + "label": "Standard user" + }, + { + "value": "1", + "label": "Administrator" + } + ] + }, + "Timeout": { + "type": "number", + "label": "Timeout (minutes, default 60)", + "omitWhenBlank": true + }, + "CustomErrorMessage": { + "type": "textField", + "label": "Custom Error Message", + "omitWhenBlank": true + }, + "AllowSkip": { + "type": "switch", + "label": "Allow users to skip setup after failure", + "omitWhenBlank": true, + "default": false + }, + "AllowDiagnostics": { + "type": "switch", + "label": "Allow users to collect diagnostics", + "omitWhenBlank": true, + "default": false + }, + "DeviceGroupName": { + "type": "textField", + "label": "Device Security Group Name (wildcard match)", + "omitWhenBlank": true + }, + "CreateNewGroup": { + "type": "switch", + "label": "Create new group if group is not found", + "omitWhenBlank": true, + "default": false + }, + "AssignTo": { + "type": "autoComplete", + "creatable": false, + "label": "Policy Assignment", + "omitWhenBlank": true, + "options": [ + { + "value": "none", + "label": "Do not assign" + }, + { + "value": "AllDevicesAndUsers", + "label": "All users (profiles deploy to the enrolling user)" + } + ] + } + }, + "read": { + "cacheType": "IntuneConfigurationPolicies" + }, + "prepare": "Get-CIPPBaselineDevicePrepProfileState", + "remediate": { + "executor": "DevicePrepProfile", + "profileName": "%ProfileName%", + "profileDescription": "%ProfileDescription%", + "deploymentType": "%DeploymentType%", + "joinType": "%JoinType%", + "accountType": "%AccountType%", + "timeout": "%Timeout%", + "customErrorMessage": "%CustomErrorMessage%", + "allowSkip": "%AllowSkip%", + "allowDiagnostics": "%AllowDiagnostics%", + "deviceGroupName": "%DeviceGroupName%", + "createNewGroup": "%CreateNewGroup%", + "assignTo": "%AssignTo%" + }, + "multiple": true, + "instanceIdentity": "ProfileName" +} diff --git a/Config/BaselineStandards/Intune Standards/EnrollmentWindowsHelloForBusinessConfiguration.json b/Config/BaselineStandards/Intune Standards/EnrollmentWindowsHelloForBusinessConfiguration.json new file mode 100644 index 0000000000000..711008ff7d15e --- /dev/null +++ b/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/Config/BaselineStandards/Intune Standards/IntuneAppTemplateDeploy.json b/Config/BaselineStandards/Intune Standards/IntuneAppTemplateDeploy.json new file mode 100644 index 0000000000000..69585a845b6fa --- /dev/null +++ b/Config/BaselineStandards/Intune Standards/IntuneAppTemplateDeploy.json @@ -0,0 +1,51 @@ +{ + "name": "IntuneAppTemplateDeploy", + "label": "Deploy Intune Application Template", + "cat": "Intune Standards", + "tag": [], + "impact": "Medium Impact", + "helpText": "Deploys selected Intune application templates to the tenant. Supports WinGet/Store apps, Office apps, Chocolatey apps, Win32 script apps, and MSP apps.", + "executiveText": "Automatically deploys approved Intune applications across all managed tenants, ensuring consistent software availability.", + "docsDescription": "Resolves each configured App Template to its per-app list and grades an empty missing-apps set against the IntuneMobileApps cache; Office is tracked by @odata.type because Graph renames it. Remediation QUEUES each missing app through New-CIPPIntuneAppDeployment - deployment is asynchronous, so the first compare afterwards may briefly still report the apps missing until the uploads land.", + "impactColour": "warning", + "addedDate": "2026-08-16", + "powershellEquivalent": "Graph API - /deviceAppManagement/mobileApps", + "recommendedBy": [], + "requiredCapabilities": [ + "INTUNE_A", + "MDM_Services", + "EMS", + "SCCM", + "MICROSOFTINTUNEPLAN1" + ], + "disabledFeatures": { + "report": false, + "warn": false, + "remediate": false + }, + "secureScoreImpact": 0, + "compare": "subset", + "variables": { + "templateIds": { + "type": "autoComplete", + "multiple": true, + "creatable": false, + "label": "Select Application Templates", + "required": true, + "api": { + "url": "/api/ListAppTemplates", + "labelField": "Displayname", + "valueField": "GUID", + "queryKey": "StdIntuneAppTemplateList" + } + } + }, + "read": { + "cacheType": "IntuneMobileApps" + }, + "prepare": "Get-CIPPBaselineIntuneAppTemplateDeployState", + "remediate": { + "executor": "IntuneAppTemplateDeploy", + "templateIds": "%templateIds%" + } +} diff --git a/Config/BaselineStandards/Intune Standards/IntuneComplianceSettings.json b/Config/BaselineStandards/Intune Standards/IntuneComplianceSettings.json new file mode 100644 index 0000000000000..231b53452fe6a --- /dev/null +++ b/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/Config/BaselineStandards/Intune Standards/IntuneWindowsDiagnostic.json b/Config/BaselineStandards/Intune Standards/IntuneWindowsDiagnostic.json new file mode 100644 index 0000000000000..f73395f9b87e3 --- /dev/null +++ b/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/Config/BaselineStandards/Intune Standards/MDMEnrollmentDuringRegistration.json b/Config/BaselineStandards/Intune Standards/MDMEnrollmentDuringRegistration.json new file mode 100644 index 0000000000000..e0efcddefbc7f --- /dev/null +++ b/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/Config/BaselineStandards/Intune Standards/MDMScope.json b/Config/BaselineStandards/Intune Standards/MDMScope.json new file mode 100644 index 0000000000000..591c152d8ddc7 --- /dev/null +++ b/Config/BaselineStandards/Intune Standards/MDMScope.json @@ -0,0 +1,64 @@ +{ + "name": "MDMScope", + "label": "Configure MDM user scope", + "cat": "Intune Standards", + "tag": [], + "impact": "Low Impact", + "helpText": "Sets the Intune MDM enrollment URLs to Microsoft defaults and the automatic enrollment user scope: everyone, no one, or a custom group.", + "executiveText": "Controls which users devices auto-enroll into Intune management when they join Entra, keeping device management coverage deliberate.", + "docsDescription": "Grades the three fixed Intune enrollment URLs, the appliesTo scope, and - for the custom-group scope - that the configured group is among the included groups. Remediation patches the URLs and scope in the separate requests Graph requires, and assigns the custom group by reference.", + "impactColour": "info", + "addedDate": "2026-08-16", + "powershellEquivalent": "Graph: PATCH policies/mobileDeviceManagementPolicies", + "recommendedBy": [], + "requiredCapabilities": [ + "INTUNE_A", + "MDM_Services", + "EMS", + "SCCM", + "MICROSOFTINTUNEPLAN1" + ], + "disabledFeatures": { + "report": false, + "warn": false, + "remediate": false + }, + "secureScoreImpact": 0, + "compare": "subset", + "variables": { + "appliesTo": { + "label": "MDM User Scope", + "required": true, + "creatable": false, + "type": "autoComplete", + "options": [ + { + "value": "all", + "label": "All" + }, + { + "value": "none", + "label": "None" + }, + { + "value": "selected", + "label": "Custom Group" + } + ] + }, + "customGroup": { + "label": "Custom Group Name (custom scope only)", + "omitWhenBlank": true, + "type": "textField" + } + }, + "read": { + "cacheType": "MobileDeviceManagementPolicies" + }, + "prepare": "Get-CIPPBaselineMDMScopeState", + "remediate": { + "executor": "MDMScope", + "appliesTo": "%appliesTo%", + "customGroup": "%customGroup%" + } +} diff --git a/Config/BaselineStandards/Intune Standards/WindowsBackupRestore.json b/Config/BaselineStandards/Intune Standards/WindowsBackupRestore.json new file mode 100644 index 0000000000000..722b1946ccaef --- /dev/null +++ b/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/Config/BaselineStandards/Intune Standards/intuneBrandingProfile.json b/Config/BaselineStandards/Intune Standards/intuneBrandingProfile.json new file mode 100644 index 0000000000000..ebfe909156d3a --- /dev/null +++ b/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/Config/BaselineStandards/Intune Standards/intuneDeviceReg.json b/Config/BaselineStandards/Intune Standards/intuneDeviceReg.json new file mode 100644 index 0000000000000..63936bd5cd110 --- /dev/null +++ b/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/Config/BaselineStandards/Intune Standards/intuneDeviceRetirementDays.json b/Config/BaselineStandards/Intune Standards/intuneDeviceRetirementDays.json new file mode 100644 index 0000000000000..07b08eb0c81b2 --- /dev/null +++ b/Config/BaselineStandards/Intune Standards/intuneDeviceRetirementDays.json @@ -0,0 +1,43 @@ +{ + "name": "intuneDeviceRetirementDays", + "label": "Enable Device Retirement in Intune", + "cat": "Intune Standards", + "tag": [], + "impact": "Low Impact", + "helpText": "Sets the number of days of inactivity before a device is automatically retired from Intune by the default cleanup rule.", + "executiveText": "Automatically removes long-inactive devices from management, keeping device inventory accurate and license consumption honest without manual cleanup.", + "docsDescription": "Grades the deviceInactivityBeforeRetirementInDays value on the default (all-platforms) managed device cleanup rule. A tenant with no cleanup rule grades as drift and remediation creates the rule; otherwise the existing rule is patched in place.", + "impactColour": "info", + "addedDate": "2026-08-16", + "powershellEquivalent": "Graph: POST or PATCH deviceManagement/managedDeviceCleanupRules", + "recommendedBy": [], + "requiredCapabilities": [ + "INTUNE_A", + "MDM_Services", + "EMS", + "SCCM", + "MICROSOFTINTUNEPLAN1" + ], + "disabledFeatures": { + "report": false, + "warn": false, + "remediate": false + }, + "secureScoreImpact": 0, + "compare": "subset", + "variables": { + "days": { + "type": "number", + "label": "Maximum days of inactivity before retirement", + "required": true + } + }, + "read": { + "cacheType": "ManagedDeviceCleanupRules" + }, + "prepare": "Get-CIPPBaselineIntuneDeviceRetirementDaysState", + "remediate": { + "executor": "IntuneDeviceRetirementDays", + "days": "%days%" + } +} diff --git a/Config/BaselineStandards/Intune Standards/intuneRequireMFA.json b/Config/BaselineStandards/Intune Standards/intuneRequireMFA.json new file mode 100644 index 0000000000000..d507be5cc7fcd --- /dev/null +++ b/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/Config/BaselineStandards/Manual Tasks/ManualTask.json b/Config/BaselineStandards/Manual Tasks/ManualTask.json index d377f0a4293c5..711a3f50b4b0a 100644 --- a/Config/BaselineStandards/Manual Tasks/ManualTask.json +++ b/Config/BaselineStandards/Manual Tasks/ManualTask.json @@ -19,6 +19,7 @@ "taskName": { "type": "textField", "label": "Task name", + "required": true, "default": "" }, "instructions": { diff --git a/Config/BaselineStandards/SharePoint Standards/DisableReshare.json b/Config/BaselineStandards/SharePoint Standards/DisableReshare.json index e0274a270fc69..ddc09abae7d10 100644 --- a/Config/BaselineStandards/SharePoint Standards/DisableReshare.json +++ b/Config/BaselineStandards/SharePoint Standards/DisableReshare.json @@ -28,6 +28,7 @@ "SHAREPOINTSTANDARD", "SHAREPOINTENTERPRISE", "SHAREPOINTENTERPRISE_EDU", + "SHAREPOINTENTERPRISE_GOV", "ONEDRIVE_BASIC", "ONEDRIVE_ENTERPRISE" ], diff --git a/Config/BaselineStandards/SharePoint Standards/DisableSharePointLegacyAuth.json b/Config/BaselineStandards/SharePoint Standards/DisableSharePointLegacyAuth.json index 0804aa17d4614..114b73b0b805b 100644 --- a/Config/BaselineStandards/SharePoint Standards/DisableSharePointLegacyAuth.json +++ b/Config/BaselineStandards/SharePoint Standards/DisableSharePointLegacyAuth.json @@ -29,6 +29,7 @@ "SHAREPOINTSTANDARD", "SHAREPOINTENTERPRISE", "SHAREPOINTENTERPRISE_EDU", + "SHAREPOINTENTERPRISE_GOV", "ONEDRIVE_BASIC", "ONEDRIVE_ENTERPRISE" ], diff --git a/Config/BaselineStandards/SharePoint Standards/DisableUserSiteCreate.json b/Config/BaselineStandards/SharePoint Standards/DisableUserSiteCreate.json new file mode 100644 index 0000000000000..ee47b1a7033a6 --- /dev/null +++ b/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/Config/BaselineStandards/SharePoint Standards/ExcludedfileExt.json b/Config/BaselineStandards/SharePoint Standards/ExcludedfileExt.json new file mode 100644 index 0000000000000..470e170bca67c --- /dev/null +++ b/Config/BaselineStandards/SharePoint Standards/ExcludedfileExt.json @@ -0,0 +1,44 @@ +{ + "name": "ExcludedfileExt", + "label": "Exclude File Extensions from Syncing", + "cat": "SharePoint Standards", + "tag": [], + "impact": "High Impact", + "helpText": "Sets the file extensions the OneDrive sync client refuses to sync. The list replaces the tenant list, matching admin center behaviour.", + "executiveText": "Keeps dangerous or unwanted file types (executables, scripts) out of OneDrive sync, reducing malware spread through synced libraries.", + "docsDescription": "Grades the excluded-extension set exactly - extensions removed from the baseline come off the tenant on remediation, matching the admin center's replace semantics. Bare extensions gain the *. prefix the admin center adds.", + "impactColour": "error", + "addedDate": "2026-08-16", + "powershellEquivalent": "Graph: PATCH admin/sharepoint/settings", + "recommendedBy": [], + "requiredCapabilities": [ + "SHAREPOINTWAC", + "SHAREPOINTSTANDARD", + "SHAREPOINTENTERPRISE", + "SHAREPOINTENTERPRISE_EDU", + "ONEDRIVE_BASIC", + "ONEDRIVE_ENTERPRISE" + ], + "disabledFeatures": { + "report": false, + "warn": false, + "remediate": false + }, + "secureScoreImpact": 0, + "compare": "subset", + "variables": { + "ext": { + "type": "textField", + "label": "Extensions, comma separated (e.g. exe, bat, js)", + "required": true + } + }, + "read": { + "cacheType": "SharePointAdminSettings" + }, + "prepare": "Get-CIPPBaselineExcludedfileExtState", + "remediate": { + "executor": "ExcludedfileExt", + "ext": "%ext%" + } +} diff --git a/Config/BaselineStandards/SharePoint Standards/SPDirectSharing.json b/Config/BaselineStandards/SharePoint Standards/SPDirectSharing.json new file mode 100644 index 0000000000000..9af5d99d21722 --- /dev/null +++ b/Config/BaselineStandards/SharePoint Standards/SPDirectSharing.json @@ -0,0 +1,37 @@ +{ + "name": "SPDirectSharing", + "label": "Default to Direct Sharing Links", + "cat": "SharePoint Standards", + "tag": [], + "impact": "Medium Impact", + "helpText": "Sets the default SharePoint sharing link type to Direct (specific people).", + "executiveText": "Makes sharing default to named recipients instead of anyone-with-the-link, so files are not one forward away from public.", + "docsDescription": "Grades whether the tenant default sharing link type is Direct. Remediation reads the SPO tenant live for a fresh CSOM identity and sets the link type through the shared SPO helper.", + "impactColour": "warning", + "addedDate": "2026-08-16", + "powershellEquivalent": "Set-SPOTenant -DefaultSharingLinkType Direct", + "recommendedBy": [], + "requiredCapabilities": [ + "SHAREPOINTWAC", + "SHAREPOINTSTANDARD", + "SHAREPOINTENTERPRISE", + "SHAREPOINTENTERPRISE_EDU", + "ONEDRIVE_BASIC", + "ONEDRIVE_ENTERPRISE" + ], + "disabledFeatures": { + "report": false, + "warn": false, + "remediate": false + }, + "secureScoreImpact": 0, + "compare": "subset", + "variables": {}, + "read": { + "cacheType": "SPOTenant" + }, + "prepare": "Get-CIPPBaselineSPDirectSharingState", + "remediate": { + "executor": "SPDirectSharing" + } +} diff --git a/Config/BaselineStandards/SharePoint Standards/SPFileRequests.json b/Config/BaselineStandards/SharePoint Standards/SPFileRequests.json new file mode 100644 index 0000000000000..bb42f444ffcee --- /dev/null +++ b/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/Config/BaselineStandards/SharePoint Standards/SPOVersionControl.json b/Config/BaselineStandards/SharePoint Standards/SPOVersionControl.json new file mode 100644 index 0000000000000..69daff377ec48 --- /dev/null +++ b/Config/BaselineStandards/SharePoint Standards/SPOVersionControl.json @@ -0,0 +1,62 @@ +{ + "name": "SPOVersionControl", + "label": "SharePoint Version Control Policy", + "cat": "SharePoint Standards", + "tag": [], + "impact": "Medium Impact", + "helpText": "Sets the tenant file version policy: intelligent auto-trim, or explicit version limits and expiry. Optionally pushes the policy to all existing sites.", + "executiveText": "Controls how many file versions SharePoint keeps, containing storage growth while preserving the recovery history the organization actually needs.", + "docsDescription": "Auto-trim on grades the trim flag alone; off grades the flag plus the major version limit and expiry days. Expiry accepts only 0 or 30-36500 days - values in the gap report No Data rather than a value the tenant would refuse. Remediation writes through the SPO SetFileVersionPolicy method and can fan the policy out to every existing site.", + "impactColour": "warning", + "addedDate": "2026-08-16", + "powershellEquivalent": "Set-SPOTenant file version policy", + "recommendedBy": [], + "requiredCapabilities": [ + "SHAREPOINTWAC", + "SHAREPOINTSTANDARD", + "SHAREPOINTENTERPRISE", + "SHAREPOINTENTERPRISE_EDU", + "ONEDRIVE_BASIC", + "ONEDRIVE_ENTERPRISE" + ], + "disabledFeatures": { + "report": false, + "warn": false, + "remediate": false + }, + "secureScoreImpact": 0, + "compare": "subset", + "variables": { + "EnableAutoTrim": { + "type": "switch", + "label": "Use intelligent auto-trim", + "default": true + }, + "MajorVersionLimit": { + "type": "number", + "label": "Major version limit (manual mode)", + "omitWhenBlank": true + }, + "ExpireVersionsAfterDays": { + "type": "number", + "label": "Expire versions after days (0 = never, else 30-36500)", + "omitWhenBlank": true + }, + "ApplyToExistingSites": { + "type": "switch", + "label": "Apply to all existing sites and libraries", + "default": false + } + }, + "read": { + "cacheType": "SPOTenant" + }, + "prepare": "Get-CIPPBaselineSPOVersionControlState", + "remediate": { + "executor": "SPOVersionControl", + "enableAutoTrim": "%EnableAutoTrim%", + "majorVersionLimit": "%MajorVersionLimit%", + "expireVersionsAfterDays": "%ExpireVersionsAfterDays%", + "applyToExistingSites": "%ApplyToExistingSites%" + } +} diff --git a/Config/BaselineStandards/SharePoint Standards/TenantDefaultTimezone.json b/Config/BaselineStandards/SharePoint Standards/TenantDefaultTimezone.json new file mode 100644 index 0000000000000..9a5d1353f5090 --- /dev/null +++ b/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%" + } + } + ] + } +} diff --git a/Config/BaselineStandards/SharePoint Standards/sharingCapability.json b/Config/BaselineStandards/SharePoint Standards/sharingCapability.json index e3de25762c410..458fbf2a640f0 100644 --- a/Config/BaselineStandards/SharePoint Standards/sharingCapability.json +++ b/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/Config/BaselineStandards/SharePoint Standards/sharingDomainRestriction.json b/Config/BaselineStandards/SharePoint Standards/sharingDomainRestriction.json new file mode 100644 index 0000000000000..9d7efcce2d203 --- /dev/null +++ b/Config/BaselineStandards/SharePoint Standards/sharingDomainRestriction.json @@ -0,0 +1,65 @@ +{ + "name": "sharingDomainRestriction", + "label": "Restrict sharing to a specific domain list", + "cat": "SharePoint Standards", + "tag": [], + "impact": "High Impact", + "helpText": "Restricts SharePoint external sharing to an allow-list or block-list of domains, or removes the restriction.", + "executiveText": "Controls which external organizations files can be shared with, containing data exposure to sanctioned partners.", + "docsDescription": "Grades the sharing domain restriction mode and - for the list modes - the sorted domain list on the matching side. Remediation writes the mode and list in one call.", + "impactColour": "error", + "addedDate": "2026-08-16", + "powershellEquivalent": "Graph: PATCH admin/sharepoint/settings", + "recommendedBy": [], + "requiredCapabilities": [ + "SHAREPOINTWAC", + "SHAREPOINTSTANDARD", + "SHAREPOINTENTERPRISE", + "SHAREPOINTENTERPRISE_EDU", + "ONEDRIVE_BASIC", + "ONEDRIVE_ENTERPRISE" + ], + "disabledFeatures": { + "report": false, + "warn": false, + "remediate": false + }, + "secureScoreImpact": 0, + "compare": "subset", + "variables": { + "Mode": { + "label": "Restriction Mode", + "type": "autoComplete", + "creatable": false, + "required": true, + "options": [ + { + "value": "none", + "label": "Off (no restriction)" + }, + { + "value": "allowList", + "label": "Allow specific domains" + }, + { + "value": "blockList", + "label": "Block specific domains" + } + ] + }, + "Domains": { + "type": "textField", + "label": "Domains, comma separated (list modes only)", + "omitWhenBlank": true + } + }, + "read": { + "cacheType": "SharePointAdminSettings" + }, + "prepare": "Get-CIPPBaselinesharingDomainRestrictionState", + "remediate": { + "executor": "sharingDomainRestriction", + "mode": "%Mode%", + "domains": "%Domains%" + } +} diff --git a/Config/BaselineStandards/Teams Standards/TeamsDisableResourceAccounts.json b/Config/BaselineStandards/Teams Standards/TeamsDisableResourceAccounts.json new file mode 100644 index 0000000000000..aee7dd06e72ed --- /dev/null +++ b/Config/BaselineStandards/Teams Standards/TeamsDisableResourceAccounts.json @@ -0,0 +1,51 @@ +{ + "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": [ + "MCOSTANDARD", + "MCOEV", + "MCOIMP", + "TEAMS1", + "Teams_Room_Standard" + ], + "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/Config/BaselineStandards/Teams Standards/TeamsExternalAccessPolicy.json b/Config/BaselineStandards/Teams Standards/TeamsExternalAccessPolicy.json index 6ef835626a316..9c3c92fab18f2 100644 --- a/Config/BaselineStandards/Teams Standards/TeamsExternalAccessPolicy.json +++ b/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/Config/BaselineStandards/Teams Standards/TeamsFederationConfiguration.json b/Config/BaselineStandards/Teams Standards/TeamsFederationConfiguration.json new file mode 100644 index 0000000000000..0a506df40245b --- /dev/null +++ b/Config/BaselineStandards/Teams Standards/TeamsFederationConfiguration.json @@ -0,0 +1,76 @@ +{ + "name": "TeamsFederationConfiguration", + "label": "Federation Configuration for Microsoft Teams", + "cat": "Teams Standards", + "tag": [], + "impact": "Medium Impact", + "helpText": "Controls external Teams communication: allow or block all external domains, allow or block a specific list, and whether unmanaged consumer Teams users can communicate with the organization.", + "executiveText": "Governs who employees can reach over Teams outside the organization. Restricting federation to trusted partners reduces phishing and impersonation exposure through external chat.", + "docsDescription": "Grades the tenant federation configuration - federation on/off, consumer access, and the allow/block domain lists - against the selected communication mode. An allow-all posture reads as AllowAllKnownDomains. Remediation writes the full federation posture through the Teams ConfigAPI in one call.", + "impactColour": "warning", + "addedDate": "2026-08-16", + "powershellEquivalent": "Set-CsTenantFederationConfiguration", + "recommendedBy": [], + "requiredCapabilities": [ + "MCOSTANDARD", + "MCOEV", + "MCOIMP", + "TEAMS1", + "Teams_Room_Standard" + ], + "disabledFeatures": { + "report": false, + "warn": false, + "remediate": false + }, + "secureScoreImpact": 0, + "compare": "subset", + "variables": { + "DomainControl": { + "type": "autoComplete", + "creatable": false, + "label": "Communication Mode", + "required": true, + "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" + } + ] + }, + "DomainList": { + "type": "textField", + "label": "Domains, comma separated (Specific modes only)", + "omitWhenBlank": true + }, + "AllowTeamsConsumer": { + "type": "switch", + "label": "Allow communication with unmanaged Teams users", + "default": false + }, + "AllowTeamsConsumerInbound": { + "type": "switch", + "label": "Allow unmanaged Teams users to initiate contact", + "default": false + } + }, + "read": { + "cacheType": "CsTenantFederationConfiguration" + }, + "prepare": "Get-CIPPBaselineTeamsFederationConfigurationState", + "remediate": { + "executor": "TeamsFederationConfiguration" + } +} diff --git a/Config/BaselineStandards/Teams Standards/TeamsGlobalMeetingPolicy.json b/Config/BaselineStandards/Teams Standards/TeamsGlobalMeetingPolicy.json index cf3dc0786226b..e1eab2fc4b76d 100644 --- a/Config/BaselineStandards/Teams Standards/TeamsGlobalMeetingPolicy.json +++ b/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/Config/BaselineStandards/Templates/AssignmentFilterTemplate.json b/Config/BaselineStandards/Templates/AssignmentFilterTemplate.json new file mode 100644 index 0000000000000..74b7c769f5b9d --- /dev/null +++ b/Config/BaselineStandards/Templates/AssignmentFilterTemplate.json @@ -0,0 +1,56 @@ +{ + "name": "AssignmentFilterTemplate", + "label": "Assignment Filter Template", + "cat": "Templates", + "tag": [], + "impact": "Low Impact", + "helpText": "Deploy Intune assignment filters from CIPP templates. An existing filter with the same name is updated field-by-field.", + "executiveText": "Deploys standardized Intune assignment filters that control which devices and users policies apply to. Consistent filters across tenants keep policy targeting predictable and auditable.", + "docsDescription": "Deploy an Intune assignment filter from a CIPP template. The standard grades presence by display name plus the filter's writable fields - description, platform, rule and management type - and remediation creates the filter or patches only the fields that drifted.", + "impactColour": "info", + "addedDate": "2026-08-16", + "powershellEquivalent": "Graph: POST or PATCH deviceManagement/assignmentFilters", + "recommendedBy": [], + "requiredCapabilities": [ + "INTUNE_A", + "MDM_Services", + "EMS", + "SCCM", + "MICROSOFTINTUNEPLAN1" + ], + "disabledFeatures": { + "report": false, + "warn": false, + "remediate": false + }, + "secureScoreImpact": 0, + "compare": "subset", + "multiple": true, + "instanceIdentity": "assignmentFilterTemplate", + "identity": { + "partition": "AssignmentFilterTemplate", + "nameField": "displayName" + }, + "variables": { + "assignmentFilterTemplate": { + "type": "autoComplete", + "creatable": false, + "label": "Select Assignment Filter Template", + "required": true, + "api": { + "url": "/api/ListAssignmentFilterTemplates", + "labelField": "Displayname", + "altLabelField": "displayName", + "valueField": "GUID", + "queryKey": "ListAssignmentFilterTemplates" + } + } + }, + "read": { + "cacheType": "IntuneAssignmentFilters" + }, + "prepare": "Get-CIPPBaselineAssignmentFilterTemplateState", + "remediate": { + "executor": "AssignmentFilterTemplate" + } +} diff --git a/Config/BaselineStandards/Templates/ConditionalAccessTemplate.json b/Config/BaselineStandards/Templates/ConditionalAccessTemplate.json index 04ef9a253af21..b6359a7fb20c4 100644 --- a/Config/BaselineStandards/Templates/ConditionalAccessTemplate.json +++ b/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/Config/BaselineStandards/Templates/ConditionalAccessTemplatePackage.json b/Config/BaselineStandards/Templates/ConditionalAccessTemplatePackage.json index f899060e31699..e8db824d046ee 100644 --- a/Config/BaselineStandards/Templates/ConditionalAccessTemplatePackage.json +++ b/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/Config/BaselineStandards/Templates/DeployContactTemplates.json b/Config/BaselineStandards/Templates/DeployContactTemplates.json new file mode 100644 index 0000000000000..b56639041d915 --- /dev/null +++ b/Config/BaselineStandards/Templates/DeployContactTemplates.json @@ -0,0 +1,55 @@ +{ + "name": "DeployContactTemplates", + "label": "Mail Contact Template", + "cat": "Templates", + "tag": [], + "impact": "Low Impact", + "helpText": "Deploy mail contacts from CIPP templates. An existing contact with the same display name is brought in line with the template.", + "executiveText": "Deploys standardized external mail contacts - support desks, billing addresses, partner contacts - so the same directory entries exist in every tenant.", + "docsDescription": "Deploy a mail contact from a CIPP template. The standard grades whether the contact exists by display name and diffs the template's specified fields - email, names, address and phone details - against the tenant; fields the template leaves empty are never graded or written. Remediation creates the contact or updates only what the template specifies.", + "impactColour": "info", + "addedDate": "2026-08-16", + "powershellEquivalent": "New-MailContact, Set-MailContact or Set-Contact", + "recommendedBy": [], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ], + "disabledFeatures": { + "report": false, + "warn": false, + "remediate": false + }, + "secureScoreImpact": 0, + "compare": "subset", + "multiple": true, + "instanceIdentity": "contactTemplate", + "identity": { + "partition": "ContactTemplate", + "nameField": "displayName" + }, + "variables": { + "contactTemplate": { + "type": "autoComplete", + "creatable": false, + "label": "Select Mail Contact Template", + "required": true, + "api": { + "url": "/api/ListContactTemplates", + "labelField": "name", + "valueField": "GUID", + "queryKey": "ListContactTemplates" + } + } + }, + "read": { + "cacheType": "ExoMailContacts" + }, + "prepare": "Get-CIPPBaselineDeployContactTemplatesState", + "remediate": { + "executor": "DeployContactTemplates" + } +} diff --git a/Config/BaselineStandards/Templates/DlpCompliancePolicyTemplate.json b/Config/BaselineStandards/Templates/DlpCompliancePolicyTemplate.json new file mode 100644 index 0000000000000..42f938e35f914 --- /dev/null +++ b/Config/BaselineStandards/Templates/DlpCompliancePolicyTemplate.json @@ -0,0 +1,47 @@ +{ + "name": "DlpCompliancePolicyTemplate", + "label": "DLP Compliance Policy Template", + "cat": "Templates", + "tag": [], + "impact": "Medium Impact", + "helpText": "Deploy Microsoft Purview DLP compliance policies from CIPP templates. Existing policies are overwritten in place.", + "executiveText": "Deploys Data Loss Prevention policies from a standardized template library, ensuring consistent DLP coverage across tenants for sensitive financial, identity, and regulated content.", + "docsDescription": "One instance per selected template. Each template is diffed field by field against the live policy and its rules through the same normalization the deploy path uses, so a policy deployed from the template grades InSync. Missing and drifted policies are remediated in place by name; a PendingDeletion policy cannot be modified and is surfaced as non-compliant without a doomed deploy attempt.", + "impactColour": "warning", + "addedDate": "2026-08-16", + "powershellEquivalent": "New-DlpCompliancePolicy / Set-DlpCompliancePolicy / New-DlpComplianceRule / Set-DlpComplianceRule", + "recommendedBy": [], + "requiredCapabilities": [], + "disabledFeatures": { + "report": false, + "warn": false, + "remediate": false + }, + "secureScoreImpact": 0, + "compare": "subset", + "multiple": true, + "instanceIdentity": "dlpCompliancePolicyTemplate", + "identity": { + "partition": "DlpCompliancePolicyTemplate", + "nameField": "name" + }, + "variables": { + "dlpCompliancePolicyTemplate": { + "type": "autoComplete", + "creatable": false, + "label": "Select DLP Compliance Policy Template", + "required": true, + "api": { + "url": "/api/ListDlpCompliancePolicyTemplates", + "labelField": "name", + "valueField": "GUID", + "queryKey": "ListDlpCompliancePolicyTemplates" + } + } + }, + "read": {}, + "prepare": "Get-CIPPBaselineDlpCompliancePolicyTemplateState", + "remediate": { + "executor": "DlpCompliancePolicyTemplate" + } +} diff --git a/Config/BaselineStandards/Templates/ExchangeConnectorTemplate.json b/Config/BaselineStandards/Templates/ExchangeConnectorTemplate.json new file mode 100644 index 0000000000000..eabc76ba9d48c --- /dev/null +++ b/Config/BaselineStandards/Templates/ExchangeConnectorTemplate.json @@ -0,0 +1,60 @@ +{ + "name": "ExchangeConnectorTemplate", + "label": "Exchange Connector Template", + "cat": "Templates", + "tag": [], + "impact": "Medium Impact", + "helpText": "Deploy Exchange inbound or outbound connectors from CIPP templates. An existing connector with the same name is rewritten from the template on every remediation run.", + "executiveText": "Deploys standardized Exchange mail flow connectors that control how email routes between the organization and partners or filtering services. Consistent connectors keep mail routing and security filtering uniform across tenants.", + "docsDescription": "Deploy an Exchange connector from a CIPP template. The template's direction decides the connector type. The standard grades whether the connector exists by name - it does not diff connector settings - and rewrites the full template on each remediation run, matching the classic standard.", + "impactColour": "warning", + "addedDate": "2026-08-16", + "powershellEquivalent": "New-InboundConnector, Set-InboundConnector, New-OutboundConnector or Set-OutboundConnector", + "recommendedBy": [], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ], + "disabledFeatures": { + "report": false, + "warn": false, + "remediate": false + }, + "secureScoreImpact": 0, + "compare": "subset", + "checkBeforeRun": false, + "multiple": true, + "instanceIdentity": "exConnectorTemplate", + "identity": { + "partition": "ExConnectorTemplate", + "nameField": "name" + }, + "variables": { + "exConnectorTemplate": { + "type": "autoComplete", + "creatable": false, + "label": "Select Exchange Connector Template", + "required": true, + "api": { + "url": "/api/ListExConnectorTemplates", + "labelField": "name", + "valueField": "GUID", + "queryKey": "ListExConnectorTemplates" + } + } + }, + "read": { + "cacheType": "ExoInboundConnector", + "requiredCaches": [ + "ExoInboundConnector", + "ExoOutboundConnector" + ] + }, + "prepare": "Get-CIPPBaselineExchangeConnectorTemplateState", + "remediate": { + "executor": "ExchangeConnectorTemplate" + } +} diff --git a/Config/BaselineStandards/Templates/GroupTemplate.json b/Config/BaselineStandards/Templates/GroupTemplate.json new file mode 100644 index 0000000000000..2371b6a6c3fe9 --- /dev/null +++ b/Config/BaselineStandards/Templates/GroupTemplate.json @@ -0,0 +1,55 @@ +{ + "name": "GroupTemplate", + "label": "Group Template", + "cat": "Templates", + "tag": [], + "impact": "Medium Impact", + "helpText": "Deploy groups from CIPP templates. An existing group with the same name is updated in place on every remediation run.", + "executiveText": "Deploys standardized groups - security, Microsoft 365, dynamic and distribution - so access control and communication structures stay consistent across tenants.", + "docsDescription": "Deploy a group from a CIPP template. The standard grades whether the group exists by display name - dynamic distribution groups are checked in Exchange, everything else in Entra - and remediation creates the group or updates its description, membership rule and external-sender settings in place, matching the classic standard.", + "impactColour": "warning", + "addedDate": "2026-08-16", + "powershellEquivalent": "New-CIPPGroup, Set-DistributionGroup or Set-DynamicDistributionGroup", + "recommendedBy": [], + "requiredCapabilities": [], + "disabledFeatures": { + "report": false, + "warn": false, + "remediate": false + }, + "secureScoreImpact": 0, + "compare": "subset", + "checkBeforeRun": false, + "multiple": true, + "instanceIdentity": "groupTemplate", + "identity": { + "partition": "GroupTemplate", + "nameField": "displayName" + }, + "variables": { + "groupTemplate": { + "type": "autoComplete", + "creatable": false, + "label": "Select Group Template", + "required": true, + "api": { + "url": "/api/ListGroupTemplates", + "labelField": "Displayname", + "altLabelField": "displayName", + "valueField": "GUID", + "queryKey": "ListGroupTemplates" + } + } + }, + "read": { + "cacheType": "Groups", + "requiredCaches": [ + "Groups", + "ExoDynamicDistributionGroup" + ] + }, + "prepare": "Get-CIPPBaselineGroupTemplateState", + "remediate": { + "executor": "GroupTemplate" + } +} diff --git a/Config/BaselineStandards/Templates/IntuneTemplate.json b/Config/BaselineStandards/Templates/IntuneTemplate.json index be9cecc53e1df..e0f74ccd33cb8 100644 --- a/Config/BaselineStandards/Templates/IntuneTemplate.json +++ b/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/Config/BaselineStandards/Templates/IntuneTemplatePackage.json b/Config/BaselineStandards/Templates/IntuneTemplatePackage.json index 2f82f31513ed9..8ef623ffff471 100644 --- a/Config/BaselineStandards/Templates/IntuneTemplatePackage.json +++ b/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/Config/BaselineStandards/Templates/QuarantineTemplate.json b/Config/BaselineStandards/Templates/QuarantineTemplate.json new file mode 100644 index 0000000000000..c1b0c4fb49e08 --- /dev/null +++ b/Config/BaselineStandards/Templates/QuarantineTemplate.json @@ -0,0 +1,97 @@ +{ + "name": "QuarantineTemplate", + "label": "Quarantine Policy", + "cat": "Templates", + "tag": [], + "impact": "Low Impact", + "helpText": "Deploy a custom quarantine policy. Each instance defines one policy by name with its end-user notification and permission settings.", + "executiveText": "Deploys standardized quarantine policies that control what users can do with quarantined messages - preview, release, request release - and whether they are notified. Consistent policies keep the quarantine experience uniform across tenants.", + "docsDescription": "Deploy a custom quarantine policy. Unlike the other template standards this one has no stored template - the instance's settings ARE the policy definition, keyed on its display name. The standard grades the policy's notification setting and each end-user permission individually and remediation creates or updates the policy to match.", + "impactColour": "info", + "addedDate": "2026-08-16", + "powershellEquivalent": "New-QuarantinePolicy or Set-QuarantinePolicy", + "recommendedBy": [], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ], + "disabledFeatures": { + "report": false, + "warn": false, + "remediate": false + }, + "secureScoreImpact": 0, + "compare": "subset", + "multiple": true, + "instanceIdentity": "displayName", + "variables": { + "displayName": { + "type": "autoComplete", + "creatable": true, + "label": "Quarantine Display Name", + "required": true + }, + "ESNEnabled": { + "type": "switch", + "label": "Enable end-user spam notifications", + "default": true + }, + "ReleaseAction": { + "type": "select", + "label": "Select release action preference", + "omitWhenBlank": true, + "options": [ + { + "label": "Allow recipients to request a message to be released from quarantine", + "value": "PermissionToRequestRelease" + }, + { + "label": "Allow recipients to release a message from quarantine", + "value": "PermissionToRelease" + } + ] + }, + "IncludeMessagesFromBlockedSenderAddress": { + "type": "switch", + "label": "Include Messages From Blocked Sender Address", + "default": false + }, + "PermissionToDelete": { + "type": "switch", + "label": "Allow recipients to delete message", + "default": false + }, + "PermissionToPreview": { + "type": "switch", + "label": "Allow recipients to preview message", + "default": false + }, + "PermissionToBlockSender": { + "type": "switch", + "label": "Allow recipients to block Sender Address", + "default": false + }, + "PermissionToAllowSender": { + "type": "switch", + "label": "Allow recipients to whitelist Sender Address", + "default": false + } + }, + "read": { + "cacheType": "ExoQuarantinePolicy" + }, + "prepare": "Get-CIPPBaselineQuarantineTemplateState", + "remediate": { + "executor": "QuarantineTemplate", + "esnEnabled": "%ESNEnabled%", + "releaseAction": "%ReleaseAction%", + "includeMessagesFromBlockedSenderAddress": "%IncludeMessagesFromBlockedSenderAddress%", + "permissionToDelete": "%PermissionToDelete%", + "permissionToPreview": "%PermissionToPreview%", + "permissionToBlockSender": "%PermissionToBlockSender%", + "permissionToAllowSender": "%PermissionToAllowSender%" + } +} diff --git a/Config/BaselineStandards/Templates/RetentionCompliancePolicyTemplate.json b/Config/BaselineStandards/Templates/RetentionCompliancePolicyTemplate.json new file mode 100644 index 0000000000000..956354ed78a5a --- /dev/null +++ b/Config/BaselineStandards/Templates/RetentionCompliancePolicyTemplate.json @@ -0,0 +1,50 @@ +{ + "name": "RetentionCompliancePolicyTemplate", + "label": "Retention Compliance Policy Template", + "cat": "Templates", + "tag": [], + "impact": "Medium Impact", + "helpText": "Deploy Microsoft Purview retention compliance policies from CIPP templates. Existing policies and rules are overwritten in place.", + "executiveText": "Deploys retention policies that govern how long content is preserved in Exchange, SharePoint, OneDrive, and Teams. Enforces consistent compliance retention across tenants for regulatory and legal hold needs.", + "docsDescription": "Deploy Microsoft Purview retention compliance policies from CIPP templates. If a policy or rule with the same name already exists in the tenant, it is updated in place; otherwise it is created. Uses the application token to bypass GDAP delegated-identity restrictions on retention cmdlets. The standard grades whether each selected policy exists by name - it does not diff policy settings - and rewrites every selected policy on each remediation run, matching the classic standard.", + "impactColour": "warning", + "addedDate": "2026-05-10", + "powershellEquivalent": "New-RetentionCompliancePolicy or Set-RetentionCompliancePolicy", + "recommendedBy": [], + "requiredCapabilities": [], + "disabledFeatures": { + "report": false, + "warn": false, + "remediate": false + }, + "secureScoreImpact": 0, + "compare": "subset", + "checkBeforeRun": false, + "multiple": true, + "instanceIdentity": "retentionCompliancePolicyTemplate", + "identity": { + "partition": "RetentionCompliancePolicyTemplate", + "nameField": "name" + }, + "variables": { + "retentionCompliancePolicyTemplate": { + "type": "autoComplete", + "creatable": false, + "label": "Select Retention Compliance Policy Template", + "required": true, + "api": { + "url": "/api/ListRetentionCompliancePolicyTemplates", + "labelField": "name", + "valueField": "GUID", + "queryKey": "ListRetentionCompliancePolicyTemplates" + } + } + }, + "read": { + "cacheType": "ComplianceRetentionPolicies" + }, + "prepare": "Get-CIPPBaselineRetentionCompliancePolicyTemplateState", + "remediate": { + "executor": "RetentionCompliancePolicyTemplate" + } +} diff --git a/Config/BaselineStandards/Templates/ReusableSettingsTemplate.json b/Config/BaselineStandards/Templates/ReusableSettingsTemplate.json new file mode 100644 index 0000000000000..92ff672377534 --- /dev/null +++ b/Config/BaselineStandards/Templates/ReusableSettingsTemplate.json @@ -0,0 +1,61 @@ +{ + "name": "ReusableSettingsTemplate", + "label": "Reusable Settings Template", + "cat": "Templates", + "tag": [], + "impact": "Medium Impact", + "helpText": "Deploy Intune reusable settings from CIPP templates. An existing setting with the same name is diffed against the template and overwritten when it drifts.", + "executiveText": "Deploys Intune reusable policy settings - shared building blocks referenced by security and configuration policies - so the same certificates, file paths and definitions apply consistently across tenants.", + "docsDescription": "Deploy an Intune reusable policy setting from a CIPP template. The standard grades presence by display name and diffs the full setting body against the template via the Intune comparer; remediation creates the setting or overwrites it in place.", + "impactColour": "warning", + "addedDate": "2026-08-16", + "powershellEquivalent": "Graph: POST or PUT deviceManagement/reusablePolicySettings", + "recommendedBy": [], + "requiredCapabilities": [ + "INTUNE_A", + "MDM_Services", + "EMS", + "SCCM", + "MICROSOFTINTUNEPLAN1" + ], + "disabledFeatures": { + "report": false, + "warn": false, + "remediate": false + }, + "secureScoreImpact": 0, + "compare": "subset", + "multiple": true, + "instanceIdentity": "reusableSettingsTemplate", + "identity": { + "partition": "IntuneReusableSettingTemplate", + "nameField": "displayName" + }, + "variables": { + "reusableSettingsTemplate": { + "type": "autoComplete", + "creatable": false, + "label": "Select Reusable Settings Template", + "required": true, + "api": { + "url": "/api/ListIntuneReusableSettingTemplates", + "labelField": "displayName", + "valueField": "GUID", + "queryKey": "ListIntuneReusableSettingTemplates", + "showRefresh": true, + "templateView": { + "title": "Reusable Settings", + "property": "RawJSON", + "type": "intune" + } + } + } + }, + "read": { + "cacheType": "IntuneReusableSettings" + }, + "prepare": "Get-CIPPBaselineReusableSettingsTemplateState", + "remediate": { + "executor": "ReusableSettingsTemplate" + } +} diff --git a/Config/BaselineStandards/Templates/SafeLinksTemplatePolicy.json b/Config/BaselineStandards/Templates/SafeLinksTemplatePolicy.json new file mode 100644 index 0000000000000..b2ec83735eed5 --- /dev/null +++ b/Config/BaselineStandards/Templates/SafeLinksTemplatePolicy.json @@ -0,0 +1,64 @@ +{ + "name": "SafeLinksTemplatePolicy", + "label": "SafeLinks Policy Template", + "cat": "Templates", + "tag": [], + "impact": "Medium Impact", + "helpText": "Deploy SafeLinks policies and rules from CIPP templates. Existing policies and rules are rewritten from the template on every remediation run.", + "executiveText": "Deploys standardized SafeLinks policies that protect users from malicious links in email, Teams and Office documents. Template-driven deployment keeps link protection settings uniform across tenants.", + "docsDescription": "Deploy a SafeLinks policy and its rule from a CIPP template. The standard grades whether the policy and the rule each exist by name - it does not diff their settings - and rewrites both from the template on each remediation run, matching the classic standard. The rule's enabled state is applied when the template expresses one.", + "impactColour": "warning", + "addedDate": "2026-08-16", + "powershellEquivalent": "New-SafeLinksPolicy, Set-SafeLinksPolicy, New-SafeLinksRule or Set-SafeLinksRule", + "recommendedBy": [], + "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" + ] + ], + "disabledFeatures": { + "report": false, + "warn": false, + "remediate": false + }, + "secureScoreImpact": 0, + "compare": "subset", + "checkBeforeRun": false, + "multiple": true, + "instanceIdentity": "safeLinksTemplate", + "identity": { + "partition": "SafeLinksTemplate", + "nameField": "templateName" + }, + "variables": { + "safeLinksTemplate": { + "type": "autoComplete", + "creatable": false, + "label": "Select SafeLinks Policy Template", + "required": true, + "api": { + "url": "/api/ListSafeLinksPolicyTemplates", + "labelField": "TemplateName", + "valueField": "GUID", + "queryKey": "ListSafeLinksPolicyTemplates" + } + } + }, + "read": { + "cacheType": "ExoSafeLinksPolicies" + }, + "prepare": "Get-CIPPBaselineSafeLinksTemplatePolicyState", + "remediate": { + "executor": "SafeLinksTemplatePolicy" + } +} diff --git a/Config/BaselineStandards/Templates/SensitiveInfoTypeTemplate.json b/Config/BaselineStandards/Templates/SensitiveInfoTypeTemplate.json new file mode 100644 index 0000000000000..ff6ed90a4c911 --- /dev/null +++ b/Config/BaselineStandards/Templates/SensitiveInfoTypeTemplate.json @@ -0,0 +1,46 @@ +{ + "name": "SensitiveInfoTypeTemplate", + "label": "Sensitive Information Type Template", + "cat": "Templates", + "tag": [], + "impact": "Low Impact", + "helpText": "Deploy custom Microsoft Purview Sensitive Information Types from CIPP templates. Existing custom SITs with the same name are overwritten in place.", + "executiveText": "Deploys custom Sensitive Information Types so DLP policies can detect organization-specific identifiers - employee IDs, project codenames, internal account numbers - across tenants consistently.", + "docsDescription": "Deploy custom Sensitive Information Types from CIPP templates. Supports the simple-mode template (Name + Pattern + Confidence - backend synthesizes the rule pack XML) and the advanced-mode template (caller-supplied FileDataBase64 rule pack). Each selected template is diffed against the live SIT's rule pack; missing and drifted SITs are remediated in place. Built-in Microsoft SITs cannot be modified and are reported as compliant. A template supplying neither a Pattern nor a rule pack is reported as invalid and is not deployed.", + "impactColour": "info", + "addedDate": "2026-05-10", + "powershellEquivalent": "New-DlpSensitiveInformationTypeRulePackage or Set-DlpSensitiveInformationTypeRulePackage", + "recommendedBy": [], + "requiredCapabilities": [], + "disabledFeatures": { + "report": false, + "warn": false, + "remediate": false + }, + "secureScoreImpact": 0, + "compare": "subset", + "multiple": true, + "instanceIdentity": "sensitiveInfoTypeTemplate", + "identity": { + "partition": "SensitiveInfoTypeTemplate", + "nameField": "name" + }, + "variables": { + "sensitiveInfoTypeTemplate": { + "type": "autoComplete", + "creatable": false, + "label": "Select Sensitive Information Type Template", + "required": true, + "api": { + "url": "/api/ListSensitiveInfoTypeTemplates", + "labelField": "name", + "valueField": "GUID", + "queryKey": "ListSensitiveInfoTypeTemplates" + } + } + }, + "prepare": "Get-CIPPBaselineSensitiveInfoTypeTemplateState", + "remediate": { + "executor": "SensitiveInfoTypeTemplate" + } +} diff --git a/Config/BaselineStandards/Templates/SensitivityLabelTemplate.json b/Config/BaselineStandards/Templates/SensitivityLabelTemplate.json new file mode 100644 index 0000000000000..93f90081f2068 --- /dev/null +++ b/Config/BaselineStandards/Templates/SensitivityLabelTemplate.json @@ -0,0 +1,51 @@ +{ + "name": "SensitivityLabelTemplate", + "label": "Sensitivity Label Template", + "cat": "Templates", + "tag": [], + "impact": "Medium Impact", + "helpText": "Deploy Microsoft Purview sensitivity labels from CIPP templates. Existing labels and label policies are overwritten in place.", + "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.", + "docsDescription": "Deploy Microsoft Purview sensitivity labels from CIPP templates. If a label or label policy with the same name already exists, it is updated in place; otherwise it is created. The standard grades whether each selected label exists by name - it does not diff label settings - and rewrites every selected label on each remediation run, matching the classic standard.", + "impactColour": "warning", + "addedDate": "2026-05-10", + "powershellEquivalent": "Set-Label or New-Label", + "recommendedBy": [], + "requiredCapabilities": [], + "disabledFeatures": { + "report": false, + "warn": false, + "remediate": false + }, + "secureScoreImpact": 0, + "compare": "subset", + "checkBeforeRun": false, + "multiple": true, + "instanceIdentity": "sensitivityLabelTemplate", + "identity": { + "partition": "SensitivityLabelTemplate", + "nameField": "DisplayName" + }, + "variables": { + "sensitivityLabelTemplate": { + "type": "autoComplete", + "creatable": false, + "label": "Select Sensitivity Label Template", + "required": true, + "api": { + "url": "/api/ListSensitivityLabelTemplates", + "labelField": "DisplayName", + "altLabelField": "Name", + "valueField": "GUID", + "queryKey": "ListSensitivityLabelTemplates" + } + } + }, + "read": { + "cacheType": "ExoLabels" + }, + "prepare": "Get-CIPPBaselineSensitivityLabelTemplateState", + "remediate": { + "executor": "SensitivityLabelTemplate" + } +} diff --git a/Config/BaselineStandards/Templates/TenantAllowBlockListTemplate.json b/Config/BaselineStandards/Templates/TenantAllowBlockListTemplate.json new file mode 100644 index 0000000000000..aba04df21c00f --- /dev/null +++ b/Config/BaselineStandards/Templates/TenantAllowBlockListTemplate.json @@ -0,0 +1,56 @@ +{ + "name": "TenantAllowBlockListTemplate", + "label": "Tenant Allow/Block List Template", + "cat": "Templates", + "tag": [], + "impact": "Medium Impact", + "helpText": "Deploy Tenant Allow/Block List entries from CIPP templates. Entries are only ever added - existing entries are never modified or removed.", + "executiveText": "Deploys standardized allow and block list entries for senders, URLs and file hashes, keeping the same threat allowances and blocks in place across tenants.", + "docsDescription": "Deploy Tenant Allow/Block List entries from a CIPP template. The standard grades which of the template's entries are missing from the tenant's list and remediation adds only those - entries added by hand are never touched. Entries whose expiration passed read as missing again and are re-added.", + "impactColour": "warning", + "addedDate": "2026-08-16", + "powershellEquivalent": "New-TenantAllowBlockListItems", + "recommendedBy": [], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ], + "disabledFeatures": { + "report": false, + "warn": false, + "remediate": false + }, + "secureScoreImpact": 0, + "compare": "subset", + "multiple": true, + "instanceIdentity": "tenantAllowBlockListTemplate", + "identity": { + "partition": "TenantAllowBlockListTemplate", + "nameField": "templateName" + }, + "variables": { + "tenantAllowBlockListTemplate": { + "type": "autoComplete", + "creatable": false, + "label": "Select Tenant Allow/Block List Template", + "required": true, + "api": { + "url": "/api/ListTenantAllowBlockListTemplates", + "labelField": "templateName", + "valueField": "GUID", + "queryKey": "ListTenantAllowBlockListTemplates", + "showRefresh": true + } + } + }, + "read": { + "cacheType": "ExoTenantAllowBlockList" + }, + "prepare": "Get-CIPPBaselineTenantAllowBlockListTemplateState", + "remediate": { + "executor": "TenantAllowBlockListTemplate" + } +} diff --git a/Config/BaselineStandards/Templates/TransportRuleTemplate.json b/Config/BaselineStandards/Templates/TransportRuleTemplate.json new file mode 100644 index 0000000000000..58d6c02e20443 --- /dev/null +++ b/Config/BaselineStandards/Templates/TransportRuleTemplate.json @@ -0,0 +1,61 @@ +{ + "name": "TransportRuleTemplate", + "label": "Transport Rule Template", + "cat": "Templates", + "tag": [], + "impact": "Medium Impact", + "helpText": "Deploy transport rules to manage email flow. Compares presence by name; an existing rule is only rewritten when overwrite is on.", + "executiveText": "Deploys standardized email flow rules that automatically manage how emails are processed, filtered, and routed within the organization. These templates ensure consistent email security policies, compliance requirements, and business rules are applied across all email communications.", + "docsDescription": "Deploys a transport rule from a CIPP template. The standard grades whether the rule exists by name - it does not diff rule bodies, matching the classic standard.", + "impactColour": "warning", + "addedDate": "2023-12-30", + "powershellEquivalent": "New-TransportRule or Set-TransportRule", + "recommendedBy": [], + "requiredCapabilities": [ + "EXCHANGE_S_STANDARD", + "EXCHANGE_S_ENTERPRISE", + "EXCHANGE_S_STANDARD_GOV", + "EXCHANGE_S_ENTERPRISE_GOV", + "EXCHANGE_LITE" + ], + "disabledFeatures": { + "report": true, + "warn": true, + "remediate": false + }, + "secureScoreImpact": 0, + "compare": "subset", + "multiple": true, + "instanceIdentity": "transportRuleTemplate", + "identity": { + "partition": "TransportTemplate", + "nameField": "name" + }, + "variables": { + "transportRuleTemplate": { + "type": "autoComplete", + "label": "Select Transport Rule Template", + "required": true, + "api": { + "url": "/api/ListTransportRulesTemplates?noJson=true", + "labelField": "name", + "valueField": "GUID", + "queryKey": "ListTransportRulesTemplates" + } + }, + "overwrite": { + "type": "switch", + "label": "Overwrite existing transport rules", + "default": true, + "recommended": true + } + }, + "read": { + "cacheType": "ExoTransportRules" + }, + "prepare": "Get-CIPPBaselineTransportRuleTemplateState", + "remediate": { + "executor": "TransportRuleTemplate", + "overwrite": "%overwrite%" + } +} diff --git a/Config/CIPPDBCacheTypes.json b/Config/CIPPDBCacheTypes.json index e97e6260435e4..07903eacec24b 100644 --- a/Config/CIPPDBCacheTypes.json +++ b/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", @@ -357,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", @@ -403,5 +408,245 @@ "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": "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/Config/CIPPTimers.json b/Config/CIPPTimers.json index e64cc3ccb578c..56d42c3e97375 100644 --- a/Config/CIPPTimers.json +++ b/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 @@ -280,7 +280,7 @@ "Command": "Start-CIPPBaselineOrchestrator", "Description": "Baseline run: graduations, drift detection and remediation for all baselines", "Cron": "0 0 */12 * * *", - "Priority": 4, + "Priority": 3, "RunOnProcessor": true, "TZOffset": true, "PreferredProcessor": "standards" diff --git a/Config/ConversionTable.csv b/Config/ConversionTable.csv index cdd72dd19b8fb..f15bf16f1fb7b 100644 --- a/Config/ConversionTable.csv +++ b/Config/ConversionTable.csv @@ -4005,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 @@ -4026,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 @@ -4128,6 +4135,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 @@ -5665,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 @@ -5838,6 +5847,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 +5866,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 +6001,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/Config/PermissionsTranslator.json b/Config/PermissionsTranslator.json index c8a297b6e546d..30c15bbb5bdad 100644 --- a/Config/PermissionsTranslator.json +++ b/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/Config/openapi.json b/Config/openapi.json index 454444db4e9e7..bde46dd20775e 100644 --- a/Config/openapi.json +++ b/Config/openapi.json @@ -620,6 +620,12 @@ "DisplayName": { "type": "string" }, + "GroupIds": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LabelValue" + } + }, "selectedTenants": { "$ref": "#/components/schemas/LabelValue" } @@ -764,7 +770,7 @@ "bearerAuth": [] } ], - "x-cipp-role": "Tenant.Standards.ReadWrite", + "x-cipp-role": "Tenant.Baselines.ReadWrite", "x-cipp-reads-via": [ "New-CIPPBaseline" ] @@ -8583,9 +8589,6 @@ } } }, - "label": { - "type": "string" - }, "value": { "type": "string" } @@ -8706,7 +8709,8 @@ "type": "array", "items": { "type": "string" - } + }, + "description": "Keep unresolved ManagedBy entries so a failed lookup cannot strip an owner." } } } @@ -13577,6 +13581,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", @@ -14386,7 +14461,7 @@ "bearerAuth": [] } ], - "x-cipp-role": "Tenant.Standards.ReadWrite" + "x-cipp-role": "Tenant.Baselines.ReadWrite" } }, "/api/ExecBaselineRun": { @@ -14461,7 +14536,7 @@ "bearerAuth": [] } ], - "x-cipp-role": "Tenant.Standards.ReadWrite" + "x-cipp-role": "Tenant.BaselinesRun.ReadWrite" } }, "/api/ExecBaselineStage": { @@ -14525,7 +14600,7 @@ "bearerAuth": [] } ], - "x-cipp-role": "Tenant.Standards.ReadWrite" + "x-cipp-role": "Tenant.Baselines.ReadWrite" } }, "/api/ExecBECCheck": { @@ -16327,7 +16402,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 +16413,8 @@ "type": "string" }, "Message": { - "type": "string" + "type": "string", + "description": "Pretty-printed, not compressed: repo files are hand-edited on GitHub." }, "Path": { "type": "string" @@ -17565,12 +17642,7 @@ } }, "tenantFilter": { - "type": "object", - "properties": { - "label": { - "type": "string" - } - } + "$ref": "#/components/schemas/LabelValue" } } }, @@ -17727,6 +17799,17 @@ "NewRoleName": { "type": "string" }, + "PermissionRules": { + "type": "object", + "properties": { + "Exclude": { + "type": "string" + }, + "Include": { + "type": "string" + } + } + }, "Permissions": { "type": "string" }, @@ -18478,7 +18561,8 @@ "enum": [ "createDeviceLogCollectionRequest", "setDeviceName", - "users" + "users", + "wipe" ], "description": "Interact with Body parameters or the body of the request." }, @@ -18492,6 +18576,9 @@ }, "description": "limit to 15 characters" }, + "macOsUnlockCode": { + "type": "string" + }, "tenantFilter": { "type": "string" }, @@ -20450,6 +20537,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", @@ -20466,6 +20554,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", @@ -20484,6 +20573,7 @@ "enum": [ "HaloPSA", "HaloPSAFields", + "HaloPSARequestSources", "Hudu", "HuduFields", "NinjaOne", @@ -22286,13 +22376,14 @@ "x-cipp-role": "CIPP.Core.Read" } }, - "/api/ExecGroupsDelete": { + "/api/ExecGroupMembers": { "post": { - "summary": "ExecGroupsDelete", - "operationId": "ExecGroupsDelete", + "summary": "ExecGroupMembers", + "operationId": "ExecGroupMembers", "tags": [ - "Email-Exchange > Administration" + "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": { @@ -22300,18 +22391,24 @@ "schema": { "type": "object", "properties": { - "displayName": { - "type": "string" + "action": { + "type": "string", + "enum": [ + "addMember", + "addOwner", + "removeMember", + "removeOwner" + ] }, - "GroupType": { + "groupId": { "type": "string" }, - "id": { + "tenantFilter": { "type": "string" }, - "tenantFilter": { + "users": { "type": "string", - "description": "Interact with query parameters or the body of the request." + "description": "Accept a single string or an array of strings (IDs, UPNs, or mail)" } }, "required": [ @@ -22321,35 +22418,6 @@ } } }, - "parameters": [ - { - "name": "displayName", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "GroupType", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "id", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "$ref": "#/components/parameters/tenantFilter" - } - ], "responses": { "200": { "description": "Success", @@ -22361,6 +22429,9 @@ } } }, + "400": { + "description": "Bad request - missing required field or invalid input" + }, "401": { "description": "Unauthorized - invalid or missing bearer token" }, @@ -22376,13 +22447,13 @@ "bearerAuth": [] } ], - "x-cipp-role": "Exchange.Mailbox.ReadWrite" + "x-cipp-role": "Identity.Group.ReadWrite" } }, - "/api/ExecGroupsDeliveryManagement": { + "/api/ExecGroupsDelete": { "post": { - "summary": "ExecGroupsDeliveryManagement", - "operationId": "ExecGroupsDeliveryManagement", + "summary": "ExecGroupsDelete", + "operationId": "ExecGroupsDelete", "tags": [ "Email-Exchange > Administration" ], @@ -22393,13 +22464,13 @@ "schema": { "type": "object", "properties": { - "GroupType": { + "displayName": { "type": "string" }, - "ID": { + "GroupType": { "type": "string" }, - "OnlyAllowInternal": { + "id": { "type": "string" }, "tenantFilter": { @@ -22416,7 +22487,7 @@ }, "parameters": [ { - "name": "GroupType", + "name": "displayName", "in": "query", "required": false, "schema": { @@ -22424,7 +22495,7 @@ } }, { - "name": "ID", + "name": "GroupType", "in": "query", "required": false, "schema": { @@ -22432,7 +22503,7 @@ } }, { - "name": "OnlyAllowInternal", + "name": "id", "in": "query", "required": false, "schema": { @@ -22469,13 +22540,13 @@ "bearerAuth": [] } ], - "x-cipp-role": "Exchange.Group.ReadWrite" + "x-cipp-role": "Exchange.Mailbox.ReadWrite" } }, - "/api/ExecGroupsHideFromGAL": { + "/api/ExecGroupsDeliveryManagement": { "post": { - "summary": "ExecGroupsHideFromGAL", - "operationId": "ExecGroupsHideFromGAL", + "summary": "ExecGroupsDeliveryManagement", + "operationId": "ExecGroupsDeliveryManagement", "tags": [ "Email-Exchange > Administration" ], @@ -22489,10 +22560,10 @@ "GroupType": { "type": "string" }, - "HideFromGAL": { + "ID": { "type": "string" }, - "ID": { + "OnlyAllowInternal": { "type": "string" }, "tenantFilter": { @@ -22517,7 +22588,7 @@ } }, { - "name": "HideFromGAL", + "name": "ID", "in": "query", "required": false, "schema": { @@ -22525,7 +22596,7 @@ } }, { - "name": "ID", + "name": "OnlyAllowInternal", "in": "query", "required": false, "schema": { @@ -22565,61 +22636,10 @@ "x-cipp-role": "Exchange.Group.ReadWrite" } }, - "/api/ExecHaloPSATestTicket": { - "post": { - "summary": "Create a HaloPSA test ticket so admins can check the integration works without waiting\nfor a real alert to fire. Covers auth, tenant mapping, ticket type and default priority.\n\nCalls New-HaloPSATicket directly rather than going through Send-CIPPAlert, so the parts\nthat live in the alert pipeline - the [CIPP] title prefix and per-user ticket linking -\nare not exercised here.", - "operationId": "ExecHaloPSATestTicket", - "tags": [ - "CIPP > Extensions" - ], - "requestBody": { - "required": false, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": {} - } - } - } - }, - "responses": { - "200": { - "description": "Success", - "content": { - "application/json": { - "schema": { - "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": { - "Results": { - "x-cipp-field-source": "backend" - } - } - } - } - } - }, - "401": { - "description": "Unauthorized - invalid or missing bearer token" - }, - "403": { - "description": "Forbidden - caller lacks the required RBAC role" - } - }, - "security": [ - { - "bearerAuth": [] - } - ], - "x-cipp-role": "CIPP.Extension.ReadWrite", - "x-cipp-any-tenant": true - } - }, - "/api/ExecHideFromGAL": { + "/api/ExecGroupsHideFromGAL": { "post": { - "summary": "ExecHideFromGAL", - "operationId": "ExecHideFromGAL", + "summary": "ExecGroupsHideFromGAL", + "operationId": "ExecGroupsHideFromGAL", "tags": [ "Email-Exchange > Administration" ], @@ -22630,15 +22650,18 @@ "schema": { "type": "object", "properties": { + "GroupType": { + "type": "string" + }, "HideFromGAL": { "type": "string" }, "ID": { - "type": "string", - "description": "Support if the request is a POST or a GET. So to support legacy(GET) and new(POST) requests" + "type": "string" }, "tenantFilter": { - "type": "string" + "type": "string", + "description": "Interact with query parameters or the body of the request." } }, "required": [ @@ -22649,6 +22672,14 @@ } }, "parameters": [ + { + "name": "GroupType", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, { "name": "HideFromGAL", "in": "query", @@ -22660,7 +22691,140 @@ { "name": "ID", "in": "query", - "description": "Support if the request is a POST or a GET. So to support legacy(GET) and new(POST) requests", + "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": "Exchange.Group.ReadWrite" + } + }, + "/api/ExecHaloPSATestTicket": { + "post": { + "summary": "Create a HaloPSA test ticket so admins can check the integration works without waiting\nfor a real alert to fire. Covers auth, tenant mapping, ticket type and default priority.\n\nCalls New-HaloPSATicket directly rather than going through Send-CIPPAlert, so the parts\nthat live in the alert pipeline - the [CIPP] title prefix and per-user ticket linking -\nare not exercised here.", + "operationId": "ExecHaloPSATestTicket", + "tags": [ + "CIPP > Extensions" + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": {} + } + } + } + }, + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "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": { + "Results": { + "x-cipp-field-source": "backend" + } + } + } + } + } + }, + "401": { + "description": "Unauthorized - invalid or missing bearer token" + }, + "403": { + "description": "Forbidden - caller lacks the required RBAC role" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "x-cipp-role": "CIPP.Extension.ReadWrite", + "x-cipp-any-tenant": true + } + }, + "/api/ExecHideFromGAL": { + "post": { + "summary": "ExecHideFromGAL", + "operationId": "ExecHideFromGAL", + "tags": [ + "Email-Exchange > Administration" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "HideFromGAL": { + "type": "string" + }, + "ID": { + "type": "string", + "description": "Support if the request is a POST or a GET. So to support legacy(GET) and new(POST) requests" + }, + "tenantFilter": { + "type": "string" + } + }, + "required": [ + "tenantFilter" + ] + } + } + } + }, + "parameters": [ + { + "name": "HideFromGAL", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "ID", + "in": "query", + "description": "Support if the request is a POST or a GET. So to support legacy(GET) and new(POST) requests", "required": false, "schema": { "type": "string" @@ -22977,6 +23141,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", @@ -24030,6 +24265,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", @@ -26630,6 +27016,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" }, @@ -27762,6 +28151,9 @@ } } }, + "400": { + "description": "Bad request - missing required field or invalid input" + }, "401": { "description": "Unauthorized - invalid or missing bearer token" }, @@ -27777,6 +28169,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", @@ -29088,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", @@ -30239,13 +30765,14 @@ "x-cipp-any-tenant": true } }, - "/api/ExecScheduleForwardingVacation": { + "/api/ExecScheduleAuditExclusionVacation": { "post": { - "summary": "ExecScheduleForwardingVacation", - "operationId": "ExecScheduleForwardingVacation", + "summary": "Schedule a location alert exclusion for a vacation period", + "operationId": "ExecScheduleAuditExclusionVacation", "tags": [ - "Email-Exchange > Administration" + "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": { @@ -30254,23 +30781,8 @@ "type": "object", "properties": { "endDate": { - "type": "string" - }, - "ForwardExternal": { - "type": "string" - }, - "ForwardInternal": { - "$ref": "#/components/schemas/LabelValue" - }, - "forwardOption": { "type": "string", - "enum": [ - "ExternalAddress", - "internalAddress" - ] - }, - "KeepCopy": { - "type": "string" + "description": "Unix timestamp for when the exclusion is removed" }, "postExecution": { "type": "string" @@ -30279,17 +30791,18 @@ "type": "string" }, "startDate": { - "type": "string" + "type": "string", + "description": "Unix timestamp for when the exclusion is added" }, "tenantFilter": { "type": "string" }, "Users": { - "type": "string" + "type": "string", + "description": "The users going on vacation" } }, "required": [ - "ForwardExternal", "tenantFilter" ] } @@ -30322,13 +30835,13 @@ "bearerAuth": [] } ], - "x-cipp-role": "Exchange.Mailbox.ReadWrite" + "x-cipp-role": "CIPP.Alert.ReadWrite" } }, - "/api/ExecScheduleMailboxVacation": { + "/api/ExecScheduleForwardingVacation": { "post": { - "summary": "ExecScheduleMailboxVacation", - "operationId": "ExecScheduleMailboxVacation", + "summary": "ExecScheduleForwardingVacation", + "operationId": "ExecScheduleForwardingVacation", "tags": [ "Email-Exchange > Administration" ], @@ -30339,116 +30852,202 @@ "schema": { "type": "object", "properties": { - "autoMap": { - "type": "boolean" - }, - "calendarPermission": { - "$ref": "#/components/schemas/LabelValue" - }, - "canViewPrivateItems": { - "type": "boolean" - }, - "delegates": { - "type": "string" - }, "endDate": { "type": "string" }, - "includeCalendar": { - "type": "boolean" - }, - "mailboxOwners": { - "type": "string" - }, - "permissionTypes": { - "type": "string" - }, - "postExecution": { - "type": "string" - }, - "reference": { - "type": "string" - }, - "startDate": { + "ForwardExternal": { "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/ExecScheduleOOOVacation": { - "post": { - "summary": "ExecScheduleOOOVacation", - "operationId": "ExecScheduleOOOVacation", - "tags": [ - "Email-Exchange > Administration" - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "AutoDeclineFutureRequestsWhenOOF": { - "type": "string" + "ForwardInternal": { + "$ref": "#/components/schemas/LabelValue" }, - "CreateOOFEvent": { + "forwardOption": { "type": "string", - "description": "Calendar options — conditionally add when truthy in the request body" - }, - "DeclineEventsForScheduledOOF": { - "type": "string" - }, - "DeclineMeetingMessage": { - "type": "string" - }, - "endDate": { - "type": "string" - }, - "externalMessage": { - "type": "string" - }, - "internalMessage": { - "type": "string" + "enum": [ + "ExternalAddress", + "internalAddress" + ] }, - "OOFEventSubject": { + "KeepCopy": { + "type": "string" + }, + "postExecution": { + "type": "string" + }, + "reference": { + "type": "string" + }, + "startDate": { + "type": "string" + }, + "tenantFilter": { + "type": "string" + }, + "Users": { + "type": "string" + } + }, + "required": [ + "ForwardExternal", + "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/ExecScheduleMailboxVacation": { + "post": { + "summary": "ExecScheduleMailboxVacation", + "operationId": "ExecScheduleMailboxVacation", + "tags": [ + "Email-Exchange > Administration" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "autoMap": { + "type": "boolean" + }, + "calendarPermission": { + "$ref": "#/components/schemas/LabelValue" + }, + "canViewPrivateItems": { + "type": "boolean" + }, + "delegates": { + "type": "string" + }, + "endDate": { + "type": "string" + }, + "includeCalendar": { + "type": "boolean" + }, + "mailboxOwners": { + "type": "string" + }, + "permissionTypes": { + "type": "string" + }, + "postExecution": { + "type": "string" + }, + "reference": { + "type": "string" + }, + "startDate": { + "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/ExecScheduleOOOVacation": { + "post": { + "summary": "ExecScheduleOOOVacation", + "operationId": "ExecScheduleOOOVacation", + "tags": [ + "Email-Exchange > Administration" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "AutoDeclineFutureRequestsWhenOOF": { + "type": "string" + }, + "CreateOOFEvent": { + "type": "string", + "description": "Calendar options — conditionally add when truthy in the request body" + }, + "DeclineEventsForScheduledOOF": { + "type": "string" + }, + "DeclineMeetingMessage": { + "type": "string" + }, + "endDate": { + "type": "string" + }, + "externalMessage": { + "type": "string" + }, + "internalMessage": { + "type": "string" + }, + "OOFEventSubject": { "type": "string" }, "postExecution": { @@ -33226,36 +33825,53 @@ "x-cipp-any-tenant": true } }, - "/api/ExecSnoozeAlert": { + "/api/ExecSiteBrowserActions": { "post": { - "summary": "ExecSnoozeAlert", - "operationId": "ExecSnoozeAlert", + "summary": "ExecSiteBrowserActions", + "operationId": "ExecSiteBrowserActions", "tags": [ - "CIPP > Core" + "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": false, + "required": true, "content": { "application/json": { "schema": { "type": "object", "properties": { - "AlertItem": { + "Action": { "type": "string" }, - "CmdletName": { + "BatchDeleteMode": { + "$ref": "#/components/schemas/LabelValueNumber" + }, + "DeleteOlderThanDays": { "type": "string" }, - "Duration": { - "type": "integer" + "MajorVersionLimit": { + "type": "string" }, - "Reason": { + "MajorWithMinorVersionsLimit": { "type": "string" }, - "TenantFilter": { + "SiteId": { + "type": "string" + }, + "SiteUrl": { + "type": "string" + }, + "SyncListPolicy": { + "type": "boolean" + }, + "tenantFilter": { "type": "string" } - } + }, + "required": [ + "Action", + "tenantFilter" + ] } } } @@ -33279,9 +33895,6 @@ }, "403": { "description": "Forbidden - caller lacks the required RBAC role" - }, - "500": { - "description": "Internal server error" } }, "security": [ @@ -33289,17 +33902,17 @@ "bearerAuth": [] } ], - "x-cipp-role": "CIPP.AlertSnooze.ReadWrite", - "x-cipp-any-tenant": true + "x-cipp-role": "Sharepoint.Site.ReadWrite" } }, - "/api/ExecSPOVersionCleanup": { + "/api/ExecSiteBrowserPermissions": { "post": { - "summary": "ExecSPOVersionCleanup", - "operationId": "ExecSPOVersionCleanup", + "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": { @@ -33307,16 +33920,47 @@ "schema": { "type": "object", "properties": { - "BatchDeleteMode": { + "Action": { "type": "string" }, - "DeleteOlderThanDays": { + "ClearSubscopes": { + "type": "boolean" + }, + "CopyRoleAssignments": { "type": "string" }, - "MajorVersionLimit": { + "GroupId": { "type": "string" }, - "MajorWithMinorVersionsLimit": { + "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": { @@ -33324,9 +33968,26 @@ }, "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" ] } @@ -33344,14 +34005,14 @@ } } }, + "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": [ @@ -33359,79 +34020,218 @@ "bearerAuth": [] } ], - "x-cipp-role": "Sharepoint.Site.ReadWrite" + "x-cipp-role": "Sharepoint.Site.ReadWrite", + "x-cipp-reads-via": [ + "Resolve-BrowserPermissionRoleDefId" + ] } }, - "/api/ExecSSOSetup": { + "/api/ExecSnoozeAlert": { "post": { - "summary": "ExecSSOSetup", - "operationId": "ExecSSOSetup", + "summary": "ExecSnoozeAlert", + "operationId": "ExecSnoozeAlert", "tags": [ - "CIPP > Setup" + "CIPP > Core" ], "requestBody": { - "required": true, + "required": false, "content": { "application/json": { "schema": { "type": "object", "properties": { - "Action": { + "AlertItem": { "type": "string" }, - "appId": { + "CmdletName": { "type": "string" }, - "appSecret": { - "type": "string" + "Duration": { + "type": "integer" }, - "multiTenant": { - "type": "boolean" + "Reason": { + "type": "string" }, - "targetUrl": { + "TenantFilter": { "type": "string" } - }, - "required": [ - "appSecret" - ] + } } } } }, - "parameters": [ - { - "name": "Action", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - } - ], "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. The fields taken from Graph are the ones this endpoint selects, so they are what the response actually carries.", - "properties": { - "AppId": { - "type": "string", - "x-cipp-field-source": "graph,backend" - }, - "CreatedAt": { - "x-cipp-field-source": "backend" - }, - "MultiTenant": { - "x-cipp-field-source": "backend" - }, - "Status": { - "x-cipp-field-source": "backend" - } - } + "$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": "CIPP.AlertSnooze.ReadWrite", + "x-cipp-any-tenant": true + } + }, + "/api/ExecSPOVersionCleanup": { + "post": { + "summary": "ExecSPOVersionCleanup", + "operationId": "ExecSPOVersionCleanup", + "tags": [ + "Teams-Sharepoint" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "BatchDeleteMode": { + "type": "string" + }, + "DeleteOlderThanDays": { + "type": "string" + }, + "MajorVersionLimit": { + "type": "string" + }, + "MajorWithMinorVersionsLimit": { + "type": "string" + }, + "SiteUrl": { + "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": "Sharepoint.Site.ReadWrite" + } + }, + "/api/ExecSSOSetup": { + "post": { + "summary": "ExecSSOSetup", + "operationId": "ExecSSOSetup", + "tags": [ + "CIPP > Setup" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "Action": { + "type": "string" + }, + "appId": { + "type": "string" + }, + "appSecret": { + "type": "string" + }, + "multiTenant": { + "type": "boolean" + }, + "targetUrl": { + "type": "string" + } + }, + "required": [ + "appSecret" + ] + } + } + } + }, + "parameters": [ + { + "name": "Action", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ], + "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. The fields taken from Graph are the ones this endpoint selects, so they are what the response actually carries.", + "properties": { + "AppId": { + "type": "string", + "x-cipp-field-source": "graph,backend" + }, + "CreatedAt": { + "x-cipp-field-source": "backend" + }, + "MultiTenant": { + "x-cipp-field-source": "backend" + }, + "Status": { + "x-cipp-field-source": "backend" + } + } } } } @@ -34293,6 +35093,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", @@ -34605,7 +35491,7 @@ "bearerAuth": [] } ], - "x-cipp-role": "Tenant.Standards.ReadWrite" + "x-cipp-role": "Tenant.BaselinesDeviations.ReadWrite" } }, "/api/ExecUpdateDriftDeviation": { @@ -35138,7 +36024,7 @@ "tags": [ "CIPP > Core" ], - "description": "Compares the caller's reported CIPP version against the latest published release and reports whether the frontend or the API is out of date.", + "description": "Compares the caller's reported CIPP version against the latest published release and reports whether the frontend or the API is out of date, alongside the hosting shape (hosting type, App Service SKU, runtime stack) and the recorded version-update history.", "parameters": [ { "name": "LocalVersion", @@ -35156,7 +36042,27 @@ "application/json": { "schema": { "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." + "description": "Derived from the fields the endpoint selects onto each record. The response may carry more; these are the ones known to exist.", + "properties": { + "LocalCIPPAPIVersion": { + "x-cipp-field-source": "backend" + }, + "LocalCIPPVersion": { + "x-cipp-field-source": "backend" + }, + "OutOfDateCIPP": { + "x-cipp-field-source": "backend" + }, + "OutOfDateCIPPAPI": { + "x-cipp-field-source": "backend" + }, + "RemoteCIPPAPIVersion": { + "x-cipp-field-source": "backend" + }, + "RemoteCIPPVersion": { + "x-cipp-field-source": "backend" + } + } } } } @@ -35783,6 +36689,9 @@ "Conditions": { "x-cipp-field-source": "frontend" }, + "Enabled": { + "x-cipp-field-source": "frontend" + }, "EventType": { "x-cipp-field-source": "frontend" }, @@ -37445,22 +38354,34 @@ "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.", + "description": "Derived from the fields written into the storage table it reads, and the fields the endpoint selects onto each record. Fields taken from the storage writers may be omitted by this endpoint, and the response may carry computed fields not listed here.", "properties": { "Data": { - "x-cipp-field-source": "backend" + "type": "string", + "x-cipp-field-source": "storage,backend" + }, + "ETag": { + "type": "string", + "x-cipp-field-source": "storage" }, "LogId": { "x-cipp-field-source": "backend" }, + "PartitionKey": { + "x-cipp-field-source": "storage" + }, + "RowKey": { + "x-cipp-field-source": "storage" + }, "Tenant": { - "x-cipp-field-source": "backend" + "x-cipp-field-source": "storage,backend" }, "Timestamp": { - "x-cipp-field-source": "backend" + "type": "string", + "x-cipp-field-source": "storage,backend" }, "Title": { - "x-cipp-field-source": "backend" + "x-cipp-field-source": "storage,backend" } } } @@ -37995,7 +38916,7 @@ "bearerAuth": [] } ], - "x-cipp-role": "Tenant.Standards.Read" + "x-cipp-role": "Tenant.Baselines.Read" } }, "/api/ListBaselines": { @@ -38062,7 +38983,7 @@ "bearerAuth": [] } ], - "x-cipp-role": "Tenant.Standards.Read" + "x-cipp-role": "Tenant.Baselines.Read" } }, "/api/ListBaselineStandards": { @@ -38103,7 +39024,7 @@ "bearerAuth": [] } ], - "x-cipp-role": "Tenant.Standards.Read" + "x-cipp-role": "Tenant.Baselines.Read" } }, "/api/ListBasicAuth": { @@ -42357,7 +43278,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" @@ -45620,6 +46542,125 @@ "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. Supports UseReportDB=true to serve cached data from the reporting database; AllTenants always uses the cache.", + "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" + }, + { + "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": { + "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" + }, + "sponsors": { + "x-cipp-field-source": "backend" + }, + "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", @@ -45849,6 +46890,9 @@ "lastSignInDateTime": { "x-cipp-field-source": "frontend" }, + "lastSuccessfulSignInDateTime": { + "x-cipp-field-source": "frontend" + }, "numberOfAssignedLicenses": { "x-cipp-field-source": "frontend" }, @@ -46117,14 +47161,251 @@ "x-cipp-role": "Endpoint.MEM.Read" } }, - "/api/ListIntuneReusableSettings": { + "/api/ListIntuneReusableSettings": { + "get": { + "summary": "ListIntuneReusableSettings", + "operationId": "ListIntuneReusableSettings", + "tags": [ + "Endpoint > MEM" + ], + "description": "Lists Intune reusable policy settings for a tenant. Supports UseReportDB=true query parameter to retrieve cached data from the reporting database for significantly better performance, especially when querying AllTenants.", + "parameters": [ + { + "name": "ID", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "$ref": "#/components/parameters/tenantFilter" + }, + { + "name": "UseReportDB", + "in": "query", + "description": "Serve from the reporting database cache instead of live Graph. Much faster, especially for AllTenants.", + "required": false, + "schema": { + "type": "boolean" + } + } + ], + "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" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "x-cipp-role": "Endpoint.MEM.Read" + } + }, + "/api/ListIntuneReusableSettingTemplates": { + "get": { + "summary": "ListIntuneReusableSettingTemplates", + "operationId": "ListIntuneReusableSettingTemplates", + "tags": [ + "Endpoint > MEM" + ], + "description": "Lists the saved Intune reusable setting templates, the shared setting groups that settings catalog policies reference rather than redefining. Pass ID to return a single template.", + "parameters": [ + { + "name": "ID", + "in": "query", + "description": "escape OData quotes", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "description": "Derived from the fields written into the storage table it reads, and the fields the endpoint selects onto each record, and the columns the CIPP UI renders. Fields taken from the storage writers may be omitted by this endpoint, and the response may carry computed fields not listed here.", + "properties": { + "description": { + "x-cipp-field-source": "backend,frontend" + }, + "displayName": { + "x-cipp-field-source": "backend,frontend" + }, + "ETag": { + "type": "string", + "x-cipp-field-source": "storage" + }, + "GUID": { + "x-cipp-field-source": "storage,backend" + }, + "isSynced": { + "x-cipp-field-source": "backend,frontend" + }, + "JSON": { + "x-cipp-field-source": "storage" + }, + "Package": { + "x-cipp-field-source": "storage,frontend" + }, + "PartitionKey": { + "x-cipp-field-source": "storage" + }, + "Permissions": { + "type": "string", + "x-cipp-field-source": "storage" + }, + "RawJSON": { + "x-cipp-field-source": "backend" + }, + "RowKey": { + "x-cipp-field-source": "storage" + }, + "SHA": { + "x-cipp-field-source": "storage" + }, + "Source": { + "x-cipp-field-source": "storage" + }, + "TemplateName": { + "x-cipp-field-source": "storage" + }, + "Timestamp": { + "type": "string", + "x-cipp-field-source": "storage" + }, + "UpdatedBy": { + "x-cipp-field-source": "storage" + } + } + } + } + } + } + }, + "401": { + "description": "Unauthorized - invalid or missing bearer token" + }, + "403": { + "description": "Forbidden - caller lacks the required RBAC role" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "x-cipp-role": "Endpoint.MEM.Read", + "x-cipp-any-tenant": true + } + }, + "/api/ListIntuneScript": { + "get": { + "summary": "ListIntuneScript", + "operationId": "ListIntuneScript", + "tags": [ + "Endpoint > MEM" + ], + "description": "Lists Intune device management scripts (Windows, macOS, Linux, and remediation scripts) for a tenant. Supports UseReportDB=true query parameter to retrieve cached data from the reporting database for significantly better performance, especially when querying AllTenants.", + "parameters": [ + { + "$ref": "#/components/parameters/tenantFilter" + }, + { + "name": "UseReportDB", + "in": "query", + "description": "Serve from the reporting database cache instead of live Graph. Much faster, especially for AllTenants.", + "required": false, + "schema": { + "type": "boolean" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "description": "Derived from the columns the CIPP UI renders. The response may carry more; these are the ones known to exist.", + "properties": { + "description": { + "x-cipp-field-source": "frontend" + }, + "displayName": { + "x-cipp-field-source": "frontend" + }, + "lastModifiedDateTime": { + "x-cipp-field-source": "frontend" + }, + "runAsAccount": { + "x-cipp-field-source": "frontend" + }, + "ScriptAssignment": { + "x-cipp-field-source": "frontend" + }, + "ScriptExclude": { + "x-cipp-field-source": "frontend" + }, + "scriptType": { + "x-cipp-field-source": "frontend" + } + } + } + } + } + } + }, + "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.MEM.Read" + } + }, + "/api/ListIntuneTemplates": { "get": { - "summary": "ListIntuneReusableSettings", - "operationId": "ListIntuneReusableSettings", + "summary": "ListIntuneTemplates", + "operationId": "ListIntuneTemplates", "tags": [ "Endpoint > MEM" ], - "description": "Lists Intune reusable policy settings for a tenant. Supports UseReportDB=true query parameter to retrieve cached data from the reporting database for significantly better performance, especially when querying AllTenants.", + "description": "Lists the saved Intune policy templates. On first call the templates shipped with CIPP are imported into the templates table. These are CIPP templates, not a tenant's deployed policies.", "parameters": [ { "name": "ID", @@ -46135,57 +47416,16 @@ } }, { - "$ref": "#/components/parameters/tenantFilter" - }, - { - "name": "UseReportDB", + "name": "mode", "in": "query", - "description": "Serve from the reporting database cache instead of live Graph. Much faster, especially for AllTenants.", "required": false, "schema": { - "type": "boolean" - } - } - ], - "responses": { - "200": { - "description": "Success", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/StandardResults" - } - } + "type": "string" } }, - "401": { - "description": "Unauthorized - invalid or missing bearer token" - }, - "403": { - "description": "Forbidden - caller lacks the required RBAC role" - } - }, - "security": [ { - "bearerAuth": [] - } - ], - "x-cipp-role": "Endpoint.MEM.Read" - } - }, - "/api/ListIntuneReusableSettingTemplates": { - "get": { - "summary": "ListIntuneReusableSettingTemplates", - "operationId": "ListIntuneReusableSettingTemplates", - "tags": [ - "Endpoint > MEM" - ], - "description": "Lists the saved Intune reusable setting templates, the shared setting groups that settings catalog policies reference rather than redefining. Pass ID to return a single template.", - "parameters": [ - { - "name": "ID", + "name": "View", "in": "query", - "description": "escape OData quotes", "required": false, "schema": { "type": "string" @@ -46203,6 +47443,9 @@ "type": "object", "description": "Derived from the fields written into the storage table it reads, and the fields the endpoint selects onto each record, and the columns the CIPP UI renders. Fields taken from the storage writers may be omitted by this endpoint, and the response may carry computed fields not listed here.", "properties": { + "corrupt": { + "x-cipp-field-source": "backend" + }, "description": { "x-cipp-field-source": "backend,frontend" }, @@ -46213,7 +47456,7 @@ "type": "string", "x-cipp-field-source": "storage" }, - "GUID": { + "guid": { "x-cipp-field-source": "storage,backend" }, "isSynced": { @@ -46222,8 +47465,11 @@ "JSON": { "x-cipp-field-source": "storage" }, - "Package": { - "x-cipp-field-source": "storage,frontend" + "label": { + "x-cipp-field-source": "backend" + }, + "package": { + "x-cipp-field-source": "storage,backend,frontend" }, "PartitionKey": { "x-cipp-field-source": "storage" @@ -46232,27 +47478,39 @@ "type": "string", "x-cipp-field-source": "storage" }, - "RawJSON": { - "x-cipp-field-source": "backend" - }, "RowKey": { "x-cipp-field-source": "storage" }, "SHA": { "x-cipp-field-source": "storage" }, - "Source": { - "x-cipp-field-source": "storage" + "source": { + "x-cipp-field-source": "storage,backend" + }, + "templateCount": { + "x-cipp-field-source": "backend" }, "TemplateName": { "x-cipp-field-source": "storage" }, + "templates": { + "x-cipp-field-source": "backend" + }, "Timestamp": { "type": "string", "x-cipp-field-source": "storage" }, + "Type": { + "x-cipp-field-source": "backend,frontend" + }, "UpdatedBy": { "x-cipp-field-source": "storage" + }, + "usage": { + "x-cipp-field-source": "frontend" + }, + "value": { + "x-cipp-field-source": "backend" } } } @@ -46276,28 +47534,14 @@ "x-cipp-any-tenant": true } }, - "/api/ListIntuneScript": { + "/api/ListIPWhitelist": { "get": { - "summary": "ListIntuneScript", - "operationId": "ListIntuneScript", + "summary": "ListIPWhitelist", + "operationId": "ListIPWhitelist", "tags": [ - "Endpoint > MEM" - ], - "description": "Lists Intune device management scripts (Windows, macOS, Linux, and remediation scripts) for a tenant. Supports UseReportDB=true query parameter to retrieve cached data from the reporting database for significantly better performance, especially when querying AllTenants.", - "parameters": [ - { - "$ref": "#/components/parameters/tenantFilter" - }, - { - "name": "UseReportDB", - "in": "query", - "description": "Serve from the reporting database cache instead of live Graph. Much faster, especially for AllTenants.", - "required": false, - "schema": { - "type": "boolean" - } - } + "CIPP > Settings" ], + "description": "Lists trusted IP addresses configured in CIPP for IP-based access control.", "responses": { "200": { "description": "Success", @@ -46307,30 +47551,7 @@ "type": "array", "items": { "type": "object", - "description": "Derived from the columns the CIPP UI renders. The response may carry more; these are the ones known to exist.", - "properties": { - "description": { - "x-cipp-field-source": "frontend" - }, - "displayName": { - "x-cipp-field-source": "frontend" - }, - "lastModifiedDateTime": { - "x-cipp-field-source": "frontend" - }, - "runAsAccount": { - "x-cipp-field-source": "frontend" - }, - "ScriptAssignment": { - "x-cipp-field-source": "frontend" - }, - "ScriptExclude": { - "x-cipp-field-source": "frontend" - }, - "scriptType": { - "x-cipp-field-source": "frontend" - } - } + "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." } } } @@ -46341,9 +47562,6 @@ }, "403": { "description": "Forbidden - caller lacks the required RBAC role" - }, - "500": { - "description": "Internal server error" } }, "security": [ @@ -46351,41 +47569,21 @@ "bearerAuth": [] } ], - "x-cipp-role": "Endpoint.MEM.Read" + "x-cipp-role": "CIPP.Core.Read", + "x-cipp-any-tenant": true } }, - "/api/ListIntuneTemplates": { + "/api/ListIRMConfiguration": { "get": { - "summary": "ListIntuneTemplates", - "operationId": "ListIntuneTemplates", + "summary": "ListIRMConfiguration", + "operationId": "ListIRMConfiguration", "tags": [ - "Endpoint > MEM" + "Email-Exchange > Tools" ], - "description": "Lists the saved Intune policy templates. On first call the templates shipped with CIPP are imported into the templates table. These are CIPP templates, not a tenant's deployed policies.", + "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": [ { - "name": "ID", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "mode", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "View", - "in": "query", - "required": false, - "schema": { - "type": "string" - } + "$ref": "#/components/parameters/tenantFilter" } ], "responses": { @@ -46397,75 +47595,33 @@ "type": "array", "items": { "type": "object", - "description": "Derived from the fields written into the storage table it reads, and the fields the endpoint selects onto each record, and the columns the CIPP UI renders. Fields taken from the storage writers may be omitted by this endpoint, and the response may carry computed fields not listed here.", + "description": "Derived from the fields the endpoint selects onto each record. The response may carry more; these are the ones known to exist.", "properties": { - "corrupt": { + "AdRmsDetected": { "x-cipp-field-source": "backend" }, - "description": { - "x-cipp-field-source": "backend,frontend" - }, - "displayName": { - "x-cipp-field-source": "backend,frontend" - }, - "ETag": { - "type": "string", - "x-cipp-field-source": "storage" - }, - "guid": { - "x-cipp-field-source": "storage,backend" - }, - "isSynced": { - "x-cipp-field-source": "backend,frontend" - }, - "JSON": { - "x-cipp-field-source": "storage" - }, - "label": { + "AzureRMSLicensingEnabled": { "x-cipp-field-source": "backend" }, - "package": { - "x-cipp-field-source": "storage,backend,frontend" - }, - "PartitionKey": { - "x-cipp-field-source": "storage" - }, - "Permissions": { - "type": "string", - "x-cipp-field-source": "storage" - }, - "RowKey": { - "x-cipp-field-source": "storage" - }, - "SHA": { - "x-cipp-field-source": "storage" - }, - "source": { - "x-cipp-field-source": "storage,backend" - }, - "templateCount": { + "ExternalLicensingEnabled": { "x-cipp-field-source": "backend" }, - "TemplateName": { - "x-cipp-field-source": "storage" - }, - "templates": { + "InternalLicensingEnabled": { "x-cipp-field-source": "backend" }, - "Timestamp": { - "type": "string", - "x-cipp-field-source": "storage" + "JournalReportDecryptionEnabled": { + "x-cipp-field-source": "backend" }, - "Type": { - "x-cipp-field-source": "backend,frontend" + "LicensingLocation": { + "x-cipp-field-source": "backend" }, - "UpdatedBy": { - "x-cipp-field-source": "storage" + "MessageEncryptionEnabled": { + "x-cipp-field-source": "backend" }, - "usage": { - "x-cipp-field-source": "frontend" + "SimplifiedClientAccessEnabled": { + "x-cipp-field-source": "backend" }, - "value": { + "TransportDecryptionSetting": { "x-cipp-field-source": "backend" } } @@ -46479,45 +47635,9 @@ }, "403": { "description": "Forbidden - caller lacks the required RBAC role" - } - }, - "security": [ - { - "bearerAuth": [] - } - ], - "x-cipp-role": "Endpoint.MEM.Read", - "x-cipp-any-tenant": true - } - }, - "/api/ListIPWhitelist": { - "get": { - "summary": "ListIPWhitelist", - "operationId": "ListIPWhitelist", - "tags": [ - "CIPP > Settings" - ], - "description": "Lists trusted IP addresses configured in CIPP for IP-based access control.", - "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" + "500": { + "description": "Internal server error" } }, "security": [ @@ -46525,8 +47645,7 @@ "bearerAuth": [] } ], - "x-cipp-role": "CIPP.Core.Read", - "x-cipp-any-tenant": true + "x-cipp-role": "Exchange.Mailbox.Read" } }, "/api/ListJITAdmin": { @@ -47810,6 +48929,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", @@ -47846,13 +48974,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": { @@ -47865,6 +48996,9 @@ "RecipientAddress": { "x-cipp-field-source": "frontend" }, + "ReleasedUser": { + "x-cipp-field-source": "frontend" + }, "ReleaseStatus": { "x-cipp-field-source": "frontend" }, @@ -47964,6 +49098,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", @@ -53130,6 +54436,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", @@ -59045,7 +60479,7 @@ "bearerAuth": [] } ], - "x-cipp-role": "Tenant.Standards.ReadWrite" + "x-cipp-role": "Tenant.Baselines.ReadWrite" } }, "/api/RemoveBPATemplate": { diff --git a/Config/standards.json b/Config/standards.json index 62a8932f623d6..d4a68dc0e98c7 100644 --- a/Config/standards.json +++ b/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" }, @@ -1353,14 +1393,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", @@ -1387,7 +1435,8 @@ "name": "standards.NudgeMFA.state", "options": [ { "label": "Enabled", "value": "enabled" }, - { "label": "Disabled", "value": "disabled" } + { "label": "Disabled", "value": "disabled" }, + { "label": "Microsoft managed", "value": "default" } ] }, { @@ -3429,21 +3478,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", @@ -6021,6 +6087,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 +6121,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, @@ -6449,8 +6535,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" + } ] } ], @@ -6461,6 +6549,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", @@ -6937,7 +7087,8 @@ "label": "Select Sensitivity Label Templates", "api": { "url": "/api/ListSensitivityLabelTemplates", - "labelField": "name", + "labelField": "DisplayName", + "altLabelField": "Name", "valueField": "GUID", "queryKey": "ListSensitivityLabelTemplates" } @@ -7653,6 +7804,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/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Baselines/Push-CIPPBaselineStandard.ps1 b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Baselines/Push-CIPPBaselineStandard.ps1 index 59f6f3efaa095..a88d744c8fa5d 100644 --- a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Baselines/Push-CIPPBaselineStandard.ps1 +++ b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Baselines/Push-CIPPBaselineStandard.ps1 @@ -18,10 +18,65 @@ function Push-CIPPBaselineStandard { Set-CippBaselineRunContext -RunId $Item.RunId $Result = Invoke-CIPPBaselineStandard -Item $Item.Item -Mode ($Item.Mode ?? 'run') -TriggeredBy ($Item.TriggeredBy ?? 'schedule') -Force:([bool]$Item.Force) -RunId $Item.RunId if ($Result.Remediated -and $Result.CacheType) { - return [PSCustomObject]@{ - TenantFilter = "$($Item.Item.TenantFilter)" - CacheType = "$($Result.CacheType)" + if ("$($Item.Mode)" -eq 'oneoff') { + # A one-off is an operator pressing Fix and looking at the row - they + # re-compare within seconds, and the deferred PostExec refresh (run + # finalization lags a scheduler tick) hands that compare the stale + # pre-remediation cache: fixed drift re-detects as still broken. One + # standard means at most a couple of caches, so refresh them inline and + # return no impact records for PostExec to repeat. + $RefreshCaches = { + foreach ($CacheType in @($Result.CacheType | Where-Object { $_ })) { + $Collector = "Set-CIPPDBCache$CacheType" + try { + if (Get-Command -Name $Collector -ErrorAction SilentlyContinue) { + $null = & $Collector -TenantFilter $Item.Item.TenantFilter + } + } catch { + Write-Information "Baselines: inline cache refresh $CacheType for $($Item.Item.TenantFilter) failed: $($_.Exception.Message)" + } + } + } + & $RefreshCaches + # Lag-prone Graph surfaces (organization, admin/sharepoint/settings) can + # hand that refresh PRE-write state, and a present-but-stale cache never + # re-collects - collection only fires on a miss, so the fixed drift would + # re-detect until the next scheduled collection. Verify the refreshed + # cache actually grades clean; if not, give propagation a moment and + # collect ONCE more. Still drifted after that is worth a warning, not a + # loop: either the write truly failed or the API lags beyond reason, and + # the scheduled collection remains the backstop. + try { + $Verify = @{ Item = $Item.Item; Mode = 'oneoff'; TriggeredBy = ($Item.TriggeredBy ?? 'schedule'); RunId = $Item.RunId } + $Verdict = Invoke-CIPPBaselineStandard @Verify -GradeOnly + if ($Verdict -and -not $Verdict.Compliant) { + # Two growing backoffs: EXO and Graph reads routinely lag writes + # beyond 10s on production tenants, and a cache captured mid-lag + # stays poisoned until the scheduled collection. + foreach ($BackoffSeconds in @(10, 30)) { + Start-Sleep -Seconds $BackoffSeconds + & $RefreshCaches + $Verdict = Invoke-CIPPBaselineStandard @Verify -GradeOnly + if (-not ($Verdict -and -not $Verdict.Compliant)) { break } + } + if ($Verdict -and -not $Verdict.Compliant) { + Write-LogMessage -API 'Baselines' -tenant $Item.Item.TenantFilter -message "The refreshed cache still grades `"$($Item.Item.Standard)`" as drifted after remediation - either the write did not take effect or the API is lagging beyond the retry window; the next compare may re-report drift until the scheduled collection." -Sev 'Warning' + } + } + } catch { + Write-Information "Baselines: post-remediation cache verification for $($Item.Item.Standard) failed: $($_.Exception.Message)" + } + return } + # One record per declared cache - the refresh dedupes across standards, so a + # run that remediates ten Exchange standards still collects each type once. + foreach ($CacheType in @($Result.CacheType | Where-Object { $_ })) { + [PSCustomObject]@{ + TenantFilter = "$($Item.Item.TenantFilter)" + CacheType = "$CacheType" + } + } + return } } catch { Write-LogMessage -API 'Baselines' -tenant $Item.Item.TenantFilter -message "Baseline activity failed for $($Item.Item.Standard): $($_.Exception.Message)" -Sev 'Error' diff --git a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Graph Requests/Push-ListGraphRequestQueue.ps1 b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Graph Requests/Push-ListGraphRequestQueue.ps1 index f7792a14f41d4..acaf391492cbb 100644 --- a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Graph Requests/Push-ListGraphRequestQueue.ps1 +++ b/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/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-ListMailQuarantineAllTenants.ps1 b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-ListMailQuarantineAllTenants.ps1 index 515d2e0dd00b7..dbd3138340c9f 100644 --- a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-ListMailQuarantineAllTenants.ps1 +++ b/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/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-UpdatePermissionsQueue.ps1 b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-UpdatePermissionsQueue.ps1 index d0318bbdde5e7..f5cc448159b82 100644 --- a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-UpdatePermissionsQueue.ps1 +++ b/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/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/SharePoint Sharing/Push-DBCacheSharePointSiteSharingLinks.ps1 b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/SharePoint Sharing/Push-DBCacheSharePointSiteSharingLinks.ps1 index d77f6d51190cb..3423adf56dfaa 100644 --- a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/SharePoint Sharing/Push-DBCacheSharePointSiteSharingLinks.ps1 +++ b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/SharePoint Sharing/Push-DBCacheSharePointSiteSharingLinks.ps1 @@ -1,42 +1,58 @@ 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. 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. + + 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 +71,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 +99,81 @@ 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. - function Add-CIPPSharingRows { - param($Buffer, $Drive, $Site, $InternalDomains, $TenantFilter, $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 + } - if (@($Buffer).Count -eq 0) { return } + $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. + # 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, [ref]$DropCounter) + if (@($Buffer).Count -eq 0) { return } $ItemByRequestId = @{} $RequestId = 0 $PermissionRequests = foreach ($SharedItem in $Buffer) { @@ -96,97 +185,186 @@ 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 - }) + 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 } } # --- 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 { + 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 @() + } + + # 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" + $RequeueCount = [int]($Item.RequeueCount ?? 0) + $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 +374,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 +399,213 @@ 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 } + # 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 } + } - $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 - } + # 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 + }) } - # 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 + Invoke-DriveRequeue + 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 @() + # 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 + } - $SiteContext = [PSCustomObject]@{ - SiteId = $SiteId - SiteName = $SiteName - SiteUrl = $SiteUrl - IsPersonalSite = $IsPersonalSite - } + $DeltaSelect = 'id,name,webUrl,folder,shared,deleted,size,lastModifiedDateTime' + $FullDeltaUri = "https://graph.microsoft.com/beta/drives/$($Drive.id)/root/delta?`$select=$DeltaSelect&`$top=999" - # 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 + # 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 = 'Full' + $Uri = $null + if ($Checkpoint -and $Checkpoint.CurrentUri -and [string]$Checkpoint.CurrentMode -in @('Full', 'Incremental')) { + $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 } + } - # 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) - } + # ---------------- 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 - } - - $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) } - } - - # 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 + $DeltaLink = $null + $DriveFailed = $false + $DroppedReads = 0 + 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 } - if ($PageRows.Count -gt 0) { - Add-CIPPDbItem -TenantFilter $TenantFilter -Type $CacheType -Data @($PageRows) -Append -RunId $ScanId + 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 @() } - - if ($Page.'@odata.deltaLink') { - $DeltaLink = [string]$Page.'@odata.deltaLink' - $Uri = $null - } else { - $Uri = [string]$Page.'@odata.nextLink' + 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 + } - # 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 -DropCounter ([ref]$DroppedReads) + + 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 + } 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 + # 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/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/SharePoint Sharing/Push-StoreSharePointSharingLinks.ps1 b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/SharePoint Sharing/Push-StoreSharePointSharingLinks.ps1 index 9e7f23996501f..a7448c6e4b78f 100644 --- a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/SharePoint Sharing/Push-StoreSharePointSharingLinks.ps1 +++ b/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/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Webhooks/Push-AuditLogProcessingBatch.ps1 b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Webhooks/Push-AuditLogProcessingBatch.ps1 index da1a92b168ea2..afd8378e92e17 100644 --- a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Webhooks/Push-AuditLogProcessingBatch.ps1 +++ b/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/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Webhooks/Push-AuditLogTenantProcess.ps1 b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Webhooks/Push-AuditLogTenantProcess.ps1 index f52eef63271f5..5037fb69275a2 100644 --- a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Webhooks/Push-AuditLogTenantProcess.ps1 +++ b/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/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertSharepointQuota.ps1 b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertSharepointQuota.ps1 index 7c8efa8cdf766..3e0f6cd93532e 100644 --- a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertSharepointQuota.ps1 +++ b/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/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertUnlicensedOneDriveData.ps1 b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertUnlicensedOneDriveData.ps1 new file mode 100644 index 0000000000000..aee0951583e30 --- /dev/null +++ b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertUnlicensedOneDriveData.ps1 @@ -0,0 +1,176 @@ +function Get-CIPPAlertUnlicensedOneDriveData { + <# + .FUNCTIONALITY + Entrypoint + #> + [CmdletBinding()] + param ( + [Parameter(Mandatory = $false)] + [Alias('input')] + $InputValue, + $TenantFilter + ) + + $HasSharePoint = Test-CIPPStandardLicense -StandardName 'UnlicensedOneDriveData' -TenantFilter $TenantFilter -Preset SharePoint + if (-not $HasSharePoint) { + return + } + + if ($InputValue -is [string]) { + try { + if ($InputValue.Trim().StartsWith('{')) { + $InputValue = $InputValue | ConvertFrom-Json -ErrorAction Stop + } + } catch { + # Leave as-is if parsing fails + } + } + + $DaysThreshold = 30 + $IncludeSharedMailboxes = $false + if ($InputValue -is [hashtable] -or $InputValue -is [PSCustomObject]) { + $DaysRaw = $InputValue.UnlicensedOneDriveData + if ($null -ne $DaysRaw -and "$DaysRaw" -ne '') { + $ParsedDays = 0 + if ([int]::TryParse("$DaysRaw", [ref]$ParsedDays) -and $ParsedDays -ge 1) { + $DaysThreshold = $ParsedDays + } + } + if ($null -ne $InputValue.IncludeSharedMailboxes) { + $IncludeSharedMailboxes = [bool]$InputValue.IncludeSharedMailboxes + } + } elseif ($null -ne $InputValue -and "$InputValue" -ne '') { + $ParsedDays = 0 + if ([int]::TryParse("$InputValue", [ref]$ParsedDays) -and $ParsedDays -ge 1) { + $DaysThreshold = $ParsedDays + } + } + + try { + $SharePointInfo = Get-SharePointAdminLink -Public $false -tenantFilter $TenantFilter + $AdminUrl = $SharePointInfo.AdminUrl.TrimEnd('/') + $extraHeaders = @{ + 'Accept' = 'application/json' + } + $Billing = New-GraphGetRequest -extraHeaders $extraHeaders -scope "$AdminUrl/.default" -tenantid $TenantFilter -uri "$AdminUrl/_api/SPOInternalUseOnly.Tenant/?`$select=UnlicensedOdbSyntexBillingEnabled,UnlicensedOdbStorageBillingMode" -AsApp $true -UseCertificate + } catch { + return + } + + if ($Billing.UnlicensedOdbSyntexBillingEnabled -eq $true) { + return + } + + $ViewXml = @' +021TEAMCHANNEL#0TEAMCHANNEL#1200 +'@ + + try { + $Rows = @(Get-CIPPSPOAdminListData -TenantFilter $TenantFilter -AdminUrl $AdminUrl -ListName 'DO_NOT_DELETE_SPLIST_TENANTADMIN_ALL_SITES_AGGREGATED_SITECOLLECTIONS' -ViewXml $ViewXml) + } catch { + return + } + + $SharedLookupOk = $false + $SharedUpns = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) + try { + $SharedMailboxes = @(New-GraphGetRequest -uri "https://outlook.office365.com/adminapi/beta/$TenantFilter/Mailbox?`$filter=RecipientTypeDetails eq 'SharedMailbox'" -Tenantid $TenantFilter -scope ExchangeOnline) + foreach ($Mailbox in $SharedMailboxes) { + $SharedUpn = [string]$Mailbox.UserPrincipalName + if ([string]::IsNullOrWhiteSpace($SharedUpn)) { + $SharedUpn = [string]$Mailbox.userPrincipalName + } + if (-not [string]::IsNullOrWhiteSpace($SharedUpn)) { + [void]$SharedUpns.Add($SharedUpn) + } + } + $SharedLookupOk = $true + } catch { + $SharedLookupOk = $false + } + + $Today = (Get-Date).Date + $AlertData = foreach ($Row in $Rows) { + $ReasonInt = 0 + if (-not [int]::TryParse("$($Row.UnlicensedOdbReason)", [ref]$ReasonInt)) { + $ReasonInt = -1 + } + if ($ReasonInt -eq 2) { + continue + } + + $StartRaw = [string]$Row.UnlicensedOdbStartDate + if ([string]::IsNullOrWhiteSpace($StartRaw)) { + continue + } + $StartDate = [datetime]::MinValue + if (-not [datetime]::TryParse($StartRaw, [ref]$StartDate)) { + continue + } + + $EstimatedDeletionOn = $StartDate.AddDays(365).Date + $DaysUntilDeletion = [int][math]::Floor(($EstimatedDeletionOn - $Today).TotalDays) + if ($DaysUntilDeletion -lt 0 -or $DaysUntilDeletion -gt $DaysThreshold) { + continue + } + + $Upn = [string]$Row.UnlicensedOdbProvisionedForUPN + if ([string]::IsNullOrWhiteSpace($Upn)) { + $Upn = [string]$Row.SiteOwnerEmail + } + + $IsSharedMailbox = $false + if ($SharedLookupOk -and -not [string]::IsNullOrWhiteSpace($Upn)) { + $IsSharedMailbox = $SharedUpns.Contains($Upn) + } + if (-not $IncludeSharedMailboxes -and $IsSharedMailbox) { + continue + } + + $StorageUsedGB = $null + $StorageBytes = 0.0 + $ParsedStorage = $false + if ($Row.PSObject.Properties.Name -contains 'StorageUsed.') { + $ParsedStorage = [double]::TryParse("$($Row.'StorageUsed.')", [ref]$StorageBytes) + } + if (-not $ParsedStorage -and $Row.StorageUsed) { + $UsedRaw = "$($Row.StorageUsed)" -replace '[^\d.]', '' + $ParsedStorage = [double]::TryParse($UsedRaw, [ref]$StorageBytes) + } + if ($ParsedStorage) { + $StorageUsedGB = [math]::Round($StorageBytes / 1GB, 2) + } + + $Title = [string]$Row.Title + if ([string]::IsNullOrWhiteSpace($Title)) { + $Title = $Upn + } + $Identity = if (-not [string]::IsNullOrWhiteSpace($Upn)) { "$Title ($Upn)" } else { $Title } + $SharedNote = if ($SharedLookupOk -and $IsSharedMailbox) { ' Shared mailbox.' } else { '' } + $Message = "$Identity unlicensed OneDrive data estimated to be deleted in $DaysUntilDeletion days (unlicensed since $($StartDate.ToString('yyyy-MM-dd'))).$SharedNote" + + $Item = [PSCustomObject]@{ + Message = $Message + UnlicensedOdbProvisionedForUPN = [string]$Row.UnlicensedOdbProvisionedForUPN + SiteOwnerEmail = [string]$Row.SiteOwnerEmail + Title = $Title + SiteUrl = [string]$Row.SiteUrl + UnlicensedOdbStartDate = $StartDate + EstimatedDeletionOn = $EstimatedDeletionOn + DaysUntilDeletion = $DaysUntilDeletion + ArchiveStatus = [string]$Row.ArchiveStatus + StorageUsedGB = $StorageUsedGB + UnlicensedOdbReason = $Row.UnlicensedOdbReason + UnlicensedOdbCleanupBlockReason = $Row.UnlicensedOdbCleanupBlockReason + Tenant = $TenantFilter + } + if ($SharedLookupOk) { + $Item | Add-Member -NotePropertyName 'IsSharedMailbox' -NotePropertyValue $IsSharedMailbox + } + $Item + } + + if ($AlertData) { + Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData + } +} diff --git a/Modules/CIPPCore/Public/Add-CIPPGroupMember.ps1 b/Modules/CIPPCore/Public/Add-CIPPGroupMember.ps1 index 18a90974b6aec..2e32a9711802b 100644 --- a/Modules/CIPPCore/Public/Add-CIPPGroupMember.ps1 +++ b/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/Modules/CIPPCore/Public/Add-CIPPGroupOwner.ps1 b/Modules/CIPPCore/Public/Add-CIPPGroupOwner.ps1 new file mode 100644 index 0000000000000..02cf330f9e4e6 --- /dev/null +++ b/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/Modules/CIPPCore/Public/Add-CIPPScheduledTask.ps1 b/Modules/CIPPCore/Public/Add-CIPPScheduledTask.ps1 index 89a9ffc0cdee9..1eaaa5f840663 100644 --- a/Modules/CIPPCore/Public/Add-CIPPScheduledTask.ps1 +++ b/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/Modules/CIPPCore/Public/AuditLogs/Get-CippAuditLogPlannedWindows.ps1 b/Modules/CIPPCore/Public/AuditLogs/Get-CippAuditLogPlannedWindows.ps1 index a33fffb19acdb..bf90ef5889415 100644 --- a/Modules/CIPPCore/Public/AuditLogs/Get-CippAuditLogPlannedWindows.ps1 +++ b/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/Modules/CIPPCore/Public/Authentication/Clear-CippAccessUserCache.ps1 b/Modules/CIPPCore/Public/Authentication/Clear-CippAccessUserCache.ps1 new file mode 100644 index 0000000000000..0af199897b3a5 --- /dev/null +++ b/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/Modules/CIPPCore/Public/Authentication/ConvertTo-CippPermissionRules.ps1 b/Modules/CIPPCore/Public/Authentication/ConvertTo-CippPermissionRules.ps1 new file mode 100644 index 0000000000000..aacb9a08a3af4 --- /dev/null +++ b/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/Modules/CIPPCore/Public/Authentication/Get-CIPPAccessRole.ps1 b/Modules/CIPPCore/Public/Authentication/Get-CIPPAccessRole.ps1 index 799aae7c44666..ca422b372cef2 100644 --- a/Modules/CIPPCore/Public/Authentication/Get-CIPPAccessRole.ps1 +++ b/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,20 @@ function Get-CIPPAccessRole { $CombinedRoles = $AllRoles | Select-Object -Unique # For debugging - Write-Information "Combined Roles: $($CombinedRoles -join ', ')" + 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/Modules/CIPPCore/Public/Authentication/Get-CIPPRolePermissions.ps1 b/Modules/CIPPCore/Public/Authentication/Get-CIPPRolePermissions.ps1 index 37f446347aaa1..c06f70df5c284 100644 --- a/Modules/CIPPCore/Public/Authentication/Get-CIPPRolePermissions.ps1 +++ b/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,69 @@ 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 + # 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 -or ($ReadCounterpart -and $ReadCounterpart -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/Modules/CIPPCore/Public/Authentication/Get-CippRequestContext.ps1 b/Modules/CIPPCore/Public/Authentication/Get-CippRequestContext.ps1 index a9df439f94fa2..c4fa730a63bf7 100644 --- a/Modules/CIPPCore/Public/Authentication/Get-CippRequestContext.ps1 +++ b/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/Modules/CIPPCore/Public/Authentication/Resolve-CippImpersonation.ps1 b/Modules/CIPPCore/Public/Authentication/Resolve-CippImpersonation.ps1 new file mode 100644 index 0000000000000..e544deab23cdb --- /dev/null +++ b/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/Modules/CIPPCore/Public/Authentication/Select-CippAllowedTenantData.ps1 b/Modules/CIPPCore/Public/Authentication/Select-CippAllowedTenantData.ps1 index c31a698162d39..f159ce5cd349f 100644 --- a/Modules/CIPPCore/Public/Authentication/Select-CippAllowedTenantData.ps1 +++ b/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/Modules/CIPPCore/Public/Authentication/Set-CIPPAccessRole.ps1 b/Modules/CIPPCore/Public/Authentication/Set-CIPPAccessRole.ps1 index 9bfa11f84ff5b..623a318b472c8 100644 --- a/Modules/CIPPCore/Public/Authentication/Set-CIPPAccessRole.ps1 +++ b/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/Modules/CIPPCore/Public/Authentication/Test-CIPPAccess.ps1 b/Modules/CIPPCore/Public/Authentication/Test-CIPPAccess.ps1 index c85843371a0eb..890251be393d2 100644 --- a/Modules/CIPPCore/Public/Authentication/Test-CIPPAccess.ps1 +++ b/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,11 @@ function Test-CIPPAccess { if (-not $User.userRoles) { throw 'Access denied: unable to resolve roles for the authenticated principal' } + + # IP enforcement deliberately uses the REAL roles, never the impersonated one: a + # role's IP allowlist describes where its actual members sign in from, and + # simulating it locks the impersonating superadmin out of the entire UI, /me and + # the exit banner included. $AllowedIPRanges = Get-CIPPRoleIPRanges -Roles $User.userRoles if ($AllowedIPRanges -notcontains 'Any') { @@ -189,6 +209,20 @@ function Test-CIPPAccess { $swIPCheck.Stop() $AccessTimings['IPRangeCheck'] = $swIPCheck.Elapsed.TotalMilliseconds + # Superadmin-only role impersonation: everything downstream (/me permissions, + # base/custom role checks, tenant scoping) evaluates under the impersonated role. + # Only the IP check above is exempt, so impersonation can never lock the UI. + $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') }) + } + if ($Request.Params.CIPPEndpoint -eq 'me') { if (!$User.userRoles) { @@ -224,6 +258,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 +374,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/Modules/CIPPCore/Public/Authentication/Test-CIPPAccessUserRole.ps1 b/Modules/CIPPCore/Public/Authentication/Test-CIPPAccessUserRole.ps1 index ca42c57fa9794..831fe3d26ca5a 100644 --- a/Modules/CIPPCore/Public/Authentication/Test-CIPPAccessUserRole.ps1 +++ b/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 ', ')" diff --git a/Modules/CIPPCore/Public/Authentication/Test-CippApiClientRoleGrant.ps1 b/Modules/CIPPCore/Public/Authentication/Test-CippApiClientRoleGrant.ps1 index eeb49beeb8687..9468da344fb51 100644 --- a/Modules/CIPPCore/Public/Authentication/Test-CippApiClientRoleGrant.ps1 +++ b/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 { diff --git a/Modules/CIPPCore/Public/Baselines/Convert-CIPPBaselineResolvedEntity.ps1 b/Modules/CIPPCore/Public/Baselines/Convert-CIPPBaselineResolvedEntity.ps1 index a115b16963b34..a08a3d7e34ade 100644 --- a/Modules/CIPPCore/Public/Baselines/Convert-CIPPBaselineResolvedEntity.ps1 +++ b/Modules/CIPPCore/Public/Baselines/Convert-CIPPBaselineResolvedEntity.ps1 @@ -28,15 +28,29 @@ function Convert-CIPPBaselineResolvedEntity { $Definition = $Definitions | Where-Object { $_.name -eq $BaseName } | Select-Object -First 1 # Multi-instance standards all share one definition label - the row label carries the # instance's identity so ten instances are ten distinguishable rows: the task name for - # manual tasks, the deployed template's displayName for identity-carrying standards - # (CA/Intune templates - their normalized ExpectedValue is the full policy). + # manual tasks, the deployed template's displayName for identity-carrying standards. + # CA/Intune templates surface it in their normalized ExpectedValue (the full policy); + # families whose graded Expected is presence-shaped carry no name there, so the + # identity variable is read from the effective Inheritance entry instead - that is + # the configured template reference itself. $Manual = & $ParseJson $Entity.Manual $ExpectedParsed = & $ParseJson $Entity.ExpectedValue $IdentitySuffix = if ($Manual.taskName) { $Manual.taskName } - elseif ($Definition.instanceIdentity) { $ExpectedParsed.displayName ?? $ExpectedParsed.name ?? $ExpectedParsed.$($Definition.instanceIdentity) } + elseif ($Definition.instanceIdentity) { + $FromVariables = if ($Entity.Inheritance) { + $Effective = @(& $ParseJson $Entity.Inheritance) | Where-Object { $_.effective } | Select-Object -First 1 + $Value = $Effective.value.$($Definition.instanceIdentity) + # .value ?? works on option objects AND hashtable-shaped rows alike; plain + # strings have no .value and fall through. + $Value.value ?? $Value + } + $ExpectedParsed.displayName ?? $ExpectedParsed.name ?? $FromVariables + } if ($IdentitySuffix -and $Definition.instanceIdentity -and $ResolveTemplateName) { # A real display name misses the lookup and stays as-is; a raw template id resolves. - $IdentitySuffix = (& $ResolveTemplateName "$($Definition.remediate.executor)" "$IdentitySuffix") ?? $IdentitySuffix + $Partition = "$($Definition.identity.partition ?? $Definition.remediate.executor)" + $NameField = "$($Definition.identity.nameField ?? 'displayName')" + $IdentitySuffix = (& $ResolveTemplateName $Partition "$IdentitySuffix" $NameField) ?? $IdentitySuffix } $Label = if ($IdentitySuffix) { '{0} - {1}' -f ($Definition.label ?? $BaseName), $IdentitySuffix } else { $Definition.label ?? $StandardName } diff --git a/Modules/CIPPCore/Public/Baselines/Export-CIPPBaselineTemplate.ps1 b/Modules/CIPPCore/Public/Baselines/Export-CIPPBaselineTemplate.ps1 index 6ae58623e217e..5717ff6a15fe9 100644 --- a/Modules/CIPPCore/Public/Baselines/Export-CIPPBaselineTemplate.ps1 +++ b/Modules/CIPPCore/Public/Baselines/Export-CIPPBaselineTemplate.ps1 @@ -92,9 +92,18 @@ function Export-CIPPBaselineTemplate { } continue } - if ($Definition.instanceIdentity -and "$($Definition.remediate.executor)" -in @('IntuneTemplate', 'CATemplate')) { + # Bundle the referenced template body when the instance identity IS a + # templates-table reference: declared via the identity block, or the + # CA/Intune convention (partition = executor). Identity-carrying standards + # whose identity is a plain name (no identity block) have nothing to bundle. + $IdentityPartition = if ($Definition.identity.partition) { + "$($Definition.identity.partition)" + } elseif ("$($Definition.remediate.executor)" -in @('IntuneTemplate', 'CATemplate')) { + "$($Definition.remediate.executor)" + } + if ($Definition.instanceIdentity -and $IdentityPartition) { $TemplateRef = "$(& $Unwrap $Config.variables.$($Definition.instanceIdentity))" - if (-not $TemplateRef -or -not (& $CollectTemplate "$($Definition.remediate.executor)" $TemplateRef)) { + if (-not $TemplateRef -or -not (& $CollectTemplate $IdentityPartition $TemplateRef)) { Write-Information "Export-CIPPBaselineTemplate: $($Config.instance) references template '$TemplateRef' which no longer exists - skipped from the export." continue } diff --git a/Modules/CIPPCore/Public/Baselines/Get-CIPPBaseline.ps1 b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaseline.ps1 index cbaae9d33a6c7..a6e74ee66e8db 100644 --- a/Modules/CIPPCore/Public/Baselines/Get-CIPPBaseline.ps1 +++ b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaseline.ps1 @@ -37,28 +37,38 @@ function Get-CIPPBaseline { # Identity-carrying standards (CA/Intune templates) store the raw template id in # their variables; the editor's pickers and instance titles need the template's - # display name. Resolve lazily from the template store (partition = the remediate - # executor name) and hand the variable back as a {label, value} option object - - # the editor consumes it verbatim and unwraps back to the raw id on save. + # display name. Resolve lazily from the template store and hand the variable back + # as a {label, value} option object - the editor consumes it verbatim and unwraps + # back to the raw id on save. The definition's optional identity block names the + # partition and name field; the defaults (partition = the remediate executor name, + # name field = displayName) are what CA and Intune templates use, but the wider + # template families store rows under partitions that do NOT match their executor + # ('TransportTemplate', 'ExConnectorTemplate', ...) and name them 'name'/'Name'. $IdentityDefinitions = @{} if ($ResolveIdentityLabels) { foreach ($Definition in @(Get-CIPPBaselineDefinition)) { if ($Definition.instanceIdentity) { - $IdentityDefinitions[$Definition.name] = @{ Variable = $Definition.instanceIdentity; Partition = "$($Definition.remediate.executor)" } + $IdentityDefinitions[$Definition.name] = @{ + Variable = $Definition.instanceIdentity + Partition = "$($Definition.identity.partition ?? $Definition.remediate.executor)" + NameField = "$($Definition.identity.nameField ?? 'displayName')" + } } } } $TemplateNameMaps = @{} $ResolveTemplateName = { - param($Partition, $Id) + param($Partition, $Id, $NameField) if (-not $Partition -or -not $Id) { return $null } - if (-not $TemplateNameMaps.ContainsKey($Partition)) { + if ([string]::IsNullOrWhiteSpace($NameField)) { $NameField = 'displayName' } + $MapKey = "$Partition|$NameField" + if (-not $TemplateNameMaps.ContainsKey($MapKey)) { $Map = @{} try { $TemplatesTable = Get-CippTable -tablename 'templates' $SafePartition = ConvertTo-CIPPODataFilterValue -Value $Partition foreach ($TemplateRow in @(Get-CIPPAzDataTableEntity @TemplatesTable -Filter "PartitionKey eq '$SafePartition'")) { - $TemplateName = $(try { ($TemplateRow.JSON | ConvertFrom-Json).displayName } catch { $null }) + $TemplateName = $(try { ($TemplateRow.JSON | ConvertFrom-Json).$NameField } catch { $null }) if ($TemplateName) { $Map["$($TemplateRow.RowKey)"] = $TemplateName if ($TemplateRow.GUID) { $Map["$($TemplateRow.GUID)"] = $TemplateName } @@ -67,9 +77,9 @@ function Get-CIPPBaseline { } catch { Write-Information "Get-CIPPBaseline: template name lookup for $Partition failed: $($_.Exception.Message)" } - $TemplateNameMaps[$Partition] = $Map + $TemplateNameMaps[$MapKey] = $Map } - $TemplateNameMaps[$Partition]["$Id"] + $TemplateNameMaps[$MapKey]["$Id"] } $EnrichIdentityVariable = { param($InstanceKey, $Variables) @@ -77,9 +87,9 @@ function Get-CIPPBaseline { $Identity = $IdentityDefinitions[(($InstanceKey) -split '#')[0]] if ($Identity -and $Variables.PSObject.Properties[$Identity.Variable]) { $RawId = $Variables.$($Identity.Variable) - if ($RawId -is [System.Management.Automation.PSCustomObject]) { $RawId = $RawId.value } + $RawId = $RawId.value ?? $RawId if ($RawId) { - $Label = (& $ResolveTemplateName $Identity.Partition "$RawId") ?? "$RawId" + $Label = (& $ResolveTemplateName $Identity.Partition "$RawId" $Identity.NameField) ?? "$RawId" $Variables.$($Identity.Variable) = [PSCustomObject]@{ label = $Label; value = "$RawId" } } } @@ -202,10 +212,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 +234,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/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineActivityBasedTimeoutState.ps1 b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineActivityBasedTimeoutState.ps1 new file mode 100644 index 0000000000000..b866922ba21dd --- /dev/null +++ b/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/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineAddDKIMState.ps1 b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineAddDKIMState.ps1 new file mode 100644 index 0000000000000..71b49f5985fcf --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineAddDKIMState.ps1 @@ -0,0 +1,48 @@ +function Get-CIPPBaselineAddDKIMState { + <# + .SYNOPSIS + Prepare hook for AddDKIM: accepted domains without enabled DKIM signing. + .DESCRIPTION + Grades which accepted domains have no DKIM config at all plus which have one that is + disabled - the two lists the classic remediated differently (New- vs Set-), which is + why both are carried separately for the executor. + + The exclusion list is the classic's, shared with the domain analyser: service + domains (onmicrosoft, signature services, Teams SBCs) never get DKIM through + Exchange and would otherwise be permanent false drift. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Item, + $TenantFilter + ) + + $Domains = @(Get-CIPPBaselineCacheRows -TenantFilter $TenantFilter -Type 'ExoAcceptedDomains') + $DkimConfigs = @(Get-CIPPBaselineCacheRows -TenantFilter $TenantFilter -Type 'ExoDkimSigningConfig') + if ($Domains.Count -eq 0 -and -not (Test-CIPPBaselineCacheCollected -TenantFilter $TenantFilter -Type 'ExoAcceptedDomains')) { + return @{ Current = $null } + } + + $Exclusions = @('*.microsoftonline.com', '*.mail.onmicrosoft.com', '*.exclaimer.cloud', '*.excl.cloud', '*.codetwo.online', + '*.call2teams.com', '*.signature365.net', '*.myteamsconnect.io', '*.teams.dstny.com', '*.msteams.8x8.com', + '*.ucconnect.co.uk', '*.teams-sbc.dk') + $IsExcluded = { param($Name) foreach ($Pattern in $Exclusions) { if ($Name -like $Pattern) { return $true } } $false } + + $AllDomains = @($Domains | ForEach-Object { "$($_.DomainName)" } | Where-Object { $_ -and -not (& $IsExcluded $_) }) + $Dkim = @($DkimConfigs | Where-Object { "$($_.Domain)" -and -not (& $IsExcluded "$($_.Domain)") }) + + $NewDomains = @($AllDomains | Where-Object { @($Dkim.Domain) -notcontains $_ } | Sort-Object) + $SetDomains = @($Dkim | Where-Object { $AllDomains -contains "$($_.Domain)" -and $_.Enabled -eq $false } | ForEach-Object { "$($_.Domain)" } | Sort-Object) + + $Current = [PSCustomObject]@{ domainsWithoutDkim = @(@($NewDomains) + @($SetDomains) | Sort-Object) } + # Carried for the executor: absent configs are New-ed, disabled ones are Set-. + $Current | Add-Member -NotePropertyName 'domainsToCreate' -NotePropertyValue @($NewDomains) + $Current | Add-Member -NotePropertyName 'domainsToEnable' -NotePropertyValue @($SetDomains) + + @{ + Expected = [PSCustomObject]@{ domainsWithoutDkim = @() } + Current = $Current + } +} diff --git a/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineAlignment.ps1 b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineAlignment.ps1 index cbd8ba3dcb3f1..6e76a10c3ce29 100644 --- a/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineAlignment.ps1 +++ b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineAlignment.ps1 @@ -21,22 +21,26 @@ function Get-CIPPBaselineAlignment { $ResolvedTable = Get-CippTable -tablename 'BaselineAlignment' $Definitions = Get-CIPPBaselineDefinition - # Lazy template-name lookup for identity-carrying standards: the template store - # partition matches the remediate executor name (CATemplate, IntuneTemplate). - # Loaded once per partition, only when a row actually needs it. Rows written before - # the prepare ran (Conflict, license-skip) carry the raw template id as their - # displayName - every labeling path resolves it to the template's real name. + # Lazy template-name lookup for identity-carrying standards. The definition's + # optional identity block names the partition and name field; the defaults + # (partition = the remediate executor name, name field = displayName) are the + # CA/Intune convention. Loaded once per partition+field, only when a row actually + # needs it. Rows written before the prepare ran (Conflict, license-skip) carry the + # raw template id as their displayName - every labeling path resolves it to the + # template's real name. $TemplateNameMaps = @{} $ResolveTemplateName = { - param($Partition, $Id) + param($Partition, $Id, $NameField) if (-not $Partition -or -not $Id) { return $null } - if (-not $TemplateNameMaps.ContainsKey($Partition)) { + if ([string]::IsNullOrWhiteSpace($NameField)) { $NameField = 'displayName' } + $MapKey = "$Partition|$NameField" + if (-not $TemplateNameMaps.ContainsKey($MapKey)) { $Map = @{} try { $TemplatesTable = Get-CippTable -tablename 'templates' $SafePartition = ConvertTo-CIPPODataFilterValue -Value $Partition foreach ($TemplateRow in @(Get-CIPPAzDataTableEntity @TemplatesTable -Filter "PartitionKey eq '$SafePartition'")) { - $TemplateName = $(try { ($TemplateRow.JSON | ConvertFrom-Json).displayName } catch { $null }) + $TemplateName = $(try { ($TemplateRow.JSON | ConvertFrom-Json).$NameField } catch { $null }) if ($TemplateName) { $Map["$($TemplateRow.RowKey)"] = $TemplateName if ($TemplateRow.GUID) { $Map["$($TemplateRow.GUID)"] = $TemplateName } @@ -45,9 +49,9 @@ function Get-CIPPBaselineAlignment { } catch { Write-Information "Get-CIPPBaselineAlignment: template name lookup for $Partition failed: $($_.Exception.Message)" } - $TemplateNameMaps[$Partition] = $Map + $TemplateNameMaps[$MapKey] = $Map } - $TemplateNameMaps[$Partition]["$Id"] + $TemplateNameMaps[$MapKey]["$Id"] } # Historic view: every recorded run event for the tenant, flattened and newest-first. @@ -66,7 +70,20 @@ function Get-CIPPBaselineAlignment { $Suffix = $(try { ($Resolved.Manual | ConvertFrom-Json).taskName } catch { $null }) if (-not $Suffix -and $ResolvedDefinition.instanceIdentity) { $Suffix = $(try { ($Resolved.ExpectedValue | ConvertFrom-Json).displayName } catch { $null }) - if ($Suffix) { $Suffix = (& $ResolveTemplateName "$($ResolvedDefinition.remediate.executor)" "$Suffix") ?? $Suffix } + # Presence-shaped families carry no name in Expected; the identity + # variable on the effective Inheritance entry is the configured id. + if (-not $Suffix) { + $Suffix = $(try { + $Effective = @($Resolved.Inheritance | ConvertFrom-Json) | Where-Object { $_.effective } | Select-Object -First 1 + $Value = $Effective.value.$($ResolvedDefinition.instanceIdentity) + $Value.value ?? $Value + } catch { $null }) + } + if ($Suffix) { + $Partition = "$($ResolvedDefinition.identity.partition ?? $ResolvedDefinition.remediate.executor)" + $NameField = "$($ResolvedDefinition.identity.nameField ?? 'displayName')" + $Suffix = (& $ResolveTemplateName $Partition "$Suffix" $NameField) ?? $Suffix + } } if ($Suffix) { $ManualLabels[$Resolved.StandardName] = '{0} - {1}' -f ($ResolvedDefinition.label ?? $ResolvedBase), $Suffix } } @@ -229,14 +246,16 @@ function Get-CIPPBaselineAlignment { $Expected = & $RenderExpected $Definition $Config.variables # Multi-instance labels carry the instance identity before the first # run too: the task name for manual tasks; for identity-carrying - # standards the configured id resolves to the template's displayName - # via the template store (partition = the remediate executor name). + # standards the configured id resolves to the template's name via + # the template store, at the definition's declared partition/field. $SynthIdentity = if ($Definition.manual -and $Config.variables.taskName) { $Config.variables.taskName } elseif ($Definition.instanceIdentity) { $IdentityValue = $Config.variables.$($Definition.instanceIdentity) - if ($IdentityValue -is [System.Management.Automation.PSCustomObject]) { $IdentityValue = $IdentityValue.value } - (& $ResolveTemplateName "$($Definition.remediate.executor)" "$IdentityValue") ?? $IdentityValue + $IdentityValue = $IdentityValue.value ?? $IdentityValue + $Partition = "$($Definition.identity.partition ?? $Definition.remediate.executor)" + $NameField = "$($Definition.identity.nameField ?? 'displayName')" + (& $ResolveTemplateName $Partition "$IdentityValue" $NameField) ?? $IdentityValue } $SynthLabel = if ($SynthIdentity) { '{0} - {1}' -f ($Definition.label ?? $InstanceKey), $SynthIdentity } else { $Definition.label ?? $InstanceKey } $SynthesizedRows.Add([PSCustomObject]@{ @@ -376,11 +395,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 +427,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 +461,7 @@ function Get-CIPPBaselineAlignment { secureScoreImpact = $First.secureScoreImpact totalTenants = $Scores.total rows = @($Group.Group) + trend = @($TrendPoints) } + $Scores) } diff --git a/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineAntiPhishPolicyState.ps1 b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineAntiPhishPolicyState.ps1 new file mode 100644 index 0000000000000..af3c853f8d341 --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineAntiPhishPolicyState.ps1 @@ -0,0 +1,145 @@ +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 + # DERIVED write params the static remediate spec cannot express. Same derivations as + # the graded Expected above, so grade and write can never disagree - these fifteen + # were graded but never written, drifting forever. Blank optional actions/tags are + # omitted: sending an empty enum errors, and the operator has not expressed intent. + $ExtraPolicyParams = [ordered]@{ + EnableMailboxIntelligence = $true + EnableMailboxIntelligenceProtection = $true + EnableTargetedDomainsProtection = $true + EnableTargetedUserProtection = $true + EnableOrganizationDomainsProtection = $true + EnableSimilarUsersSafetyTips = [bool]($V.EnableSimilarUsersSafetyTips -eq $true) + EnableSimilarDomainsSafetyTips = [bool]($V.EnableSimilarDomainsSafetyTips -eq $true) + EnableUnusualCharactersSafetyTips = [bool]($V.EnableUnusualCharactersSafetyTips -eq $true) + } + if (-not [string]::IsNullOrWhiteSpace("$($V.PhishThresholdLevel)")) { $ExtraPolicyParams['PhishThresholdLevel'] = [int]"$($V.PhishThresholdLevel)" } + foreach ($ActionParam in @('MailboxIntelligenceProtectionAction', 'MailboxIntelligenceQuarantineTag', 'TargetedUserProtectionAction', 'TargetedUserQuarantineTag', 'TargetedDomainProtectionAction', 'TargetedDomainQuarantineTag')) { + if (-not [string]::IsNullOrWhiteSpace("$($V.$ActionParam)")) { $ExtraPolicyParams[$ActionParam] = "$($V.$ActionParam)" } + } + $Current | Add-Member -NotePropertyName 'extraPolicyParams' -NotePropertyValue ([PSCustomObject]$ExtraPolicyParams) + + @{ Expected = $Expected; Current = $Current } +} diff --git a/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineAppDeployState.ps1 b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineAppDeployState.ps1 new file mode 100644 index 0000000000000..ee5947d4188dc --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineAppDeployState.ps1 @@ -0,0 +1,61 @@ +function Get-CIPPBaselineAppDeployState { + <# + .SYNOPSIS + Prepare hook for AppDeploy: presence of the configured applications. + .DESCRIPTION + Grades an empty missing-apps list against the ServicePrincipals cache, the classic's + exact check. Copy mode checks the configured app ids against appId and + applicationTemplateId; template mode resolves each App Approval template to its + type-specific identity - display name for manifests, gallery template id for gallery + apps, app id for enterprise apps. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Item, + $TenantFilter + ) + + $ServicePrincipals = @(Get-CIPPBaselineCacheRows -TenantFilter $TenantFilter -Type 'ServicePrincipals') + if ($ServicePrincipals.Count -eq 0 -and -not (Test-CIPPBaselineCacheCollected -TenantFilter $TenantFilter -Type 'ServicePrincipals')) { + return @{ Current = $null } + } + + $V = $Item.Variables + $Mode = [string]($V.mode.value ?? $V.mode ?? 'copy') + $MissingApps = [System.Collections.Generic.List[string]]::new() + + if ($Mode -eq 'template') { + $TemplateIds = @($V.templateIds | ForEach-Object { "$($_.value ?? $_)" } | Where-Object { $_ }) + if ($TemplateIds.Count -eq 0) { return @{ Current = $null } } + $Table = Get-CIPPTable -TableName 'templates' + foreach ($TemplateId in $TemplateIds) { + $Template = Get-CIPPAzDataTableEntity @Table -Filter "PartitionKey eq 'AppApprovalTemplate' and RowKey eq '$TemplateId'" + if (-not $Template) { continue } + $TemplateData = $Template.JSON | ConvertFrom-Json + $AppType = "$($TemplateData.AppType ?? 'EnterpriseApp')" + $IsAppMissing = switch ($AppType) { + 'ApplicationManifest' { "$($TemplateData.AppName)" -notin @($ServicePrincipals.displayName) } + 'GalleryTemplate' { "$($TemplateData.GalleryTemplateId)" -notin @($ServicePrincipals.applicationTemplateId) } + default { "$($TemplateData.AppId)" -notin @($ServicePrincipals.appId) } + } + if ($IsAppMissing) { + $MissingApps.Add("$($TemplateData.AppName ?? $TemplateData.AppId ?? $TemplateData.GalleryTemplateId)") + } + } + } else { + $AppsToAdd = @("$($V.appids)" -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ }) + if ($AppsToAdd.Count -eq 0) { return @{ Current = $null } } + foreach ($App in $AppsToAdd) { + if ($App -notin @($ServicePrincipals.appId) -and $App -notin @($ServicePrincipals.applicationTemplateId)) { + $MissingApps.Add($App) + } + } + } + + @{ + Expected = [PSCustomObject]@{ missingApps = @() } + Current = [PSCustomObject]@{ missingApps = @($MissingApps | Sort-Object) } + } +} diff --git a/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineAppManagementPolicyState.ps1 b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineAppManagementPolicyState.ps1 new file mode 100644 index 0000000000000..e70439fd5d4f8 --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineAppManagementPolicyState.ps1 @@ -0,0 +1,85 @@ +function Get-CIPPBaselineAppManagementPolicyState { + <# + .SYNOPSIS + Prepare hook for AppManagementPolicy: credential restrictions on the default app + management policy. + .DESCRIPTION + Builds the desired restriction arrays the way the classic did, and the construction + is why this is a hook: the operator's day counts become ISO durations (P30D), the + password-addition state is MIRRORED onto symmetric key addition, and only configured + settings contribute entries. Both sides sort by restrictionType and the application + restrictions mirror onto service principal restrictions, exactly as the classic + compared them. + + Nothing configured means the standard expresses no opinion - the classic returned + without grading, and No Data is the honest equivalent. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Item, + $TenantFilter + ) + + $V = $Item.Variables + $PasswordAddition = "$($V.passwordCredentialsPasswordAddition.value ?? $V.passwordCredentialsPasswordAddition)" + $CustomPassword = "$($V.passwordCredentialsCustomPasswordAddition.value ?? $V.passwordCredentialsCustomPasswordAddition)" + $PasswordLifetimeDays = "$($V.passwordCredentialsMaxLifetime)" + $KeyLifetimeDays = "$($V.keyCredentialsMaxLifetime)" + + $PasswordCredentials = [System.Collections.Generic.List[object]]::new() + if (-not [string]::IsNullOrWhiteSpace($PasswordAddition)) { + foreach ($Type in @('passwordAddition', 'symmetricKeyAddition')) { + $PasswordCredentials.Add([PSCustomObject]@{ restrictionType = $Type; state = $PasswordAddition; maxLifetime = $null; restrictForAppsCreatedAfterDateTime = '0001-01-01T00:00:00Z' }) + } + } + if (-not [string]::IsNullOrWhiteSpace($CustomPassword)) { + $PasswordCredentials.Add([PSCustomObject]@{ restrictionType = 'customPasswordAddition'; state = $CustomPassword; maxLifetime = $null; restrictForAppsCreatedAfterDateTime = '0001-01-01T00:00:00Z' }) + } + if (-not [string]::IsNullOrWhiteSpace($PasswordLifetimeDays)) { + $PasswordCredentials.Add([PSCustomObject]@{ restrictionType = 'passwordLifetime'; state = 'enabled'; maxLifetime = "P$($PasswordLifetimeDays)D"; restrictForAppsCreatedAfterDateTime = '0001-01-01T00:00:00Z' }) + } + if (-not [string]::IsNullOrWhiteSpace($KeyLifetimeDays)) { + $PasswordCredentials.Add([PSCustomObject]@{ restrictionType = 'symmetricKeyLifetime'; state = 'enabled'; maxLifetime = "P$($KeyLifetimeDays)D"; restrictForAppsCreatedAfterDateTime = '0001-01-01T00:00:00Z' }) + } + $KeyCredentials = @(if (-not [string]::IsNullOrWhiteSpace($KeyLifetimeDays)) { + [PSCustomObject]@{ restrictionType = 'asymmetricKeyLifetime'; state = 'enabled'; maxLifetime = "P$($KeyLifetimeDays)D"; restrictForAppsCreatedAfterDateTime = '0001-01-01T00:00:00Z' } + }) + + if ($PasswordCredentials.Count -eq 0 -and @($KeyCredentials).Count -eq 0) { return @{ Current = $null } } + + $SortedPassword = @($PasswordCredentials | Sort-Object { $_.restrictionType }) + $SortedKey = @($KeyCredentials | Sort-Object { $_.restrictionType }) + $Expected = [PSCustomObject]@{ + isEnabled = $true + applicationRestrictions = [PSCustomObject]@{ passwordCredentials = $SortedPassword; keyCredentials = $SortedKey } + servicePrincipalRestrictions = [PSCustomObject]@{ passwordCredentials = $SortedPassword; keyCredentials = $SortedKey } + } + + $Policy = @(Get-CIPPBaselineCacheRows -TenantFilter $TenantFilter -Type 'DefaultAppManagementPolicy') | Select-Object -First 1 + if (-not $Policy) { return @{ Current = $null } } + + $Current = [PSCustomObject]@{ + isEnabled = [bool]$Policy.isEnabled + applicationRestrictions = [PSCustomObject]@{ + passwordCredentials = @($Policy.applicationRestrictions.passwordCredentials | Sort-Object -Property restrictionType) + keyCredentials = @($Policy.applicationRestrictions.keyCredentials | Sort-Object -Property restrictionType) + } + servicePrincipalRestrictions = [PSCustomObject]@{ + passwordCredentials = @($Policy.servicePrincipalRestrictions.passwordCredentials | Sort-Object -Property restrictionType) + keyCredentials = @($Policy.servicePrincipalRestrictions.keyCredentials | Sort-Object -Property restrictionType) + } + } + # Carried for the executor: the PATCH body is the graded expected state itself - + # carried BEFORE the round-trip below so the wire body keeps its exact strings. + $Current | Add-Member -NotePropertyName 'desiredState' -NotePropertyValue $Expected + + # JSON round-trip the expected side so its ISO date strings become [datetime] the same + # way the cached current side's did - ConvertFrom-Json converts ISO strings to real + # datetimes, and the type-strict compare would otherwise report drift between two + # values that PRINT identically. + $Expected = $Expected | ConvertTo-Json -Depth 20 | ConvertFrom-Json + + @{ Expected = $Expected; Current = $Current } +} diff --git a/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineAppleEnrollmentTypeProfileState.ps1 b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineAppleEnrollmentTypeProfileState.ps1 new file mode 100644 index 0000000000000..ba6e26a1c0d41 --- /dev/null +++ b/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/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineAssignmentFilterTemplateState.ps1 b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineAssignmentFilterTemplateState.ps1 new file mode 100644 index 0000000000000..df646e3cf5c26 --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineAssignmentFilterTemplateState.ps1 @@ -0,0 +1,78 @@ +function Get-CIPPBaselineAssignmentFilterTemplateState { + <# + .SYNOPSIS + Prepare hook for AssignmentFilterTemplate: is this instance's filter deployed and in + sync. + .DESCRIPTION + One instance grades ONE template. Matched on displayName, and unlike most template + families this one grades the WRITABLE FIELDS too - description, platform, rule and + assignmentFilterManagementType - because the classic's remediation already did a + field-level diff before patching, so the graded compare and the write agree. + + When the filter is missing only presence is graded; grading fields against a filter + that does not exist would report one drift row per field where a single 'missing' is + the truth. + + The classic's own aggregate compare was broken - its Where-Object tested + $_.displayName against itself, so anything deployed made everything read deployed. + The per-field compare here is what its remediation actually enforced. + + Template resolution stays per-family: PartitionKey 'AssignmentFilterTemplate', + RowKey alone. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Item, + $TenantFilter + ) + + $Filters = @(Get-CIPPBaselineCacheRows -TenantFilter $TenantFilter -Type 'IntuneAssignmentFilters') + if ($Filters.Count -eq 0 -and -not (Test-CIPPBaselineCacheCollected -TenantFilter $TenantFilter -Type 'IntuneAssignmentFilters')) { + return @{ Current = $null } + } + + $Reference = $Item.Variables.assignmentFilterTemplate + if ($Reference -is [System.Management.Automation.PSCustomObject] -and $Reference.PSObject.Properties.Name -contains 'value') { $Reference = $Reference.value } + if ([string]::IsNullOrWhiteSpace("$Reference")) { return @{ Current = $null } } + + $Table = Get-CippTable -tablename 'templates' + $SafeReference = ConvertTo-CIPPODataFilterValue -Value "$Reference" + $Entity = Get-CIPPAzDataTableEntity @Table -Filter "PartitionKey eq 'AssignmentFilterTemplate' and RowKey eq '$SafeReference'" | Select-Object -First 1 + $Template = $(if ($Entity -and -not [string]::IsNullOrWhiteSpace($Entity.JSON)) { try { $Entity.JSON | ConvertFrom-Json -Depth 20 -ErrorAction Stop } catch { $null } }) + $FilterName = "$($Template.displayName)" + if (-not $Template -or [string]::IsNullOrWhiteSpace($FilterName)) { return @{ Current = $null } } + + $Existing = $Filters | Where-Object { "$($_.displayName)" -eq $FilterName } | Select-Object -First 1 + + if (-not $Existing) { + $Current = [PSCustomObject]@{ deployed = $false } + $Current | Add-Member -NotePropertyName 'templateBody' -NotePropertyValue $Template + $Current | Add-Member -NotePropertyName 'existingId' -NotePropertyValue $null + return @{ + Expected = [PSCustomObject]@{ deployed = $true } + Current = $Current + } + } + + $Expected = [PSCustomObject]@{ + deployed = $true + description = "$($Template.description)" + platform = "$($Template.platform)" + rule = "$($Template.rule)" + assignmentFilterManagementType = "$($Template.assignmentFilterManagementType)" + } + $Current = [PSCustomObject]@{ + deployed = $true + description = "$($Existing.description)" + platform = "$($Existing.platform)" + rule = "$($Existing.rule)" + assignmentFilterManagementType = "$($Existing.assignmentFilterManagementType)" + } + # Carried for the executor, not graded. + $Current | Add-Member -NotePropertyName 'templateBody' -NotePropertyValue $Template + $Current | Add-Member -NotePropertyName 'existingId' -NotePropertyValue "$($Existing.id)" + + @{ Expected = $Expected; Current = $Current } +} diff --git a/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineAuthenticationMethodsState.ps1 b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineAuthenticationMethodsState.ps1 new file mode 100644 index 0000000000000..ffa1e606bccf2 --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineAuthenticationMethodsState.ps1 @@ -0,0 +1,191 @@ +function Get-CIPPBaselineAuthenticationMethodsState { + <# + .SYNOPSIS + Prepare hook for AuthenticationMethods: per-method state, targeting and settings + across the authentication methods policy. + .DESCRIPTION + The classic's ten-method matrix, ported whole. Only methods the operator configured + grade at all; each graded method contributes its drifts - state, include-target + set (a named group resolved from the Groups cache, or all_users), and the + method-specific extras: Microsoft Authenticator's software OATH flag and three + feature states, Temporary Access Pass's five numbers, QR code's two, and Email + OTP's external-id flag and exclude targets. + + The graded shape is one list naming every drift, so the row reads as findings + rather than a wall of per-method columns. Everything the executor needs - one + Set-CIPPAuthenticationPolicy parameter set per drifted method - is carried, built + here because it depends on the same group resolution the grade used. + + A configured group name that resolves to nothing skips that method's targeting + grade exactly as the classic skipped it - half-resolved targeting must not drive + a write. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Item, + $TenantFilter + ) + + $Policy = @(Get-CIPPBaselineCacheRows -TenantFilter $TenantFilter -Type 'AuthenticationMethodsPolicy') | Select-Object -First 1 + if (-not $Policy) { return @{ Current = $null } } + + $V = $Item.Variables + $AuthMethods = @( + @{ Id = 'MicrosoftAuthenticator'; RemediationId = 'MicrosoftAuthenticator'; Key = 'MicrosoftAuthenticator'; Label = 'Microsoft Authenticator' } + @{ Id = 'Fido2'; RemediationId = 'FIDO2'; Key = 'FIDO2'; Label = 'FIDO2 Security Keys' } + @{ Id = 'TemporaryAccessPass'; RemediationId = 'TemporaryAccessPass'; Key = 'TAP'; Label = 'Temporary Access Pass' } + @{ Id = 'softwareOath'; RemediationId = 'softwareOath'; Key = 'SoftwareOath'; Label = 'Software OATH Tokens' } + @{ Id = 'HardwareOath'; RemediationId = 'HardwareOATH'; Key = 'HardwareOath'; Label = 'Hardware OATH Tokens' } + @{ Id = 'Sms'; RemediationId = 'SMS'; Key = 'SMS'; Label = 'SMS' } + @{ Id = 'Voice'; RemediationId = 'Voice'; Key = 'Voice'; Label = 'Voice Call' } + @{ Id = 'Email'; RemediationId = 'Email'; Key = 'Email'; Label = 'Email OTP' } + @{ Id = 'x509Certificate'; RemediationId = 'x509Certificate'; Key = 'x509Certificate'; Label = 'Certificate-Based Authentication' } + @{ Id = 'QRCodePin'; RemediationId = 'QRCodePin'; Key = 'QRCodePin'; Label = 'QR Code Pin' } + ) + + $Configured = @(foreach ($Method in $AuthMethods) { + $Enabled = $V."$($Method.Key)Enabled" + if ($null -eq $Enabled -or "$Enabled" -eq '') { continue } + [PSCustomObject]@{ + Id = $Method.Id; RemediationId = $Method.RemediationId; Key = $Method.Key; Label = $Method.Label + Enabled = [bool]($Enabled -eq $true -or "$Enabled" -eq 'True') + GroupName = "$($V."$($Method.Key)Group")" + ExcludeGroupName = "$($V."$($Method.Key)ExcludeGroup")" + } + }) + if ($Configured.Count -eq 0) { return @{ Current = $null } } + + $Configs = @{} + foreach ($Config in @($Policy.authenticationMethodConfigurations)) { $Configs["$($Config.id)"] = $Config } + + $Groups = $null + $NeedsGroups = @($Configured | Where-Object { $_.Enabled -and (-not [string]::IsNullOrWhiteSpace($_.GroupName) -or -not [string]::IsNullOrWhiteSpace($_.ExcludeGroupName)) }).Count -gt 0 + if ($NeedsGroups) { $Groups = @(Get-CIPPBaselineCacheRows -TenantFilter $TenantFilter -Type 'Groups') } + $GroupIdCache = @{} + $ResolveGroup = { + param($Name) + if ($GroupIdCache.ContainsKey($Name)) { return $GroupIdCache[$Name] } + $Ids = @($Groups | Where-Object { "$($_.displayName)".StartsWith($Name) } | ForEach-Object { "$($_.id)" }) + $GroupIdCache[$Name] = $(if ($Ids.Count -gt 0) { $Ids } else { $null }) + $GroupIdCache[$Name] + } + + $AllDrifts = [System.Collections.Generic.List[string]]::new() + $RemediationSets = [System.Collections.Generic.List[object]]::new() + foreach ($Method in $Configured) { + $Config = $Configs[$Method.Id] + if (-not $Config) { continue } + $DesiredState = if ($Method.Enabled) { 'enabled' } else { 'disabled' } + $Drifts = [System.Collections.Generic.List[string]]::new() + if ("$($Config.state)" -ne $DesiredState) { $Drifts.Add("$($Method.Label): state '$($Config.state)' should be '$DesiredState'") } + + $CurrentTargetIds = @($Config.includeTargets | ForEach-Object { "$($_.id)" }) + $ResolvedGroupIds = $null + if ($Method.Enabled -and -not [string]::IsNullOrWhiteSpace($Method.GroupName)) { + $ResolvedGroupIds = & $ResolveGroup $Method.GroupName + if ($ResolvedGroupIds) { + $Diff = Compare-Object -ReferenceObject @($ResolvedGroupIds | Sort-Object) -DifferenceObject @($CurrentTargetIds | Sort-Object) -ErrorAction SilentlyContinue + if ($Diff) { $Drifts.Add("$($Method.Label): targeting [$($CurrentTargetIds -join ', ')] should be [$($ResolvedGroupIds -join ', ')]") } + } + } elseif ($Method.Enabled) { + if ('all_users' -notin $CurrentTargetIds) { $Drifts.Add("$($Method.Label): targeting [$($CurrentTargetIds -join ', ')] should be [all_users]") } + } + + $Params = @{ AuthenticationMethodId = $Method.RemediationId; Enabled = $Method.Enabled } + if ($Method.Enabled) { + $Params['GroupIds'] = $(if ($ResolvedGroupIds) { @($ResolvedGroupIds) } else { @('all_users') }) + } + switch ($Method.Id) { + 'MicrosoftAuthenticator' { + if ($Method.Enabled) { + $SoftwareOath = [bool]($V.MicrosoftAuthenticatorSoftwareOath -eq $true) + if ([bool]$Config.isSoftwareOathEnabled -ne $SoftwareOath) { $Drifts.Add("$($Method.Label): isSoftwareOathEnabled '$($Config.isSoftwareOathEnabled)' should be '$SoftwareOath'") } + $Params['MicrosoftAuthenticatorSoftwareOathEnabled'] = $SoftwareOath + foreach ($Feature in @( + @{ Setting = 'MicrosoftAuthenticatorDisplayAppInfo'; Property = 'displayAppInformationRequiredState'; Param = 'MicrosoftAuthenticatorDisplayAppInfo'; Label = 'Display App Info' } + @{ Setting = 'MicrosoftAuthenticatorDisplayLocation'; Property = 'displayLocationInformationRequiredState'; Param = 'MicrosoftAuthenticatorDisplayLocation'; Label = 'Display Location' } + @{ Setting = 'MicrosoftAuthenticatorCompanionApp'; Property = 'companionAppAllowedState'; Param = 'MicrosoftAuthenticatorCompanionApp'; Label = 'Companion App' } + )) { + $Desired = "$($V."$($Feature.Setting)".value ?? $V."$($Feature.Setting)")" + if (-not [string]::IsNullOrWhiteSpace($Desired)) { + $CurrentFeature = "$($Config.featureSettings."$($Feature.Property)".state)" + if ($CurrentFeature -ne $Desired) { $Drifts.Add("$($Method.Label): $($Feature.Label) '$CurrentFeature' should be '$Desired'") } + $Params[$Feature.Param] = $Desired + } + } + } + } + 'TemporaryAccessPass' { + if ($Method.Enabled) { + # '' 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'") } + if ([int]"$($Config.maximumLifetimeInMinutes)" -ne $MaxLifetime) { $Drifts.Add("$($Method.Label): maximumLifetimeInMinutes '$($Config.maximumLifetimeInMinutes)' should be '$MaxLifetime'") } + if ([int]"$($Config.defaultLength)" -ne $DefaultLength) { $Drifts.Add("$($Method.Label): defaultLength '$($Config.defaultLength)' should be '$DefaultLength'") } + $Params['TAPisUsableOnce'] = $UsableOnce + $Params['TAPDefaultLifeTime'] = $DefaultLifetime + $Params['TAPMinimumLifetime'] = $MinLifetime + $Params['TAPMaximumLifetime'] = $MaxLifetime + $Params['TAPDefaultLength'] = $DefaultLength + } + } + 'QRCodePin' { + if ($Method.Enabled) { + # 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 + $Params['QRCodePinLength'] = $PinLength + } + } + 'Email' { + if ($Method.Enabled) { + $ExternalOtp = "$($V.EmailAllowExternalIdToUseEmailOtp.value ?? $V.EmailAllowExternalIdToUseEmailOtp)" + if (-not [string]::IsNullOrWhiteSpace($ExternalOtp)) { + if ("$($Config.allowExternalIdToUseEmailOtp)" -ne $ExternalOtp) { $Drifts.Add("$($Method.Label): allowExternalIdToUseEmailOtp should be '$ExternalOtp'") } + $Params['EmailAllowExternalIdToUseEmailOtp'] = $ExternalOtp + } + if (-not [string]::IsNullOrWhiteSpace($Method.ExcludeGroupName)) { + $ExcludeIds = & $ResolveGroup $Method.ExcludeGroupName + if ($ExcludeIds) { + $CurrentExcludeIds = @($Config.excludeTargets | ForEach-Object { "$($_.id)" }) + $Diff = Compare-Object -ReferenceObject @($ExcludeIds | Sort-Object) -DifferenceObject @($CurrentExcludeIds | Sort-Object) -ErrorAction SilentlyContinue + if ($Diff) { $Drifts.Add("$($Method.Label): excludeTargets [$($CurrentExcludeIds -join ', ')] should be [$($ExcludeIds -join ', ')]") } + $Params['EmailExcludeGroupIds'] = @($ExcludeIds) + } + } + } + } + } + + if ($Drifts.Count -gt 0) { + foreach ($Drift in $Drifts) { $AllDrifts.Add($Drift) } + $RemediationSets.Add([PSCustomObject]@{ Label = $Method.Label; Params = $Params }) + } + } + + $Current = [PSCustomObject]@{ methodsOutOfPolicy = @($AllDrifts | Sort-Object) } + # Carried for the executor: one Set-CIPPAuthenticationPolicy parameter set per drifted method. + $Current | Add-Member -NotePropertyName 'remediationSets' -NotePropertyValue @($RemediationSets) + + @{ + Expected = [PSCustomObject]@{ methodsOutOfPolicy = @() } + Current = $Current + } +} diff --git a/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineAutoAddProxyState.ps1 b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineAutoAddProxyState.ps1 new file mode 100644 index 0000000000000..987f7301e0eb3 --- /dev/null +++ b/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/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineAutopilotProfileState.ps1 b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineAutopilotProfileState.ps1 new file mode 100644 index 0000000000000..0cb1c8623763f --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineAutopilotProfileState.ps1 @@ -0,0 +1,73 @@ +function Get-CIPPBaselineAutopilotProfileState { + <# + .SYNOPSIS + Prepare hook for AutopilotProfile: one named Windows Autopilot deployment profile. + .DESCRIPTION + Finds the configured profile by display name in the Autopilot profiles cache and + grades the classic's exact field set. Two classic derivations are load-bearing: + self-deploying mode forces White Glove OFF (the classic mutated the setting before + comparing), and userType only grades outside shared mode. Locale grades + empty-matches-empty, so a baseline with no language never drifts a profile without + one. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Item, + $TenantFilter + ) + + $Profiles = @(Get-CIPPBaselineCacheRows -TenantFilter $TenantFilter -Type 'IntuneWindowsAutopilotDeploymentProfiles') + if ($Profiles.Count -eq 0 -and -not (Test-CIPPBaselineCacheCollected -TenantFilter $TenantFilter -Type 'IntuneWindowsAutopilotDeploymentProfiles')) { + return @{ Current = $null } + } + + $V = $Item.Variables + # The identity may arrive as an option object ({label, value}) from some save paths - + # unwrap before interpolating or the profile title becomes the stringified object. + $DisplayName = "$($V.DisplayName.value ?? $V.DisplayName)" + if ([string]::IsNullOrWhiteSpace($DisplayName)) { return @{ Current = $null } } + $Profile = @($Profiles | Where-Object { "$($_.displayName)" -eq $DisplayName }) | Select-Object -First 1 + + $UserType = $(if ($V.NotLocalAdmin -eq $true) { 'standard' } else { 'administrator' }) + $SelfDeploying = $V.SelfDeployingMode -eq $true + $DeploymentMode = $(if ($SelfDeploying) { 'shared' } else { 'singleUser' }) + # Self-deploying mode cannot pre-provision - the classic forced White Glove off with it. + $AllowWhiteGlove = $(if ($SelfDeploying) { $false } else { [bool]$V.AllowWhiteGlove }) + $Locale = "$($V.Languages.value ?? $V.Languages)" + + $Expected = [PSCustomObject]@{ + profileExists = $true + displayName = $DisplayName + description = "$($V.Description)" + deviceNameTemplate = "$($V.DeviceNameTemplate)" + locale = $Locale + preprovisioningAllowed = $AllowWhiteGlove + hardwareHashExtractionEnabled = [bool]$V.CollectHash + deviceUsageType = $DeploymentMode + privacySettingsHidden = [bool]$V.HidePrivacy + eulaHidden = [bool]$V.HideTerms + keyboardSelectionPageSkipped = [bool]$V.AutoKeyboard + } + $Current = [PSCustomObject]@{ + profileExists = ($null -ne $Profile) + displayName = "$($Profile.displayName)" + description = "$($Profile.description)" + deviceNameTemplate = "$($Profile.deviceNameTemplate)" + locale = $(if ($Locale -in @('', 'os-default') -and "$($Profile.locale)" -in @('', 'os-default')) { $Locale } else { "$($Profile.locale)" }) + preprovisioningAllowed = [bool]$Profile.preprovisioningAllowed + hardwareHashExtractionEnabled = [bool]$Profile.hardwareHashExtractionEnabled + deviceUsageType = "$($Profile.outOfBoxExperienceSetting.deviceUsageType)" + privacySettingsHidden = [bool]$Profile.outOfBoxExperienceSetting.privacySettingsHidden + eulaHidden = [bool]$Profile.outOfBoxExperienceSetting.eulaHidden + keyboardSelectionPageSkipped = [bool]$Profile.outOfBoxExperienceSetting.keyboardSelectionPageSkipped + } + # userType only grades outside shared mode - shared profiles carry no meaningful value. + if ($DeploymentMode -ne 'shared') { + $Expected | Add-Member -NotePropertyName 'userType' -NotePropertyValue $UserType + $Current | Add-Member -NotePropertyName 'userType' -NotePropertyValue "$($Profile.outOfBoxExperienceSetting.userType)" + } + + @{ Expected = $Expected; Current = $Current } +} diff --git a/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineAutopilotStatusPageState.ps1 b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineAutopilotStatusPageState.ps1 new file mode 100644 index 0000000000000..2f42facaf2482 --- /dev/null +++ b/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/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineCacheRows.ps1 b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineCacheRows.ps1 new file mode 100644 index 0000000000000..e5a84fe557c8c --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineCacheRows.ps1 @@ -0,0 +1,59 @@ +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. + + 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 + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [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 } + + $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 $CollectorFor 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/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineCollaborationDomainRestrictionState.ps1 b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineCollaborationDomainRestrictionState.ps1 new file mode 100644 index 0000000000000..3ac64af8d3ee1 --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineCollaborationDomainRestrictionState.ps1 @@ -0,0 +1,46 @@ +function Get-CIPPBaselineCollaborationDomainRestrictionState { + <# + .SYNOPSIS + Prepare hook for CollaborationDomainRestriction: the B2B invitation allow-list. + .DESCRIPTION + Grades the sorted allowed-domains list on the tenant's B2B management policy + against the configured list. The policy is a GUID-keyed collection with no default + singleton - org-default preferred, first otherwise - and the actual settings live in + definition[0] as a JSON string. + + The FULL parsed definition is carried for the executor: the write must rebuild the + domain policy on top of it so sibling settings (AutoRedeemPolicy) survive. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Item, + $TenantFilter + ) + + $Policies = @(Get-CIPPBaselineCacheRows -TenantFilter $TenantFilter -Type 'B2BManagementPolicy') + if ($Policies.Count -eq 0 -and -not (Test-CIPPBaselineCacheCollected -TenantFilter $TenantFilter -Type 'B2BManagementPolicy')) { + return @{ Current = $null } + } + + $Desired = @("$($Item.Variables.allowedDomains)" -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ } | Sort-Object) + if ($Desired.Count -eq 0) { return @{ Current = $null } } + + $Policy = @($Policies | Where-Object { $_.isOrganizationDefault -eq $true }) | Select-Object -First 1 + if (-not $Policy) { $Policy = $Policies | Select-Object -First 1 } + $Definition = $(if ($Policy.definition) { try { @($Policy.definition)[0] | ConvertFrom-Json -ErrorAction Stop } catch { $null } }) + $DomainPolicy = $Definition.B2BManagementPolicy.InvitationsAllowedAndBlockedDomainsPolicy + + $Current = [PSCustomObject]@{ + allowedDomains = @($DomainPolicy.AllowedDomains | Sort-Object) + } + # Carried for the executor. + $Current | Add-Member -NotePropertyName 'policyId' -NotePropertyValue "$($Policy.id)" + $Current | Add-Member -NotePropertyName 'existingDefinition' -NotePropertyValue $Definition + + @{ + Expected = [PSCustomObject]@{ allowedDomains = @($Desired) } + Current = $Current + } +} diff --git a/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineColleagueImpersonationAlertState.ps1 b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineColleagueImpersonationAlertState.ps1 new file mode 100644 index 0000000000000..3b01dbd406dbc --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineColleagueImpersonationAlertState.ps1 @@ -0,0 +1,111 @@ +function Get-CIPPBaselineColleagueImpersonationAlertState { + <# + .SYNOPSIS + Prepare hook for ColleagueImpersonationAlert: the five display-name impersonation + transport rules. + .DESCRIPTION + The expected state is DYNAMIC: each rule's header patterns are computed from the + tenant's live mailbox display names (grouped A-E/F-J/K-O/P-T/U-Z), so the grade is + per-rule "exists AND patterns match", the classic's exact semantics. Everything + reads from CIPPDb caches - Mailboxes for display names (user+shared, enabled, not + keyword-excluded), ExoAcceptedDomains for the automatic domain exemptions, and + ExoTransportRules for the rules themselves. The optional display name separator + adds the short name (text before the separator) as a second pattern per user, and + an empty letter group carries its "($range)" placeholder pattern - both classic + behaviours the write depends on. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Item, + $TenantFilter + ) + + $Mailboxes = @(Get-CIPPBaselineCacheRows -TenantFilter $TenantFilter -Type 'Mailboxes') + $Rules = @(Get-CIPPBaselineCacheRows -TenantFilter $TenantFilter -Type 'ExoTransportRules') + $AcceptedDomains = @(Get-CIPPBaselineCacheRows -TenantFilter $TenantFilter -Type 'ExoAcceptedDomains') + if ($Mailboxes.Count -eq 0 -and -not (Test-CIPPBaselineCacheCollected -TenantFilter $TenantFilter -Type 'Mailboxes')) { + return @{ Current = $null } + } + if ($AcceptedDomains.Count -eq 0 -and -not (Test-CIPPBaselineCacheCollected -TenantFilter $TenantFilter -Type 'ExoAcceptedDomains')) { + return @{ Current = $null } + } + + $V = $Item.Variables + $DisplayNameSeparator = "$($V.displayNameSeparator)" + $Unwrap = { param($Value) @(@($Value) | ForEach-Object { if ($_ -is [string]) { $_ } else { [string]($_.value ?? $_.label) } } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) } + $ExcludeKeywords = @(& $Unwrap $V.excludedMailboxes) + $AdditionalExemptSenders = @(& $Unwrap $V.additionalExemptSenders) + + $AutoExemptDomains = @($AcceptedDomains | ForEach-Object { "$($_.DomainName)" } | + Where-Object { $_ -and $_ -notmatch '\.onmicrosoft\.com$|\.exclaimer\.cloud$' }) + + $DisplayNames = @($Mailboxes | Where-Object { + $Mailbox = $_ + if ("$($Mailbox.recipientTypeDetails)" -notin @('UserMailbox', 'SharedMailbox')) { return $false } + if ($Mailbox.AccountDisabled -eq $true) { return $false } + if ([string]::IsNullOrWhiteSpace("$($Mailbox.displayName)")) { return $false } + foreach ($Keyword in $ExcludeKeywords) { + if ("$($Mailbox.displayName)" -match [regex]::Escape($Keyword)) { return $false } + } + $true + } | ForEach-Object { "$($_.displayName)" }) + + $Groups = [ordered]@{ + 'A-E' = '^[A-Ea-e]' + 'F-J' = '^[F-Jf-j]' + 'K-O' = '^[K-Ok-o]' + 'P-T' = '^[P-Tp-t]' + 'U-Z' = '^[U-Zu-z]' + } + + $RuleStates = [System.Collections.Generic.List[object]]::new() + $Expected = [PSCustomObject]@{} + $Current = [PSCustomObject]@{} + foreach ($Entry in $Groups.GetEnumerator()) { + $Range = $Entry.Key + $Pattern = $Entry.Value + $RuleName = "($Range) Colleague Impersonation Alert" + $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 + $NamesMatch = $false + if ($null -ne $Existing) { + $ExistingPatterns = @($Existing.HeaderMatchesPatterns | ForEach-Object { "$_" }) + $NamesMatch = (($Names | Sort-Object) -join "`n") -eq (($ExistingPatterns | Sort-Object) -join "`n") + } + + $RuleStates.Add([PSCustomObject]@{ + RuleName = $RuleName + Range = $Range + Names = @($Names) + RuleExists = ($null -ne $Existing) + ExistingExemptSender = @($Existing.ExceptIfFromAddressContainsWords | ForEach-Object { "$_" }) + ExistingExemptDomain = @($Existing.ExceptIfSenderDomainIs | ForEach-Object { "$_" }) + ExistingDisclaimer = "$($Existing.ApplyHtmlDisclaimerText)" + }) + $Expected | Add-Member -NotePropertyName $RuleName -NotePropertyValue $true + $Current | Add-Member -NotePropertyName $RuleName -NotePropertyValue (($null -ne $Existing) -and $NamesMatch) + } + + # Carried for the executor: the computed patterns and the existing exemptions to merge. + $Current | Add-Member -NotePropertyName 'ruleStates' -NotePropertyValue @($RuleStates) + $Current | Add-Member -NotePropertyName 'autoExemptDomains' -NotePropertyValue @($AutoExemptDomains) + $Current | Add-Member -NotePropertyName 'additionalExemptSenders' -NotePropertyValue @($AdditionalExemptSenders) + + @{ Expected = $Expected; Current = $Current } +} diff --git a/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineCopilotLimitedModeState.ps1 b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineCopilotLimitedModeState.ps1 new file mode 100644 index 0000000000000..fa233c10f4b97 --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineCopilotLimitedModeState.ps1 @@ -0,0 +1,51 @@ +function Get-CIPPBaselineCopilotLimitedModeState { + <# + .SYNOPSIS + Prepare hook for CopilotLimitedMode: is Copilot limited mode scoped as configured. + .DESCRIPTION + Enabled grades both the flag and that the scoping group matches the configured name + (resolved from the Groups cache with the classic's startsWith-first-match rule). + Disabled grades the flag alone. An enabled posture whose group cannot be resolved + reports No Data - the classic refused to remediate that state, and there is nothing + truthful to grade the tenant's group against. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Item, + $TenantFilter + ) + + $State = @(Get-CIPPBaselineCacheRows -TenantFilter $TenantFilter -Type 'CopilotAdminSettings') | Select-Object -First 1 + if (-not $State) { return @{ Current = $null } } + + $Enabled = [bool]($Item.Variables.LimitedModeEnabled -eq $true) + if (-not $Enabled) { + return @{ + Expected = [PSCustomObject]@{ limitedModeEnabled = $false } + Current = [PSCustomObject]@{ limitedModeEnabled = [bool]$State.isEnabledForGroup } + } + } + + $GroupName = "$($Item.Variables.GroupName)" + if ([string]::IsNullOrWhiteSpace($GroupName)) { return @{ Current = $null } } + $Groups = @(Get-CIPPBaselineCacheRows -TenantFilter $TenantFilter -Type 'Groups') + $Resolved = @($Groups | Where-Object { "$($_.displayName)".StartsWith($GroupName) }) | Select-Object -First 1 + if (-not $Resolved) { + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "Copilot limited mode: the group '$GroupName' does not resolve in this tenant - nothing was compared." -Sev 'Error' + return @{ Current = $null } + } + + $Current = [PSCustomObject]@{ + limitedModeEnabled = [bool]$State.isEnabledForGroup + groupCorrect = [bool]("$($State.groupId)" -eq "$($Resolved.id)") + } + # Carried for the executor. + $Current | Add-Member -NotePropertyName 'resolvedGroupId' -NotePropertyValue "$($Resolved.id)" + + @{ + Expected = [PSCustomObject]@{ limitedModeEnabled = $true; groupCorrect = $true } + Current = $Current + } +} diff --git a/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineCustomBannedPasswordListState.ps1 b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineCustomBannedPasswordListState.ps1 new file mode 100644 index 0000000000000..577bd8e323304 --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineCustomBannedPasswordListState.ps1 @@ -0,0 +1,52 @@ +function Get-CIPPBaselineCustomBannedPasswordListState { + <# + .SYNOPSIS + Prepare hook for CustomBannedPasswordList: are the configured banned words active. + .DESCRIPTION + ADDITIVE, like the classic: grades which configured words are missing from the + tenant's banned password list (tab-separated inside the directory setting) plus + whether the check is enabled at all. Words on the tenant list that the baseline + never mentioned are left alone - the remediation merges, capped at Entra's 1000. + + Words outside the 4-16 character bounds Entra enforces are dropped from the grade + the way the classic dropped them from the write - grading an unwritable word would + be permanent drift. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Item, + $TenantFilter + ) + + $Settings = @(Get-CIPPBaselineCacheRows -TenantFilter $TenantFilter -Type 'Settings') + if ($Settings.Count -eq 0 -and -not (Test-CIPPBaselineCacheCollected -TenantFilter $TenantFilter -Type 'Settings')) { + return @{ Current = $null } + } + + $Words = @("$($Item.Variables.BannedWords)" -split '[,;\r\n]+' | ForEach-Object { $_.Trim() } | + Where-Object { $_.Length -ge 4 -and $_.Length -le 16 } | Select-Object -Unique) + if ($Words.Count -eq 0 -or $Words.Count -gt 1000) { return @{ Current = $null } } + + $Existing = @($Settings | Where-Object { "$($_.templateId)" -eq '5cf42378-d67d-4f36-ba46-e8b86229381d' }) | Select-Object -First 1 + $CurrentWords = @() + if ($Existing) { + $CurrentWords = @("$((@($Existing.values) | Where-Object { $_.name -eq 'BannedPasswordList' }).value)" -split ([char]9) | Where-Object { $_ }) + } + $CheckEnabled = [bool]($Existing -and "$((@($Existing.values) | Where-Object { $_.name -eq 'EnableBannedPasswordCheck' }).value)" -eq 'True') + $Missing = @($Words | Where-Object { $CurrentWords -notcontains $_ } | Sort-Object) + + $Current = [PSCustomObject]@{ + bannedPasswordCheckEnabled = $CheckEnabled + missingBannedWords = @($Missing) + } + # Carried for the executor: the merge needs the tenant's current words. + $Current | Add-Member -NotePropertyName 'settingId' -NotePropertyValue "$($Existing.id)" + $Current | Add-Member -NotePropertyName 'currentWords' -NotePropertyValue @($CurrentWords) + + @{ + Expected = [PSCustomObject]@{ bannedPasswordCheckEnabled = $true; missingBannedWords = @() } + Current = $Current + } +} diff --git a/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineDefaultPlatformRestrictionsState.ps1 b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineDefaultPlatformRestrictionsState.ps1 new file mode 100644 index 0000000000000..d6f3d9d9d22df --- /dev/null +++ b/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/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineDefenderASRPolicyState.ps1 b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineDefenderASRPolicyState.ps1 new file mode 100644 index 0000000000000..2505b4d9cd0a2 --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineDefenderASRPolicyState.ps1 @@ -0,0 +1,95 @@ +function Get-CIPPBaselineDefenderASRPolicyState { + <# + .SYNOPSIS + Prepare hook for DefenderASRPolicy: the 'ASR Default rules' settings catalog policy. + .DESCRIPTION + Parses the attack surface reduction rules group from the cached policy exactly as the + classic did: a rule PRESENT in the group counts as enabled (its individual mode is not + graded per rule), and the policy mode is the FIRST rule's mode. Rules the baseline + turns off must be absent from the group or they grade as drift. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Item, + $TenantFilter + ) + + $Policies = @(Get-CIPPBaselineCacheRows -TenantFilter $TenantFilter -Type 'IntuneConfigurationPolicies') + if ($Policies.Count -eq 0 -and -not (Test-CIPPBaselineCacheCollected -TenantFilter $TenantFilter -Type 'IntuneConfigurationPolicies')) { + return @{ Current = $null } + } + $Policy = @($Policies | Where-Object { "$($_.name)" -eq 'ASR Default rules' }) | Select-Object -First 1 + + $ASRPrefix = 'device_vendor_msft_policy_config_defender_attacksurfacereductionrules' + $ASRRuleDefMap = @{ + 'blockexecutionofpotentiallyobfuscatedscripts' = 'blockObfuscatedScripts' + 'blockadobereaderfromcreatingchildprocesses' = 'blockAdobeChild' + 'blockwin32apicallsfromofficemacros' = 'blockWin32Macro' + 'blockcredentialstealingfromwindowslocalsecurityauthoritysubsystem' = 'blockCredentialStealing' + 'blockprocesscreationsfrompsexecandwmicommands' = 'blockPSExec' + 'blockpersistencethroughwmieventsubscription' = 'wmiPersistence' + 'blockuseofcopiedorimpersonatedsystemtools' = 'blockSystemTools' + 'blockofficeapplicationsfromcreatingexecutablecontent' = 'blockOfficeExes' + 'blockofficeapplicationsfrominjectingcodeintootherprocesses' = 'blockOfficeApps' + 'blockrebootingmachineinsafemode' = 'blockSafeMode' + 'blockexecutablefilesrunningunlesstheymeetprevalenceagetrustedlistcriterion' = 'blockYoungExe' + 'blockjavascriptorvbscriptfromlaunchingdownloadedexecutablecontent' = 'blockJSVB' + 'blockwebshellcreationforservers' = 'blockWebshellForServers' + 'blockofficecommunicationappfromcreatingchildprocesses' = 'blockOfficeComChild' + 'blockallofficeapplicationsfromcreatingchildprocesses' = 'blockOfficeChild' + 'blockuntrustedunsignedprocessesthatrunfromusb' = 'blockUntrustedUSB' + 'useadvancedprotectionagainstransomware' = 'enableRansomwareVac' + 'blockexecutablecontentfromemailclientandwebmail' = 'blockExesMail' + 'blockabuseofexploitedvulnerablesigneddrivers' = 'blockUnsignedDrivers' + } + # Definition variable name (PascalCase-ish, as the classic declared them) per graded key. + $VariableNameMap = @{ + blockObfuscatedScripts = 'BlockObfuscatedScripts'; blockAdobeChild = 'BlockAdobeChild'; blockWin32Macro = 'BlockWin32Macro' + blockCredentialStealing = 'BlockCredentialStealing'; blockPSExec = 'BlockPSExec'; wmiPersistence = 'WMIPersistence' + blockSystemTools = 'BlockSystemTools'; blockOfficeExes = 'BlockOfficeExes'; blockOfficeApps = 'BlockOfficeApps' + blockSafeMode = 'BlockSafeMode'; blockYoungExe = 'BlockYoungExe'; blockJSVB = 'blockJSVB' + blockWebshellForServers = 'BlockWebshellForServers'; blockOfficeComChild = 'blockOfficeComChild'; blockOfficeChild = 'blockOfficeChild' + blockUntrustedUSB = 'BlockUntrustedUSB'; enableRansomwareVac = 'EnableRansomwareVac'; blockExesMail = 'BlockExesMail' + blockUnsignedDrivers = 'BlockUnsignedDrivers' + } + + $CurrentRules = @{} + $CurrentMode = '' + foreach ($Setting in @($Policy.settings)) { + $Instance = $Setting.settingInstance + if ("$($Instance.settingDefinitionId)" -eq $ASRPrefix -and $Instance.groupSettingCollectionValue) { + foreach ($Child in @(@($Instance.groupSettingCollectionValue)[0].children)) { + $RuleSuffix = "$($Child.settingDefinitionId)" -replace "^${ASRPrefix}_", '' + if ($ASRRuleDefMap.ContainsKey($RuleSuffix)) { + $CurrentRules[$ASRRuleDefMap[$RuleSuffix]] = $true + if (-not $CurrentMode) { $CurrentMode = ("$($Child.choiceSettingValue.value)" -split '_')[-1] } + } + } + } + } + + $V = $Item.Variables + $Expected = [ordered]@{ + policyExists = $true + mode = [string]($V.Mode.value ?? $V.Mode ?? 'block') + } + $Current = [ordered]@{ + policyExists = ($null -ne $Policy) + mode = [string]$CurrentMode + } + foreach ($Rule in $VariableNameMap.Keys | Sort-Object) { + $Expected[$Rule] = [bool]$V.($VariableNameMap[$Rule]) + $Current[$Rule] = [bool]$CurrentRules[$Rule] + } + + $CurrentObject = [PSCustomObject]$Current + # Carried for the executor. + $CurrentObject | Add-Member -NotePropertyName 'policyId' -NotePropertyValue "$($Policy.id)" + + @{ + Expected = [PSCustomObject]$Expected + Current = $CurrentObject + } +} diff --git a/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineDefenderAVPolicyState.ps1 b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineDefenderAVPolicyState.ps1 new file mode 100644 index 0000000000000..0b1a95f003129 --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineDefenderAVPolicyState.ps1 @@ -0,0 +1,161 @@ +function Get-CIPPBaselineDefenderAVPolicyState { + <# + .SYNOPSIS + Prepare hook for DefenderAVPolicy: the 'Default AV Policy' settings catalog policy. + .DESCRIPTION + Finds the fixed-name policy in the IntuneConfigurationPolicies cache and parses its + settingInstance tree back into the classic's property set: boolean choice settings + (enabled = _1 suffix), choice suffixes as strings, integers, and the threat severity + remediation group. The four remediation actions grade only when the baseline + configures them - the classic compared them conditionally for the same reason. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Item, + $TenantFilter + ) + + $Policies = @(Get-CIPPBaselineCacheRows -TenantFilter $TenantFilter -Type 'IntuneConfigurationPolicies') + if ($Policies.Count -eq 0 -and -not (Test-CIPPBaselineCacheCollected -TenantFilter $TenantFilter -Type 'IntuneConfigurationPolicies')) { + return @{ Current = $null } + } + $Policy = @($Policies | Where-Object { "$($_.name)" -eq 'Default AV Policy' }) | Select-Object -First 1 + + $DP = 'device_vendor_msft_policy_config_defender' + $DA = 'device_vendor_msft_defender_configuration' + $BoolDefIdMap = @{ + "${DP}_allowarchivescanning" = 'scanArchives' + "${DP}_allowbehaviormonitoring" = 'allowBehavior' + "${DP}_allowcloudprotection" = 'allowCloudProtection' + "${DP}_allowemailscanning" = 'allowEmailScanning' + "${DP}_allowfullscanonmappednetworkdrives" = 'allowFullScanNetwork' + "${DP}_allowfullscanremovabledrivescanning" = 'allowFullScanRemovable' + "${DP}_allowioavprotection" = 'allowDownloadable' + "${DP}_allowrealtimemonitoring" = 'allowRealTime' + "${DP}_allowscanningnetworkfiles" = 'allowNetwork' + "${DP}_allowscriptscanning" = 'allowScriptScan' + "${DP}_allowuseruiaccess" = 'allowUI' + "${DP}_checkforsignaturesbeforerunningscan" = 'checkSigs' + "${DP}_disablecatchupfullscan" = 'disableCatchupFullScan' + "${DP}_disablecatchupquickscan" = 'disableCatchupQuickScan' + "${DP}_enablelowcpupriority" = 'lowCPU' + "${DA}_meteredconnectionupdates" = 'meteredConnectionUpdates' + "${DA}_disablelocaladminmerge" = 'disableLocalAdminMerge' + } + $ChoiceDefIdMap = @{ + "${DP}_enablenetworkprotection" = 'enableNetworkProtection' + "${DP}_cloudblocklevel" = 'cloudBlockLevel' + "${DP}_allowonaccessprotection" = 'allowOnAccessProtection' + "${DP}_submitsamplesconsent" = 'submitSamplesConsent' + } + $IntegerDefIdMap = @{ + "${DP}_avgcpuloadfactor" = 'avgCPULoadFactor' + "${DP}_cloudextendedtimeout" = 'cloudExtendedTimeout' + "${DP}_signatureupdateinterval" = 'signatureUpdateInterval' + } + $RemediationDefId = "${DP}_threatseveritydefaultaction" + + $Parsed = @{} + foreach ($Setting in @($Policy.settings)) { + $Instance = $Setting.settingInstance + $DefId = "$($Instance.settingDefinitionId)" + if ($BoolDefIdMap.ContainsKey($DefId)) { + $Parsed[$BoolDefIdMap[$DefId]] = "$($Instance.choiceSettingValue.value)" -like '*_1' + } elseif ($ChoiceDefIdMap.ContainsKey($DefId)) { + $Parsed[$ChoiceDefIdMap[$DefId]] = [string]("$($Instance.choiceSettingValue.value)" -split '_')[-1] + } elseif ($IntegerDefIdMap.ContainsKey($DefId)) { + $Parsed[$IntegerDefIdMap[$DefId]] = [int]"$($Instance.simpleSettingValue.value)" + } elseif ($DefId -eq $RemediationDefId) { + foreach ($Child in @(@($Instance.groupSettingCollectionValue)[0].children)) { + $Suffix = ("$($Child.choiceSettingValue.value)" -split '_')[-1] + switch -Wildcard ("$($Child.settingDefinitionId)") { + '*_lowseveritythreats' { $Parsed['remediationLow'] = $Suffix } + '*_moderateseveritythreats' { $Parsed['remediationModerate'] = $Suffix } + '*_highseveritythreats' { $Parsed['remediationHigh'] = $Suffix } + '*_severethreats' { $Parsed['remediationSevere'] = $Suffix } + } + } + } + } + + $V = $Item.Variables + $Pick = { param($Value, $Default) [string]($Value.value ?? $Value ?? $Default) } + $Expected = [ordered]@{ + policyExists = $true + scanArchives = [bool]$V.ScanArchives + allowBehavior = [bool]$V.AllowBehavior + allowCloudProtection = [bool]$V.AllowCloudProtection + allowEmailScanning = [bool]$V.AllowEmailScanning + allowFullScanNetwork = [bool]$V.AllowFullScanNetwork + allowFullScanRemovable = [bool]$V.AllowFullScanRemovable + allowScriptScan = [bool]$V.AllowScriptScan + allowDownloadable = [bool]$V.AllowDownloadable + allowRealTime = [bool]$V.AllowRealTime + allowNetwork = [bool]$V.AllowNetwork + allowUI = [bool]$V.AllowUI + checkSigs = [bool]$V.CheckSigs + disableCatchupFullScan = [bool]$V.DisableCatchupFullScan + disableCatchupQuickScan = [bool]$V.DisableCatchupQuickScan + lowCPU = [bool]$V.LowCPU + meteredConnectionUpdates = [bool]$V.MeteredConnectionUpdates + disableLocalAdminMerge = [bool]$V.DisableLocalAdminMerge + # Blank integers grade the executor's write-side defaults: '' survives ?? so a + # plain null-coalesce would grade 0 while the recreate writes 50/8, drift no + # remediation could ever clear. + avgCPULoadFactor = $(if ([string]::IsNullOrWhiteSpace("$($V.AvgCPULoadFactor)")) { 50 } else { [int]"$($V.AvgCPULoadFactor)" }) + signatureUpdateInterval = $(if ([string]::IsNullOrWhiteSpace("$($V.SignatureUpdateInterval)")) { 8 } else { [int]"$($V.SignatureUpdateInterval)" }) + cloudExtendedTimeout = $(if ([string]::IsNullOrWhiteSpace("$($V.CloudExtendedTimeout)")) { 0 } else { [int]"$($V.CloudExtendedTimeout)" }) + } + $Current = [ordered]@{ + policyExists = ($null -ne $Policy) + scanArchives = [bool]$Parsed.scanArchives + allowBehavior = [bool]$Parsed.allowBehavior + allowCloudProtection = [bool]$Parsed.allowCloudProtection + allowEmailScanning = [bool]$Parsed.allowEmailScanning + allowFullScanNetwork = [bool]$Parsed.allowFullScanNetwork + allowFullScanRemovable = [bool]$Parsed.allowFullScanRemovable + allowScriptScan = [bool]$Parsed.allowScriptScan + allowDownloadable = [bool]$Parsed.allowDownloadable + allowRealTime = [bool]$Parsed.allowRealTime + allowNetwork = [bool]$Parsed.allowNetwork + allowUI = [bool]$Parsed.allowUI + checkSigs = [bool]$Parsed.checkSigs + disableCatchupFullScan = [bool]$Parsed.disableCatchupFullScan + disableCatchupQuickScan = [bool]$Parsed.disableCatchupQuickScan + lowCPU = [bool]$Parsed.lowCPU + meteredConnectionUpdates = [bool]$Parsed.meteredConnectionUpdates + disableLocalAdminMerge = [bool]$Parsed.disableLocalAdminMerge + avgCPULoadFactor = [int]($Parsed.avgCPULoadFactor ?? 0) + signatureUpdateInterval = [int]($Parsed.signatureUpdateInterval ?? 0) + cloudExtendedTimeout = [int]($Parsed.cloudExtendedTimeout ?? 0) + } + # The four choice settings only write when the baseline configures them - the + # recreate omits blank ones entirely, so grading a default against a policy that + # legitimately lacks the setting would be drift no write can ever clear. + foreach ($Pair in @(@('enableNetworkProtection', 'EnableNetworkProtection'), @('cloudBlockLevel', 'CloudBlockLevel'), @('allowOnAccessProtection', 'AllowOnAccessProtection'), @('submitSamplesConsent', 'SubmitSamplesConsent'))) { + $Configured = & $Pick $V.($Pair[1]) '' + if (-not [string]::IsNullOrEmpty($Configured)) { + $Expected[$Pair[0]] = $Configured + $Current[$Pair[0]] = [string]($Parsed.($Pair[0]) ?? '') + } + } + # The classic compared the four remediation actions only when configured. + foreach ($Pair in @(@('remediationLow', 'RemediationLow'), @('remediationModerate', 'RemediationModerate'), @('remediationHigh', 'RemediationHigh'), @('remediationSevere', 'RemediationSevere'))) { + $Configured = & $Pick $V.($Pair[1]) '' + if (-not [string]::IsNullOrEmpty($Configured)) { + $Expected[$Pair[0]] = $Configured + $Current[$Pair[0]] = [string]($Parsed.($Pair[0]) ?? '') + } + } + + $CurrentObject = [PSCustomObject]$Current + # Carried for the executor. + $CurrentObject | Add-Member -NotePropertyName 'policyId' -NotePropertyValue "$($Policy.id)" + + @{ + Expected = [PSCustomObject]$Expected + Current = $CurrentObject + } +} diff --git a/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineDefenderCompliancePolicyState.ps1 b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineDefenderCompliancePolicyState.ps1 new file mode 100644 index 0000000000000..fd7ca6cf25c33 --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineDefenderCompliancePolicyState.ps1 @@ -0,0 +1,66 @@ +function Get-CIPPBaselineDefenderCompliancePolicyState { + <# + .SYNOPSIS + Prepare hook for DefenderCompliancePolicy: the MDE mobile threat defense connector. + .DESCRIPTION + Reads the MDE connector singleton (fc780465-2017-40d4-a0c5-307022471b92) LIVE - it is + one small object with no cache, exactly what the classic read. A missing connector + grades every surface false. Two of the classic's rules are load-bearing: + connecting Windows forces the Windows partner-data block on (Microsoft enforces it + server-side), and microsoftDefenderForEndpointAttachEnabled always grades true. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Item, + $TenantFilter + ) + + $ConnectorState = $null + try { + $ConnectorState = New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/deviceManagement/mobileThreatDefenseConnectors/fc780465-2017-40d4-a0c5-307022471b92' -tenantid $TenantFilter + } catch { + # Connector does not exist yet - every current surface reads false below. + } + + $V = $Item.Variables + $Keys = @( + 'androidEnabled', 'iosEnabled', 'windowsEnabled', 'macEnabled', 'partnerUnsupportedOsVersionBlocked' + 'allowPartnerToCollectIOSApplicationMetadata', 'allowPartnerToCollectIOSPersonalApplicationMetadata' + 'androidDeviceBlockedOnMissingPartnerData', 'iosDeviceBlockedOnMissingPartnerData' + 'windowsDeviceBlockedOnMissingPartnerData', 'macDeviceBlockedOnMissingPartnerData' + 'androidMobileApplicationManagementEnabled', 'iosMobileApplicationManagementEnabled' + 'windowsMobileApplicationManagementEnabled', 'allowPartnerToCollectIosCertificateMetadata' + 'allowPartnerToCollectIosPersonalCertificateMetadata', 'grantMobileThreatDefensePartnerRole' + 'microsoftDefenderForEndpointAttachEnabled' + ) + $Expected = [PSCustomObject]@{ + androidEnabled = [bool]$V.ConnectAndroid + iosEnabled = [bool]$V.ConnectIos + windowsEnabled = [bool]$V.ConnectWindows + macEnabled = [bool]$V.ConnectMac + partnerUnsupportedOsVersionBlocked = [bool]$V.BlockunsupportedOS + allowPartnerToCollectIOSApplicationMetadata = [bool]$V.appSync + allowPartnerToCollectIOSPersonalApplicationMetadata = [bool]$V.allowPartnerToCollectIosPersonalApplicationMetadata + androidDeviceBlockedOnMissingPartnerData = [bool]$V.androidDeviceBlockedOnMissingPartnerData + iosDeviceBlockedOnMissingPartnerData = [bool]$V.iosDeviceBlockedOnMissingPartnerData + windowsDeviceBlockedOnMissingPartnerData = $(if ([bool]$V.ConnectWindows) { $true } else { [bool]$V.windowsDeviceBlockedOnMissingPartnerData }) + macDeviceBlockedOnMissingPartnerData = [bool]$V.macDeviceBlockedOnMissingPartnerData + androidMobileApplicationManagementEnabled = [bool]$V.ConnectAndroidCompliance + iosMobileApplicationManagementEnabled = [bool]$V.ConnectIosCompliance + windowsMobileApplicationManagementEnabled = [bool]$V.windowsMobileApplicationManagementEnabled + allowPartnerToCollectIosCertificateMetadata = [bool]$V.allowPartnerToCollectIosCertificateMetadata + allowPartnerToCollectIosPersonalCertificateMetadata = [bool]$V.allowPartnerToCollectIosPersonalCertificateMetadata + grantMobileThreatDefensePartnerRole = [bool]$V.grantMobileThreatDefensePartnerRole + microsoftDefenderForEndpointAttachEnabled = $true + } + $Current = [PSCustomObject]@{} + foreach ($Key in $Keys) { + $Current | Add-Member -NotePropertyName $Key -NotePropertyValue ([bool]$ConnectorState.$Key) + } + # Carried for the executor. + $Current | Add-Member -NotePropertyName 'connectorExists' -NotePropertyValue ($null -ne $ConnectorState) + + @{ Expected = $Expected; Current = $Current } +} diff --git a/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineDefenderEDRPolicyState.ps1 b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineDefenderEDRPolicyState.ps1 new file mode 100644 index 0000000000000..136c5fdb87c46 --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineDefenderEDRPolicyState.ps1 @@ -0,0 +1,54 @@ +function Get-CIPPBaselineDefenderEDRPolicyState { + <# + .SYNOPSIS + Prepare hook for DefenderEDRPolicy: the 'EDR Configuration' settings catalog policy. + .DESCRIPTION + Two settings grade, exactly as the classic read them: sample sharing (enabled = _1 + suffix) and the configuration type, which is correct only when it is + auto-from-connector. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Item, + $TenantFilter + ) + + $Policies = @(Get-CIPPBaselineCacheRows -TenantFilter $TenantFilter -Type 'IntuneConfigurationPolicies') + if ($Policies.Count -eq 0 -and -not (Test-CIPPBaselineCacheCollected -TenantFilter $TenantFilter -Type 'IntuneConfigurationPolicies')) { + return @{ Current = $null } + } + $Policy = @($Policies | Where-Object { "$($_.name)" -eq 'EDR Configuration' }) | Select-Object -First 1 + + $CurrentConfig = $false + $CurrentSampleSharing = $false + foreach ($Setting in @($Policy.settings)) { + $Instance = $Setting.settingInstance + switch ("$($Instance.settingDefinitionId)") { + 'device_vendor_msft_windowsadvancedthreatprotection_configuration_samplesharing' { + $CurrentSampleSharing = "$($Instance.choiceSettingValue.value)" -like '*_1' + } + 'device_vendor_msft_windowsadvancedthreatprotection_configurationtype' { + $CurrentConfig = "$($Instance.choiceSettingValue.value)" -like '*_autofromconnector' + } + } + } + + $Current = [PSCustomObject]@{ + policyExists = ($null -ne $Policy) + config = [bool]$CurrentConfig + sampleSharing = [bool]$CurrentSampleSharing + } + # Carried for the executor. + $Current | Add-Member -NotePropertyName 'policyId' -NotePropertyValue "$($Policy.id)" + + @{ + Expected = [PSCustomObject]@{ + policyExists = $true + config = [bool]$Item.Variables.Config + sampleSharing = [bool]$Item.Variables.SampleSharing + } + Current = $Current + } +} diff --git a/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineDefenderExclusionPolicyState.ps1 b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineDefenderExclusionPolicyState.ps1 new file mode 100644 index 0000000000000..bc6b3b0155709 --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineDefenderExclusionPolicyState.ps1 @@ -0,0 +1,73 @@ +function Get-CIPPBaselineDefenderExclusionPolicyState { + <# + .SYNOPSIS + Prepare hook for DefenderExclusionPolicy: the 'Default AV Exclusion Policy' settings + catalog policy. + .DESCRIPTION + Grades the three exclusion collections (extensions, paths, processes) as SORTED sets + against the cached policy's simpleSettingCollectionValues - the classic compared + sorted arrays with Compare-Object, so order never matters and any missing or extra + entry is drift. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Item, + $TenantFilter + ) + + $Policies = @(Get-CIPPBaselineCacheRows -TenantFilter $TenantFilter -Type 'IntuneConfigurationPolicies') + if ($Policies.Count -eq 0 -and -not (Test-CIPPBaselineCacheCollected -TenantFilter $TenantFilter -Type 'IntuneConfigurationPolicies')) { + return @{ Current = $null } + } + $Policy = @($Policies | Where-Object { "$($_.name)" -eq 'Default AV Exclusion Policy' }) | Select-Object -First 1 + + $V = $Item.Variables + $ExpectedExtensions = @(("$($V.excludedExtensions)" -replace ' ', '') -split ',' | Where-Object { $_ } | Sort-Object) + $ExpectedPaths = @("$($V.excludedPaths)" -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ } | Sort-Object) + $ExpectedProcesses = @("$($V.excludedProcesses)" -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ } | Sort-Object) + # Nothing configured means nothing to enforce: a settings catalog policy with zero + # exclusions never materializes, so grading policyExists against an all-empty config + # demanded a policy the write cannot produce. + if ($ExpectedExtensions.Count -eq 0 -and $ExpectedPaths.Count -eq 0 -and $ExpectedProcesses.Count -eq 0) { + return @{ Current = $null } + } + + $CurrentExtensions = @() + $CurrentPaths = @() + $CurrentProcesses = @() + foreach ($Setting in @($Policy.settings)) { + $Instance = $Setting.settingInstance + switch ("$($Instance.settingDefinitionId)") { + 'device_vendor_msft_policy_config_defender_excludedextensions' { + $CurrentExtensions = @($Instance.simpleSettingCollectionValue | ForEach-Object { "$($_.value)" } | Sort-Object) + } + 'device_vendor_msft_policy_config_defender_excludedpaths' { + $CurrentPaths = @($Instance.simpleSettingCollectionValue | ForEach-Object { "$($_.value)" } | Sort-Object) + } + 'device_vendor_msft_policy_config_defender_excludedprocesses' { + $CurrentProcesses = @($Instance.simpleSettingCollectionValue | ForEach-Object { "$($_.value)" } | Sort-Object) + } + } + } + + $Current = [PSCustomObject]@{ + policyExists = ($null -ne $Policy) + excludedExtensions = @($CurrentExtensions) + excludedPaths = @($CurrentPaths) + excludedProcesses = @($CurrentProcesses) + } + # Carried for the executor. + $Current | Add-Member -NotePropertyName 'policyId' -NotePropertyValue "$($Policy.id)" + + @{ + Expected = [PSCustomObject]@{ + policyExists = $true + excludedExtensions = @($ExpectedExtensions) + excludedPaths = @($ExpectedPaths) + excludedProcesses = @($ExpectedProcesses) + } + Current = $Current + } +} diff --git a/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineDelegateSentItemsState.ps1 b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineDelegateSentItemsState.ps1 new file mode 100644 index 0000000000000..b9ff7e5a57f95 --- /dev/null +++ b/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/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineDeployCheckChromeExtensionState.ps1 b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineDeployCheckChromeExtensionState.ps1 new file mode 100644 index 0000000000000..61914dc560ce0 --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineDeployCheckChromeExtensionState.ps1 @@ -0,0 +1,32 @@ +function Get-CIPPBaselineDeployCheckChromeExtensionState { + <# + .SYNOPSIS + Prepare hook for DeployCheckChromeExtension: the Check by CyberDrain Win32 app. + .DESCRIPTION + Grades PRESENCE of the 'Check by CyberDrain - Browser Extension' Win32 app, exactly + what the classic reported. Settings drift is the EXECUTOR's job: the app description + carries a config fingerprint and the executor redeploys only when it changes, so the + definition runs checkBeforeRun:false and remediation self-gates - grading the hash + here would change what the classic surfaced as drift. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Item, + $TenantFilter + ) + + $Apps = @(Get-CIPPBaselineCacheRows -TenantFilter $TenantFilter -Type 'IntuneMobileApps') + if ($Apps.Count -eq 0 -and -not (Test-CIPPBaselineCacheCollected -TenantFilter $TenantFilter -Type 'IntuneMobileApps')) { + return @{ Current = $null } + } + # The cache $select drops @odata.type on some rows, so match on the CIPP-authored + # display name alone - it is unique to this deployment. + $App = @($Apps | Where-Object { "$($_.displayName)" -eq 'Check by CyberDrain - Browser Extension' }) | Select-Object -First 1 + + @{ + Expected = [PSCustomObject]@{ appDeployed = $true } + Current = [PSCustomObject]@{ appDeployed = ($null -ne $App) } + } +} diff --git a/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineDeployContactTemplatesState.ps1 b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineDeployContactTemplatesState.ps1 new file mode 100644 index 0000000000000..923f73a95440b --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineDeployContactTemplatesState.ps1 @@ -0,0 +1,113 @@ +function Get-CIPPBaselineDeployContactTemplatesState { + <# + .SYNOPSIS + Prepare hook for DeployContactTemplates: is this instance's mail contact deployed + and in sync. + .DESCRIPTION + One instance grades ONE contact - a contact template IS one contact. Matched on + DisplayName, then diffed field-by-field with the classic's exact rules: + + - email compares case-insensitively against ExternalEmailAddress (the cache already + strips the SMTP: prefix and lowercases, so the template side is lowered here too) + - hidefromGAL is a BOOLEAN and is enforced in both directions + - every other field is enforced only when the template specifies a value; an empty + template field expresses no opinion, and grading it would strip operator data + + The ExoMailContacts cache merges Get-Contact's extended properties (Company, City, + Phone...) flat onto each row, so no per-contact live read is needed. + + A template with no displayName, no email, or an invalid email address cannot be + evaluated or deployed - the classic skipped it silently; here it reports No Data. + + Template resolution stays per-family: PartitionKey 'ContactTemplate' - one of the + three partitions that do NOT match their standard name. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Item, + $TenantFilter + ) + + $Contacts = @(Get-CIPPBaselineCacheRows -TenantFilter $TenantFilter -Type 'ExoMailContacts') + if ($Contacts.Count -eq 0 -and -not (Test-CIPPBaselineCacheCollected -TenantFilter $TenantFilter -Type 'ExoMailContacts')) { + return @{ Current = $null } + } + + $Reference = $Item.Variables.contactTemplate + if ($Reference -is [System.Management.Automation.PSCustomObject] -and $Reference.PSObject.Properties.Name -contains 'value') { $Reference = $Reference.value } + if ([string]::IsNullOrWhiteSpace("$Reference")) { return @{ Current = $null } } + + $Table = Get-CippTable -tablename 'templates' + $SafeReference = ConvertTo-CIPPODataFilterValue -Value "$Reference" + $Entity = Get-CIPPAzDataTableEntity @Table -Filter "PartitionKey eq 'ContactTemplate' and RowKey eq '$SafeReference'" | Select-Object -First 1 + $Template = $(if ($Entity -and -not [string]::IsNullOrWhiteSpace($Entity.JSON)) { try { $Entity.JSON | ConvertFrom-Json -Depth 20 -ErrorAction Stop } catch { $null } }) + if (-not $Template) { return @{ Current = $null } } + + $ContactName = "$($Template.displayName)" + $Email = "$($Template.email)" + if ([string]::IsNullOrWhiteSpace($ContactName) -or [string]::IsNullOrWhiteSpace($Email)) { + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "Contact template '$Reference' is missing a display name or email address and cannot be evaluated." -Sev 'Error' + return @{ Current = $null } + } + try { $null = [System.Net.Mail.MailAddress]::new($Email) } catch { + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "Contact template '$ContactName' has an invalid email address '$Email' and cannot be evaluated." -Sev 'Error' + return @{ Current = $null } + } + + $Existing = $Contacts | Where-Object { "$($_.DisplayName)" -eq $ContactName } | Select-Object -First 1 + + if (-not $Existing) { + $Current = [PSCustomObject]@{ deployed = $false; drift = @() } + $Current | Add-Member -NotePropertyName 'templateBody' -NotePropertyValue $Template + $Current | Add-Member -NotePropertyName 'existingContact' -NotePropertyValue $null + return @{ + Expected = [PSCustomObject]@{ deployed = $true; drift = @() } + Current = $Current + } + } + + $FieldMap = @( + @{ Template = 'email'; Current = "$($Existing.ExternalEmailAddress)"; IsEmail = $true } + @{ Template = 'firstName'; Current = "$($Existing.FirstName)" } + @{ Template = 'lastName'; Current = "$($Existing.LastName)" } + @{ Template = 'mailTip'; Current = "$($Existing.MailTip)" } + @{ Template = 'hidefromGAL'; Current = $Existing.HiddenFromAddressListsEnabled; IsBool = $true } + @{ Template = 'companyName'; Current = "$($Existing.Company)" } + @{ Template = 'state'; Current = "$($Existing.StateOrProvince)" } + @{ Template = 'streetAddress'; Current = "$($Existing.StreetAddress)" } + @{ Template = 'businessPhone'; Current = "$($Existing.Phone)" } + @{ Template = 'website'; Current = "$($Existing.WebPage)" } + @{ Template = 'jobTitle'; Current = "$($Existing.Title)" } + @{ Template = 'city'; Current = "$($Existing.City)" } + @{ Template = 'postalCode'; Current = "$($Existing.PostalCode)" } + @{ Template = 'country'; Current = "$($Existing.CountryOrRegion)" } + @{ Template = 'mobilePhone'; Current = "$($Existing.MobilePhone)" } + ) + $Differences = [System.Collections.Generic.List[string]]::new() + foreach ($Field in $FieldMap) { + $TemplateValue = $Template.($Field.Template) + if ($Field.IsBool) { + if ([bool]$TemplateValue -ne [bool]$Field.Current) { $Differences.Add($Field.Template) } + continue + } + if ([string]::IsNullOrWhiteSpace("$TemplateValue")) { continue } + $Mismatch = if ($Field.IsEmail) { + [string]::IsNullOrWhiteSpace($Field.Current) -or -not "$TemplateValue".Equals($Field.Current, [System.StringComparison]::OrdinalIgnoreCase) + } else { + [string]::IsNullOrWhiteSpace($Field.Current) -or "$TemplateValue" -ne $Field.Current + } + if ($Mismatch) { $Differences.Add($Field.Template) } + } + + $Current = [PSCustomObject]@{ deployed = $true; drift = @($Differences) } + # Carried for the executor, not graded. + $Current | Add-Member -NotePropertyName 'templateBody' -NotePropertyValue $Template + $Current | Add-Member -NotePropertyName 'existingContact' -NotePropertyValue $Existing + + @{ + Expected = [PSCustomObject]@{ deployed = $true; drift = @() } + Current = $Current + } +} diff --git a/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineDetectCADriftState.ps1 b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineDetectCADriftState.ps1 index dc97df5f5dd1f..04f7746048fad 100644 --- a/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineDetectCADriftState.ps1 +++ b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineDetectCADriftState.ps1 @@ -17,24 +17,41 @@ 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") } + # SharePoint CREATES '[SharePoint admin center]...' CA policies itself when the + # unmanaged-device access standards turn on app-enforced restrictions (SPO/OWA + # ConditionalAccessPolicy). Those policies match no template by definition - they are a + # managed SIDE-EFFECT, not admin drift, whenever one of those standards applies to + # this tenant. Without a creating standard they stay flagged: then somebody clicked + # the admin center by hand. + $SharePointAccessStandards = @('unmanagedSync', 'OWAAttachmentRestrictions') + $SharePointAccessManaged = @($WorkItems | Where-Object { $_.BaseName -in $SharePointAccessStandards }).Count -gt 0 + $CacheMeta = $(try { Get-CIPPDbItem -TenantFilter $TenantFilter -Type 'ConditionalAccessPolicies' -CountsOnly } catch { $null }) if ($null -eq $CacheMeta) { # Never collected: the engine's collector-on-miss / No Data semantics take over. @@ -48,6 +65,7 @@ function Get-CIPPBaselineDetectCADriftState { $PolicyName = "$($Policy.displayName)" if (-not $PolicyName) { continue } if ($ManagedNames.Contains($PolicyName)) { continue } + if ($SharePointAccessManaged -and $PolicyName.StartsWith('[SharePoint admin center]')) { continue } $Expected | Add-Member -NotePropertyName $PolicyName -NotePropertyValue $null -Force $Current | Add-Member -NotePropertyName $PolicyName -NotePropertyValue ([PSCustomObject]@{ state = "$($Policy.state)" diff --git a/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineDevicePrepProfileState.ps1 b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineDevicePrepProfileState.ps1 new file mode 100644 index 0000000000000..98bbd953c2f36 --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineDevicePrepProfileState.ps1 @@ -0,0 +1,137 @@ +function Get-CIPPBaselineDevicePrepProfileState { + <# + .SYNOPSIS + Prepare hook for DevicePrepProfile: one named Autopilot Device Preparation profile. + .DESCRIPTION + Finds the configured profile by name in the IntuneConfigurationPolicies cache and + parses the enrollment_autopilot_dpp settings back into the classic's property set. + The device security group resolves by name LIVE (the classic did the same lookup for + its expected value); a group that does not exist yet grades as an empty id - the + executor creates it when CreateNewGroup allows. + + The assignment grades separately through Compare-CIPPIntuneAssignments off the cached + assignments; a failed or unknown lookup leaves the dimension out entirely, because a + deviation no run can clear is worse than a blind spot. The settings verdict is + carried for the executor so it can repair a wrong assignment IN PLACE instead of + recreating the profile. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Item, + $TenantFilter + ) + + $Policies = @(Get-CIPPBaselineCacheRows -TenantFilter $TenantFilter -Type 'IntuneConfigurationPolicies') + if ($Policies.Count -eq 0 -and -not (Test-CIPPBaselineCacheCollected -TenantFilter $TenantFilter -Type 'IntuneConfigurationPolicies')) { + return @{ Current = $null } + } + + $V = $Item.Variables + # The identity may arrive as an option object ({label, value}) from some save paths. + $ProfileName = "$($V.ProfileName.value ?? $V.ProfileName)" + if ([string]::IsNullOrWhiteSpace($ProfileName)) { return @{ Current = $null } } + $Policy = @($Policies | Where-Object { "$($_.name)" -eq $ProfileName }) | Select-Object -First 1 + + $DeploymentType = [string]($V.DeploymentType.value ?? $V.DeploymentType ?? '0') + $JoinType = [string]($V.JoinType.value ?? $V.JoinType ?? '0') + $AccountType = [string]($V.AccountType.value ?? $V.AccountType ?? '0') + # Empty string means unset, exactly like a pruned remediate key: '' ?? falls through + # and [int]'' is 0, which graded timeout 0 against the 60 the executor writes. + $Timeout = if ([string]::IsNullOrWhiteSpace("$($V.Timeout)")) { 60 } else { [int]"$($V.Timeout)" } + $CustomErrorMessage = if ([string]::IsNullOrWhiteSpace("$($V.CustomErrorMessage)")) { "Contact your organization$([char]0x2019)s support person for help." } else { "$($V.CustomErrorMessage)" } + $AllowSkip = $(if ($V.AllowSkip -eq $true) { '1' } else { '0' }) + $AllowDiagnostics = $(if ($V.AllowDiagnostics -eq $true) { '1' } else { '0' }) + $AssignTo = [string]($V.AssignTo.value ?? $V.AssignTo ?? 'none') + + # Resolve the device security group by name, exactly the classic's lookup. Creation is + # the executor's job - a compare must never write. + $DeviceGroupId = '' + if (-not [string]::IsNullOrWhiteSpace("$($V.DeviceGroupName)")) { + try { + $EscapedName = "$($V.DeviceGroupName)" -replace "'", "''" + $GroupFilter = [System.Uri]::EscapeDataString("startsWith(displayName,'$EscapedName') and mailEnabled eq false and securityEnabled eq true") + $MatchedGroups = @(New-GraphGetRequest -uri "https://graph.microsoft.com/beta/groups?`$select=id,displayName&`$filter=$GroupFilter" -tenantid $TenantFilter) + if ($MatchedGroups.Count -gt 0) { $DeviceGroupId = "$($MatchedGroups[0].id)" } + } catch { + Write-Information "Baselines: DevicePrepProfile group lookup for '$($V.DeviceGroupName)' failed: $($_.Exception.Message)" + } + } + + $ChoiceSettingMap = @{ + 'enrollment_autopilot_dpp_deploymentmode' = 'deploymentMode' + 'enrollment_autopilot_dpp_deploymenttype' = 'deploymentType' + 'enrollment_autopilot_dpp_jointype' = 'joinType' + 'enrollment_autopilot_dpp_accountype' = 'accountType' + 'enrollment_autopilot_dpp_allowskip' = 'allowSkip' + 'enrollment_autopilot_dpp_allowdiagnostics' = 'allowDiagnostics' + } + $SimpleSettingMap = @{ + 'enrollment_autopilot_dpp_timeout' = 'timeout' + 'enrollment_autopilot_dpp_customerrormessage' = 'customErrorMessage' + 'enrollment_autopilot_dpp_devicesecuritygroupids' = 'deviceGroupId' + } + $Parsed = @{} + foreach ($Setting in @($Policy.settings)) { + $Instance = $Setting.settingInstance + $DefId = "$($Instance.settingDefinitionId)" + if ($ChoiceSettingMap.ContainsKey($DefId)) { + $Parsed[$ChoiceSettingMap[$DefId]] = ("$($Instance.choiceSettingValue.value)" -split '_')[-1] + } elseif ($SimpleSettingMap.ContainsKey($DefId)) { + $Parsed[$SimpleSettingMap[$DefId]] = $Instance.simpleSettingValue.value + } + } + + $Expected = [PSCustomObject]@{ + profileExists = $true + deploymentMode = '0' + deploymentType = $DeploymentType + joinType = $JoinType + accountType = $AccountType + timeout = $Timeout + customErrorMessage = $CustomErrorMessage + allowSkip = $AllowSkip + allowDiagnostics = $AllowDiagnostics + deviceGroupId = $DeviceGroupId + } + $Current = [PSCustomObject]@{ + profileExists = ($null -ne $Policy) + deploymentMode = [string]($Parsed.deploymentMode ?? '') + deploymentType = [string]($Parsed.deploymentType ?? '') + joinType = [string]($Parsed.joinType ?? '') + accountType = [string]($Parsed.accountType ?? '') + timeout = [int]($Parsed.timeout ?? 0) + customErrorMessage = [string]($Parsed.customErrorMessage ?? '') + allowSkip = [string]($Parsed.allowSkip ?? '') + allowDiagnostics = [string]($Parsed.allowDiagnostics ?? '') + deviceGroupId = [string]($Parsed.deviceGroupId ?? '') + } + + # Assignment dimension: graded only when requested AND readable. + $SettingsMatchedSoFar = $null -ne $Policy + if ($SettingsMatchedSoFar -and $AssignTo -ne 'none') { + try { + $AssignmentDetail = Compare-CIPPIntuneAssignments -ExistingAssignments @($Policy.assignments) -ExpectedAssignTo $AssignTo -PolicyType 'DevicePrepProfile' -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: DevicePrepProfile assignment compare failed: $($_.Exception.Message)" + } + } + + # Carried for the executor: repair-in-place needs to know the settings verdict alone. + $SettingsCorrect = $null -ne $Policy + if ($SettingsCorrect) { + foreach ($Prop in @('deploymentMode', 'deploymentType', 'joinType', 'accountType', 'allowSkip', 'allowDiagnostics', 'customErrorMessage', 'deviceGroupId')) { + if ("$($Current.$Prop)" -ne "$($Expected.$Prop)") { $SettingsCorrect = $false; break } + } + if ($SettingsCorrect -and [int]$Current.timeout -ne [int]$Expected.timeout) { $SettingsCorrect = $false } + } + $Current | Add-Member -NotePropertyName 'policyId' -NotePropertyValue "$($Policy.id)" + $Current | Add-Member -NotePropertyName 'settingsCorrect' -NotePropertyValue $SettingsCorrect + + @{ Expected = $Expected; Current = $Current } +} diff --git a/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineDeviceRegistrationPolicyState.ps1 b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineDeviceRegistrationPolicyState.ps1 new file mode 100644 index 0000000000000..7b4bed4303640 --- /dev/null +++ b/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/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineDisableBasicAuthSMTPState.ps1 b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineDisableBasicAuthSMTPState.ps1 new file mode 100644 index 0000000000000..f7ed842aeb316 --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineDisableBasicAuthSMTPState.ps1 @@ -0,0 +1,48 @@ +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 = @(Get-CIPPBaselineCacheRows -TenantFilter $TenantFilter -Type 'ExoCASMailboxSmtpAuth') + $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/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineDisableExchangeOnlinePowerShellState.ps1 b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineDisableExchangeOnlinePowerShellState.ps1 new file mode 100644 index 0000000000000..cffec7d06026c --- /dev/null +++ b/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/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineDisableGuestsState.ps1 b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineDisableGuestsState.ps1 new file mode 100644 index 0000000000000..a014a5bfc03f6 --- /dev/null +++ b/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/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineDisableInactiveUsersState.ps1 b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineDisableInactiveUsersState.ps1 new file mode 100644 index 0000000000000..d3e8ea5258454 --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineDisableInactiveUsersState.ps1 @@ -0,0 +1,69 @@ +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 }) + } + + # Operator-excluded accounts (breakglass, service accounts) are never offenders - + # disabling a breakglass account through an inactivity sweep is how lockouts happen. + $ExcludedUsers = @(@($Item.Variables.excludedUsers) | ForEach-Object { "$($_.value ?? $_)" } | Where-Object { $_ }) + if ($ExcludedUsers.Count -gt 0) { + $Inactive = @($Inactive | Where-Object { "$($_.userPrincipalName)" -notin $ExcludedUsers }) + } + + @{ + Current = [PSCustomObject]@{ + offenders = @($Inactive.userPrincipalName | Sort-Object) + targets = @($Inactive | ForEach-Object { [PSCustomObject]@{ id = "$($_.id)" } }) + } + } +} diff --git a/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineDisableM365GroupUsersState.ps1 b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineDisableM365GroupUsersState.ps1 new file mode 100644 index 0000000000000..5ca58d9883e51 --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineDisableM365GroupUsersState.ps1 @@ -0,0 +1,49 @@ +function Get-CIPPBaselineDisableM365GroupUsersState { + <# + .SYNOPSIS + Prepare hook for DisableM365GroupUsers: is user-driven M365 group creation off. + .DESCRIPTION + Grades EnableGroupCreation on the Group.Unified directory setting, plus - only when + an allowed group is configured - whether GroupCreationAllowedGroupId points at that + group. The group resolves by display name from the Groups cache, since ids differ + per tenant. A configured name that resolves to nothing grades as drift so it + surfaces, exactly as the classic decided. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Item, + $TenantFilter + ) + + $Settings = @(Get-CIPPBaselineCacheRows -TenantFilter $TenantFilter -Type 'Settings') + if ($Settings.Count -eq 0 -and -not (Test-CIPPBaselineCacheCollected -TenantFilter $TenantFilter -Type 'Settings')) { + return @{ Current = $null } + } + + $GroupSetting = @($Settings | Where-Object { "$($_.displayName)" -eq 'Group.Unified' }) | Select-Object -First 1 + $ValueOf = { param($Name) "$((@($GroupSetting.values) | Where-Object { $_.name -eq $Name }).value)" } + + $AllowedGroupName = "$($Item.Variables.AllowedGroupName)" + $DesiredGroupId = $null + if (-not [string]::IsNullOrWhiteSpace($AllowedGroupName)) { + $Groups = @(Get-CIPPBaselineCacheRows -TenantFilter $TenantFilter -Type 'Groups') + $DesiredGroupId = "$((@($Groups | Where-Object { "$($_.displayName)" -eq $AllowedGroupName }) | Select-Object -First 1).id)" + } + + $Expected = [PSCustomObject]@{ groupCreationDisabled = $true } + $Current = [PSCustomObject]@{ + groupCreationDisabled = [bool]($GroupSetting -and (& $ValueOf 'EnableGroupCreation') -eq 'false') + } + if (-not [string]::IsNullOrWhiteSpace($AllowedGroupName)) { + $Expected | Add-Member -NotePropertyName 'allowedGroupCorrect' -NotePropertyValue $true + $Current | Add-Member -NotePropertyName 'allowedGroupCorrect' -NotePropertyValue ([bool]( + -not [string]::IsNullOrWhiteSpace($DesiredGroupId) -and (& $ValueOf 'GroupCreationAllowedGroupId') -eq $DesiredGroupId)) + } + # Carried for the executor. + $Current | Add-Member -NotePropertyName 'settingId' -NotePropertyValue "$($GroupSetting.id)" + $Current | Add-Member -NotePropertyName 'resolvedGroupId' -NotePropertyValue $DesiredGroupId + + @{ Expected = $Expected; Current = $Current } +} diff --git a/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineDisableOutlookAddinsState.ps1 b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineDisableOutlookAddinsState.ps1 new file mode 100644 index 0000000000000..bc643d5d1d9d7 --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineDisableOutlookAddinsState.ps1 @@ -0,0 +1,37 @@ +function Get-CIPPBaselineDisableOutlookAddinsState { + <# + .SYNOPSIS + Prepare hook for DisableOutlookAddins: user add-in install roles on the default + role assignment policy. + .DESCRIPTION + Reads the default role assignment policy live (one small object, no cache) and + grades whether any of the three app-install roles - My Custom Apps, My Marketplace + Apps, My ReadWriteMailbox Apps - are still assigned. Any present role means users + can install add-ins, the classic's exact check. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Item, + $TenantFilter + ) + + $Policy = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-RoleAssignmentPolicy' | Where-Object { $_.IsDefault -eq $true } | Select-Object -First 1 + if (-not $Policy) { return @{ Current = $null } } + + $Roles = @('My Custom Apps', 'My Marketplace Apps', 'My ReadWriteMailbox Apps') + $RolesToRemove = @($Roles | Where-Object { @($Policy.AssignedRoles) -contains $_ }) + + $Current = [PSCustomObject]@{ + disabledOutlookAddins = ($RolesToRemove.Count -eq 0) + } + # Carried for the executor. + $Current | Add-Member -NotePropertyName 'policyIdentity' -NotePropertyValue "$($Policy.Identity)" + $Current | Add-Member -NotePropertyName 'rolesToRemove' -NotePropertyValue @($RolesToRemove) + + @{ + Expected = [PSCustomObject]@{ disabledOutlookAddins = $true } + Current = $Current + } +} diff --git a/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineDisableResourceMailboxState.ps1 b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineDisableResourceMailboxState.ps1 new file mode 100644 index 0000000000000..a1fad85cb0527 --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineDisableResourceMailboxState.ps1 @@ -0,0 +1,49 @@ +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 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 } } + + $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/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineDisableSelfServiceLicensesState.ps1 b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineDisableSelfServiceLicensesState.ps1 new file mode 100644 index 0000000000000..438257f6c0edd --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineDisableSelfServiceLicensesState.ps1 @@ -0,0 +1,63 @@ +function Get-CIPPBaselineDisableSelfServiceLicensesState { + <# + .SYNOPSIS + Prepare hook for DisableSelfServiceLicenses: self-service purchase posture across + every product. + .DESCRIPTION + Grades three surfaces the classic graded: every self-service purchasable product + must be Disabled (excluded product ids stay Enabled - the operator's allow-list), + email-based subscription signup must be off, and - when trials are disabled - the + trial autoclaim policy must be off. + + The products and autoclaim live OUTSIDE Graph (licensing.m365.microsoft.com and + admin.microsoft.com, each with its own token scope); the cache collector already + speaks both, and the products list requires the Billing Administrator GDAP role - + a 403 there parks the row at No Data rather than inventing a verdict. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Item, + $TenantFilter + ) + + $Products = @(Get-CIPPBaselineCacheRows -TenantFilter $TenantFilter -Type 'SelfServicePurchaseProducts') + if ($Products.Count -eq 0 -and -not (Test-CIPPBaselineCacheCollected -TenantFilter $TenantFilter -Type 'SelfServicePurchaseProducts')) { + return @{ Current = $null } + } + $AuthPolicy = @(Get-CIPPBaselineCacheRows -TenantFilter $TenantFilter -Type 'AuthorizationPolicy') | Select-Object -First 1 + + $Exclusions = @("$($Item.Variables.Exclusions)" -split '[,;]' | ForEach-Object { $_.Trim() } | Where-Object { $_ }) + $DisableTrials = [bool]($Item.Variables.DisableTrials -eq $true) + + $Offenders = [System.Collections.Generic.List[object]]::new() + foreach ($Product in $Products) { + $Id = "$($Product.productId)" + if ($Id -eq 'autoclaim') { + if ($DisableTrials -and "$($Product.policyValue)" -ne 'Disabled') { + $Offenders.Add([PSCustomObject]@{ productId = 'autoclaim'; productName = 'Trial Autoclaim'; policyValue = 'Disabled' }) + } + continue + } + $Desired = if ($Id -in $Exclusions) { 'Enabled' } else { 'Disabled' } + if ("$($Product.policyValue)" -ne $Desired) { + $Offenders.Add([PSCustomObject]@{ productId = $Id; productName = "$($Product.productName)"; policyValue = $Desired }) + } + } + if ($AuthPolicy -and [bool]$AuthPolicy.allowedToSignUpEmailBasedSubscriptions) { + $Offenders.Add([PSCustomObject]@{ productId = 'allowedToSignUpEmailBasedSubscriptions'; productName = 'Email Based Subscriptions'; policyValue = 'Disabled' }) + } + + $Current = [PSCustomObject]@{ + productsOutOfPolicy = @($Offenders | ForEach-Object { "$($_.productName)" } | Sort-Object) + } + # Carried for the executor: each offender knows its target value and its endpoint is + # picked by product id. + $Current | Add-Member -NotePropertyName 'offenders' -NotePropertyValue @($Offenders) + + @{ + Expected = [PSCustomObject]@{ productsOutOfPolicy = @() } + Current = $Current + } +} diff --git a/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineDisableSharedMailboxState.ps1 b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineDisableSharedMailboxState.ps1 new file mode 100644 index 0000000000000..0dfea4da2f76f --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineDisableSharedMailboxState.ps1 @@ -0,0 +1,51 @@ +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 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 } } + + $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/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineDlpCompliancePolicyTemplateState.ps1 b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineDlpCompliancePolicyTemplateState.ps1 new file mode 100644 index 0000000000000..7f9c3cec98276 --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineDlpCompliancePolicyTemplateState.ps1 @@ -0,0 +1,61 @@ +function Get-CIPPBaselineDlpCompliancePolicyTemplateState { + <# + .SYNOPSIS + Prepare hook for DlpCompliancePolicyTemplate: this instance's DLP policy sync state. + .DESCRIPTION + One instance grades ONE template (instanceIdentity), resolved from the + DlpCompliancePolicyTemplate partition. Compare-CIPPDlpCompliancePolicy diffs the + template against the live policy and its rules field by field through the same + normalization the deploy path uses, so a policy deployed from the template + collapses to InSync. + + Missing, Drift and PendingDeletion are non-compliant; only Missing and Drift are + remediable - a PendingDeletion policy cannot be modified, so deploying it would + just fail and it is surfaced instead, exactly the classic's handling. The graded + list carries a compact projection (name, state, differing Scope/Field pairs); the + full template is carried separately for the executor. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Item, + $TenantFilter + ) + + $Reference = $Item.Variables.dlpCompliancePolicyTemplate + if ($Reference -is [System.Management.Automation.PSCustomObject] -and $Reference.PSObject.Properties.Name -contains 'value') { $Reference = $Reference.value } + if ([string]::IsNullOrWhiteSpace("$Reference")) { return @{ Current = $null } } + + $Table = Get-CippTable -tablename 'templates' + $SafeReference = ConvertTo-CIPPODataFilterValue -Value "$Reference" + $Entity = Get-CIPPAzDataTableEntity @Table -Filter "PartitionKey eq 'DlpCompliancePolicyTemplate' and RowKey eq '$SafeReference'" | Select-Object -First 1 + $Template = $(if ($Entity -and -not [string]::IsNullOrWhiteSpace($Entity.JSON)) { try { $Entity.JSON | ConvertFrom-Json -Depth 50 -ErrorAction Stop } catch { $null } }) + if (-not $Template) { return @{ Current = $null } } + + $Comparison = try { + Compare-CIPPDlpCompliancePolicy -TenantFilter $TenantFilter -Template $Template -ErrorAction Stop + } catch { + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "Could not compare DLP policy '$($Template.Name ?? $Template.name)': $($_.Exception.Message)" -Sev 'Error' + return @{ Current = $null } + } + if (-not $Comparison) { return @{ Current = $null } } + + $NonCompliant = @(if ("$($Comparison.State)" -ne 'InSync') { + [PSCustomObject]@{ + Name = "$($Comparison.Name)" + State = "$($Comparison.State)" + Fields = @($Comparison.Differences | ForEach-Object { "$($_.Scope)/$($_.Field)" }) + } + }) + $Remediable = @(if ("$($Comparison.State)" -in @('Missing', 'Drift')) { $Template }) + + $Current = [PSCustomObject]@{ nonCompliantDlpPolicies = @($NonCompliant) } + # Carried for the executor, not graded. + $Current | Add-Member -NotePropertyName 'remediableTemplates' -NotePropertyValue @($Remediable) + + @{ + Expected = [PSCustomObject]@{ nonCompliantDlpPolicies = @() } + Current = $Current + } +} diff --git a/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineEmailAsAlternateLoginIdState.ps1 b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineEmailAsAlternateLoginIdState.ps1 new file mode 100644 index 0000000000000..524ba2ae49ae0 --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineEmailAsAlternateLoginIdState.ps1 @@ -0,0 +1,46 @@ +function Get-CIPPBaselineEmailAsAlternateLoginIdState { + <# + .SYNOPSIS + Prepare hook for EmailAsAlternateLoginId: the org-default home realm discovery + policy's AlternateIdLogin flag. + .DESCRIPTION + Grades three facts the classic graded: the org-default HRD policy exists, it + EXPLICITLY carries the AlternateIdLogin setting, and the setting matches - in both + directions, since disabling email sign-in is as deliberate a posture as enabling it. + No explicit setting is not the same as disabled: the tenant is then on Microsoft's + default behaviour, which can change under you, so the classic required the policy to + say what it means. + + The setting lives in definition[0] as a JSON string inside the policy. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Item, + $TenantFilter + ) + + $Policies = @(Get-CIPPBaselineCacheRows -TenantFilter $TenantFilter -Type 'HomeRealmDiscoveryPolicy') + if ($Policies.Count -eq 0 -and -not (Test-CIPPBaselineCacheCollected -TenantFilter $TenantFilter -Type 'HomeRealmDiscoveryPolicy')) { + return @{ Current = $null } + } + + $Desired = [bool]($Item.Variables.Enabled -eq $true) + $Policy = @($Policies | Where-Object { $_.isOrganizationDefault -eq $true }) | Select-Object -First 1 + $Definition = $(if ($Policy.definition) { try { @($Policy.definition)[0] | ConvertFrom-Json -ErrorAction Stop } catch { $null } }) + $Raw = $Definition.HomeRealmDiscoveryPolicy.AlternateIdLogin.Enabled + + $Current = [PSCustomObject]@{ + policyExists = [bool]$Policy + hasExplicitSetting = ($null -ne $Raw) + alternateIdLoginEnabled = [bool]($null -ne $Raw -and [bool]$Raw) + } + # Carried for the executor: PATCH the existing policy or POST a new org default. + $Current | Add-Member -NotePropertyName 'policyId' -NotePropertyValue "$($Policy.id)" + + @{ + Expected = [PSCustomObject]@{ policyExists = $true; hasExplicitSetting = $true; alternateIdLoginEnabled = $Desired } + Current = $Current + } +} diff --git a/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineEnableAppConsentRequestsState.ps1 b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineEnableAppConsentRequestsState.ps1 new file mode 100644 index 0000000000000..cbadb4b959646 --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineEnableAppConsentRequestsState.ps1 @@ -0,0 +1,63 @@ +function Get-CIPPBaselineEnableAppConsentRequestsState { + <# + .SYNOPSIS + 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 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 + Internal + #> + [CmdletBinding()] + param( + $Item, + $TenantFilter + ) + + $Policy = @(Get-CIPPBaselineCacheRows -TenantFilter $TenantFilter -Type 'AdminConsentRequestPolicy') | Select-Object -First 1 + if (-not $Policy) { return @{ Current = $null } } + + $Roles = @(@($Item.Variables.ReviewerRoles) | ForEach-Object { "$($_.value ?? $_)" } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) + if ($Roles.Count -eq 0) { $Roles = @('62e90394-69f5-4237-9190-012177145e10') } + + $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 = @(); missingReviewerUsers = @() } + Current = [PSCustomObject]@{ + appConsentRequestsEnabled = [bool]$Policy.isEnabled + missingReviewerRoles = @($MissingRoles) + missingReviewerUsers = @($MissingUsers) + } + } +} diff --git a/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineEnableExchangeCloudManagementState.ps1 b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineEnableExchangeCloudManagementState.ps1 new file mode 100644 index 0000000000000..7cda7d00b452b --- /dev/null +++ b/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/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineEnableLitigationHoldState.ps1 b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineEnableLitigationHoldState.ps1 new file mode 100644 index 0000000000000..546ad28c3f6f9 --- /dev/null +++ b/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/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineEnableOnlineArchivingState.ps1 b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineEnableOnlineArchivingState.ps1 new file mode 100644 index 0000000000000..4066755686027 --- /dev/null +++ b/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/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineEnforcePrivateGroupsState.ps1 b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineEnforcePrivateGroupsState.ps1 new file mode 100644 index 0000000000000..02dba601cfb91 --- /dev/null +++ b/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/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineEnrollmentWindowsHelloForBusinessConfigurationState.ps1 b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineEnrollmentWindowsHelloForBusinessConfigurationState.ps1 new file mode 100644 index 0000000000000..be6526dd53f90 --- /dev/null +++ b/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/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineExchangeConnectorTemplateState.ps1 b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineExchangeConnectorTemplateState.ps1 new file mode 100644 index 0000000000000..c6c5ea735e445 --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineExchangeConnectorTemplateState.ps1 @@ -0,0 +1,57 @@ +function Get-CIPPBaselineExchangeConnectorTemplateState { + <# + .SYNOPSIS + Prepare hook for ExchangeConnectorTemplate: is this instance's connector deployed. + .DESCRIPTION + One instance grades ONE template. Grades PRESENCE BY NAME (Identity) only, matching + the classic - connector settings drift is repaired by the executor's Set- branch on + every remediation run (checkBeforeRun:false), which reapplies the full template. + + The connector's DIRECTION lives as a column on the template ENTITY, not inside its + JSON - the classic read $Template.direction off the row - and it decides both which + cache is consulted (ExoInboundConnector vs ExoOutboundConnector) and which cmdlet + family the executor calls. + + Template resolution stays per-family: PartitionKey 'ExConnectorTemplate' - one of + the three partitions that do NOT match their standard name. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Item, + $TenantFilter + ) + + $Reference = $Item.Variables.exConnectorTemplate + if ($Reference -is [System.Management.Automation.PSCustomObject] -and $Reference.PSObject.Properties.Name -contains 'value') { $Reference = $Reference.value } + if ([string]::IsNullOrWhiteSpace("$Reference")) { return @{ Current = $null } } + + $Table = Get-CippTable -tablename 'templates' + $SafeReference = ConvertTo-CIPPODataFilterValue -Value "$Reference" + $Entity = Get-CIPPAzDataTableEntity @Table -Filter "PartitionKey eq 'ExConnectorTemplate' and RowKey eq '$SafeReference'" | Select-Object -First 1 + if (-not $Entity -or [string]::IsNullOrWhiteSpace($Entity.JSON)) { return @{ Current = $null } } + $Body = $(try { $Entity.JSON | ConvertFrom-Json -Depth 20 -ErrorAction Stop } catch { $null }) + $Direction = "$($Entity.direction)".ToLower() + $ConnectorName = "$($Body.name)" + if (-not $Body -or [string]::IsNullOrWhiteSpace($ConnectorName) -or $Direction -notin @('inbound', 'outbound')) { return @{ Current = $null } } + + $CacheType = if ($Direction -eq 'inbound') { 'ExoInboundConnector' } else { 'ExoOutboundConnector' } + $Connectors = @(Get-CIPPBaselineCacheRows -TenantFilter $TenantFilter -Type $CacheType) + if ($Connectors.Count -eq 0 -and -not (Test-CIPPBaselineCacheCollected -TenantFilter $TenantFilter -Type $CacheType)) { + return @{ Current = $null } + } + + $Existing = $Connectors | Where-Object { "$($_.Identity)" -eq $ConnectorName } | Select-Object -First 1 + + $Current = [PSCustomObject]@{ deployed = [bool]$Existing } + # Carried for the executor, not graded. + $Current | Add-Member -NotePropertyName 'connectorBody' -NotePropertyValue $Body + $Current | Add-Member -NotePropertyName 'direction' -NotePropertyValue $Direction + $Current | Add-Member -NotePropertyName 'existingIdentity' -NotePropertyValue "$($Existing.Identity)" + + @{ + Expected = [PSCustomObject]@{ deployed = $true } + Current = $Current + } +} diff --git a/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineExcludedfileExtState.ps1 b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineExcludedfileExtState.ps1 new file mode 100644 index 0000000000000..7e8cd00d74aa2 --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineExcludedfileExtState.ps1 @@ -0,0 +1,33 @@ +function Get-CIPPBaselineExcludedfileExtState { + <# + .SYNOPSIS + Prepare hook for ExcludedfileExt: the OneDrive sync app's excluded file extensions. + .DESCRIPTION + Grades the excluded-extension SET exactly - the classic's remediation rewrote the + whole list (its count check made partial matches drift too), so extensions removed + from the baseline come OFF the tenant on remediation, matching the admin center's + replace semantics. Extensions normalize the way the admin center normalizes them: a + bare extension gains the *. prefix. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Item, + $TenantFilter + ) + + $Settings = @(Get-CIPPBaselineCacheRows -TenantFilter $TenantFilter -Type 'SharePointAdminSettings') | Select-Object -First 1 + if (-not $Settings) { return @{ Current = $null } } + + $Exts = @(("$($Item.Variables.ext)" -replace ' ', '') -split ',' | Where-Object { $_ } | ForEach-Object { + if ($_ -notlike '*.*') { "*.$_" } else { $_ } + } | Sort-Object -Unique) + if ($Exts.Count -eq 0) { return @{ Current = $null } } + + @{ + Expected = [PSCustomObject]@{ excludedExtensions = @($Exts) } + # No -Unique on the tenant side: the classic's count check made duplicates drift too. + Current = [PSCustomObject]@{ excludedExtensions = @($Settings.excludedFileExtensionsForSyncApp | Sort-Object) } + } +} diff --git a/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineExternalMFATrustedState.ps1 b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineExternalMFATrustedState.ps1 new file mode 100644 index 0000000000000..dd5a06cd14a16 --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineExternalMFATrustedState.ps1 @@ -0,0 +1,26 @@ +function Get-CIPPBaselineExternalMFATrustedState { + <# + .SYNOPSIS + Prepare hook for ExternalMFATrusted: does the tenant trust MFA from external tenants. + .DESCRIPTION + One graded boolean, read from the default cross-tenant access policy's inboundTrust. + A hook rather than a declarative expected because the operator's switch has to grade + in BOTH directions - trusting external MFA and deliberately not trusting it are both + valid postures, and the classic offered exactly that choice. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Item, + $TenantFilter + ) + + $Policy = @(Get-CIPPBaselineCacheRows -TenantFilter $TenantFilter -Type 'CrossTenantAccessPolicy') | Select-Object -First 1 + if (-not $Policy) { return @{ Current = $null } } + + @{ + Expected = [PSCustomObject]@{ isMfaAccepted = [bool]($Item.Variables.state -eq $true) } + Current = [PSCustomObject]@{ isMfaAccepted = [bool]$Policy.inboundTrust.isMfaAccepted } + } +} diff --git a/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineFIDO2PasskeyProfilesState.ps1 b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineFIDO2PasskeyProfilesState.ps1 new file mode 100644 index 0000000000000..042dc95eb3ce9 --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineFIDO2PasskeyProfilesState.ps1 @@ -0,0 +1,65 @@ +function Get-CIPPBaselineFIDO2PasskeyProfilesState { + <# + .SYNOPSIS + Prepare hook for FIDO2PasskeyProfiles: the default passkey profile's settings. + .DESCRIPTION + Grades the DEFAULT passkey profile only - other profiles are operator-managed and + never touched, which is also why the executor rebuilds the profile array preserving + them. Graded: passkey types, attestation enforcement, whether key restrictions are + enforced, their enforcement type, and the sorted AAGUID set. + + Enforcing key restrictions with no AAGUIDs would lock every authenticator out (allow + mode) or restrict nothing (block mode) - the classic refused the combination, and it + reports No Data here. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Item, + $TenantFilter + ) + + $Config = @(Get-CIPPBaselineCacheRows -TenantFilter $TenantFilter -Type 'Fido2Configuration' -CollectorType 'AuthenticationMethodsPolicy') | Select-Object -First 1 + if (-not $Config) { return @{ Current = $null } } + + $V = $Item.Variables + $PasskeyTypes = "$($V.PasskeyTypes.value ?? $V.PasskeyTypes)" + $Attestation = "$($V.AttestationEnforcement.value ?? $V.AttestationEnforcement)" + if ([string]::IsNullOrWhiteSpace($PasskeyTypes) -or [string]::IsNullOrWhiteSpace($Attestation)) { return @{ Current = $null } } + $EnforceRestrictions = [bool]($V.EnforceKeyRestrictions -eq $true) + $EnforcementType = "$($V.EnforcementType.value ?? $V.EnforcementType)" + if ([string]::IsNullOrWhiteSpace($EnforcementType)) { $EnforcementType = 'allow' } + $AAGUIDs = @("$($V.AAGUIDs)" -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ } | Sort-Object) + if ($EnforceRestrictions -and $AAGUIDs.Count -eq 0) { return @{ Current = $null } } + + $DefaultProfile = @($Config.passkeyProfiles) | Where-Object { "$($_.id)" -eq "$($Config.defaultPasskeyProfile)" } | Select-Object -First 1 + + $Current = if ($DefaultProfile) { + [PSCustomObject]@{ + passkeyTypes = "$($DefaultProfile.passkeyTypes)" + attestationEnforcement = "$($DefaultProfile.attestationEnforcement)" + keyRestrictionsEnforced = [bool]$DefaultProfile.keyRestrictions.isEnforced + enforcementType = "$($DefaultProfile.keyRestrictions.enforcementType)" + aaGuids = @($DefaultProfile.keyRestrictions.aaGuids | Sort-Object) + } + } else { + # No default profile at all: everything grades against empties, so the row shows + # exactly which facts a remediation would establish. + [PSCustomObject]@{ passkeyTypes = ''; attestationEnforcement = ''; keyRestrictionsEnforced = $false; enforcementType = ''; aaGuids = @() } + } + # Carried for the executor: the PATCH must resend the whole profile array. + $Current | Add-Member -NotePropertyName 'allProfiles' -NotePropertyValue @($Config.passkeyProfiles) + $Current | Add-Member -NotePropertyName 'defaultProfileId' -NotePropertyValue "$($Config.defaultPasskeyProfile)" + + @{ + Expected = [PSCustomObject]@{ + passkeyTypes = $PasskeyTypes + attestationEnforcement = $Attestation + keyRestrictionsEnforced = $EnforceRestrictions + enforcementType = $EnforcementType + aaGuids = @($AAGUIDs) + } + Current = $Current + } +} diff --git a/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineGlobalQuarantineNotificationsState.ps1 b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineGlobalQuarantineNotificationsState.ps1 new file mode 100644 index 0000000000000..087d9fe39e349 --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineGlobalQuarantineNotificationsState.ps1 @@ -0,0 +1,41 @@ +function Get-CIPPBaselineGlobalQuarantineNotificationsState { + <# + .SYNOPSIS + Prepare hook for GlobalQuarantineNotifications: the end-user notification interval. + .DESCRIPTION + The tenant stores the frequency as an ISO duration (PT4H/P1D/P7D) while the operator + picks a .NET timespan string - both sides normalize to hours so the compare is a + number, not two spellings of the same duration. An interval the tenant reports in + any other shape grades as -1: unknown is drift, and remediation writes the wanted + value. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Item, + $TenantFilter + ) + + $Policy = @(Get-CIPPBaselineCacheRows -TenantFilter $TenantFilter -Type 'ExoGlobalQuarantinePolicy') | Select-Object -First 1 + if (-not $Policy) { return @{ Current = $null } } + + $Wanted = "$($Item.Variables.NotificationInterval.value ?? $Item.Variables.NotificationInterval)" + $WantedHours = try { [int]([timespan]$Wanted).TotalHours } catch { return @{ Current = $null } } + + $CurrentHours = switch ("$($Policy.EndUserSpamNotificationFrequency)") { + 'PT4H' { 4 } + 'P1D' { 24 } + 'P7D' { 168 } + default { -1 } + } + + $Current = [PSCustomObject]@{ notificationIntervalHours = [int]$CurrentHours } + $Current | Add-Member -NotePropertyName 'policyName' -NotePropertyValue "$($Policy.Name)" + $Current | Add-Member -NotePropertyName 'policyIdentity' -NotePropertyValue "$($Policy.Identity)" + + @{ + Expected = [PSCustomObject]@{ notificationIntervalHours = [int]$WantedHours } + Current = $Current + } +} diff --git a/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineGlobalQuarantineSettingsState.ps1 b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineGlobalQuarantineSettingsState.ps1 new file mode 100644 index 0000000000000..ece2a80421444 --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineGlobalQuarantineSettingsState.ps1 @@ -0,0 +1,68 @@ +function Get-CIPPBaselineGlobalQuarantineSettingsState { + <# + .SYNOPSIS + Prepare hook for GlobalQuarantineSettings: sender/subject/disclaimer branding on the + global quarantine policy. + .DESCRIPTION + The per-language text fields grade as CONTAINS: the policy stores one value per + configured language and the classic checked the configured text is among them, + because remediation writes the same text into every language slot. + + Only configured fields grade. The classic graded unset fields too - '-contains + $null' is false whenever any value exists - which made an unset optional field + permanent drift and remediation would then write nulls over real branding. Grading + only what the operator expressed is the intended behaviour of those optional fields. + + The organization branding switch is a real boolean and always grades. + + The policy's language list is carried for the executor: the write must fan the text + out across exactly the languages the tenant has configured. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Item, + $TenantFilter + ) + + $Policy = @(Get-CIPPBaselineCacheRows -TenantFilter $TenantFilter -Type 'ExoGlobalQuarantinePolicy') | Select-Object -First 1 + if (-not $Policy) { return @{ Current = $null } } + + $V = $Item.Variables + $Expected = [PSCustomObject]@{} + $Current = [PSCustomObject]@{} + + $TextFields = @( + @{ Name = 'senderNamePresent'; Value = "$($V.SenderName)"; CurrentList = @($Policy.MultiLanguageSenderName) } + @{ Name = 'customSubjectPresent'; Value = "$($V.CustomSubject)"; CurrentList = @($Policy.ESNCustomSubject) } + @{ Name = 'customDisclaimerPresent'; Value = "$($V.CustomDisclaimer)"; CurrentList = @($Policy.MultiLanguageCustomDisclaimer) } + ) + foreach ($Field in $TextFields) { + if ([string]::IsNullOrWhiteSpace($Field.Value)) { continue } + $Expected | Add-Member -NotePropertyName $Field.Name -NotePropertyValue $true + $Current | Add-Member -NotePropertyName $Field.Name -NotePropertyValue ([bool]($Field.CurrentList -contains $Field.Value)) + } + if (-not [string]::IsNullOrWhiteSpace("$($V.FromAddress)")) { + $Expected | Add-Member -NotePropertyName 'fromAddress' -NotePropertyValue "$($V.FromAddress)" + $Current | Add-Member -NotePropertyName 'fromAddress' -NotePropertyValue "$($Policy.EndUserSpamNotificationCustomFromAddress)" + } + $Expected | Add-Member -NotePropertyName 'organizationBrandingEnabled' -NotePropertyValue ([bool]($V.OrganizationBrandingEnabled -eq $true)) + $Current | Add-Member -NotePropertyName 'organizationBrandingEnabled' -NotePropertyValue ([bool]$Policy.OrganizationBrandingEnabled) + + # Carried for the executor: the write fans texts across these languages, and the + # Microsoft default policy cannot be modified - it must be replaced by name. The + # CURRENT per-language arrays ride along because Exchange requires all three arrays + # on every write with counts equal to the language count - an unconfigured field must + # resend the tenant's existing values, not omit the array or null it. + $Languages = @($Policy.MultiLanguageSetting) + if ($Languages.Count -eq 0) { $Languages = @('Default') } + $Current | Add-Member -NotePropertyName 'languages' -NotePropertyValue @($Languages) + $Current | Add-Member -NotePropertyName 'currentSenderNames' -NotePropertyValue @($Policy.MultiLanguageSenderName) + $Current | Add-Member -NotePropertyName 'currentSubjects' -NotePropertyValue @($Policy.ESNCustomSubject) + $Current | Add-Member -NotePropertyName 'currentDisclaimers' -NotePropertyValue @($Policy.MultiLanguageCustomDisclaimer) + $Current | Add-Member -NotePropertyName 'policyName' -NotePropertyValue "$($Policy.Name)" + $Current | Add-Member -NotePropertyName 'policyIdentity' -NotePropertyValue "$($Policy.Identity)" + + @{ Expected = $Expected; Current = $Current } +} diff --git a/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineGroupTemplateState.ps1 b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineGroupTemplateState.ps1 new file mode 100644 index 0000000000000..29745474a919b --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineGroupTemplateState.ps1 @@ -0,0 +1,60 @@ +function Get-CIPPBaselineGroupTemplateState { + <# + .SYNOPSIS + Prepare hook for GroupTemplate: is this instance's group deployed. + .DESCRIPTION + One instance grades ONE template. Grades PRESENCE BY NAME only, matching the + classic's report - group settings drift is repaired by the executor's update branch + on every remediation run (checkBeforeRun:false on the definition), exactly as the + classic's remediation loop did. + + Dynamic distribution groups are Exchange objects, not Graph groups, so their + presence is checked against the ExoDynamicDistributionGroup cache by Name; every + other group type checks the Groups cache by displayName. That split is the classic's + own. + + Template resolution stays per-family: PartitionKey 'GroupTemplate', RowKey alone. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Item, + $TenantFilter + ) + + $Reference = $Item.Variables.groupTemplate + if ($Reference -is [System.Management.Automation.PSCustomObject] -and $Reference.PSObject.Properties.Name -contains 'value') { $Reference = $Reference.value } + if ([string]::IsNullOrWhiteSpace("$Reference")) { return @{ Current = $null } } + + $Table = Get-CippTable -tablename 'templates' + $SafeReference = ConvertTo-CIPPODataFilterValue -Value "$Reference" + $Entity = Get-CIPPAzDataTableEntity @Table -Filter "PartitionKey eq 'GroupTemplate' and RowKey eq '$SafeReference'" | Select-Object -First 1 + $Template = $(if ($Entity -and -not [string]::IsNullOrWhiteSpace($Entity.JSON)) { try { $Entity.JSON | ConvertFrom-Json -Depth 20 -ErrorAction Stop } catch { $null } }) + $GroupName = "$($Template.displayName)" + if (-not $Template -or [string]::IsNullOrWhiteSpace($GroupName)) { return @{ Current = $null } } + + if ("$($Template.groupType)" -eq 'dynamicDistribution') { + $Distros = @(Get-CIPPBaselineCacheRows -TenantFilter $TenantFilter -Type 'ExoDynamicDistributionGroup') + if ($Distros.Count -eq 0 -and -not (Test-CIPPBaselineCacheCollected -TenantFilter $TenantFilter -Type 'ExoDynamicDistributionGroup')) { + return @{ Current = $null } + } + $Existing = $Distros | Where-Object { "$($_.Name)" -eq $GroupName } | Select-Object -First 1 + } else { + $Groups = @(Get-CIPPBaselineCacheRows -TenantFilter $TenantFilter -Type 'Groups') + if ($Groups.Count -eq 0 -and -not (Test-CIPPBaselineCacheCollected -TenantFilter $TenantFilter -Type 'Groups')) { + return @{ Current = $null } + } + $Existing = $Groups | Where-Object { "$($_.displayName)" -eq $GroupName } | Select-Object -First 1 + } + + $Current = [PSCustomObject]@{ deployed = [bool]$Existing } + # Carried for the executor, not graded. + $Current | Add-Member -NotePropertyName 'templateBody' -NotePropertyValue $Template + $Current | Add-Member -NotePropertyName 'existingGroup' -NotePropertyValue $Existing + + @{ + Expected = [PSCustomObject]@{ deployed = $true } + Current = $Current + } +} diff --git a/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineIntuneAppTemplateDeployState.ps1 b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineIntuneAppTemplateDeployState.ps1 new file mode 100644 index 0000000000000..f67202f22f44f --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineIntuneAppTemplateDeployState.ps1 @@ -0,0 +1,75 @@ +function Get-CIPPBaselineIntuneAppTemplateDeployState { + <# + .SYNOPSIS + Prepare hook for IntuneAppTemplateDeploy: presence of the configured template apps. + .DESCRIPTION + Resolves each configured App Template to its per-app list and grades an empty + missing-apps set against the IntuneMobileApps cache. Office is a singleton Graph + always names 'Microsoft 365 Apps for Windows 10 and later' regardless of the + template's name, so it is tracked by @odata.type instead - the classic's rule. + The full missing-app objects (type + config) are carried for the executor's queue. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Item, + $TenantFilter + ) + + $Apps = @(Get-CIPPBaselineCacheRows -TenantFilter $TenantFilter -Type 'IntuneMobileApps') + if ($Apps.Count -eq 0 -and -not (Test-CIPPBaselineCacheCollected -TenantFilter $TenantFilter -Type 'IntuneMobileApps')) { + return @{ Current = $null } + } + + $TemplateIds = @($Item.Variables.templateIds | ForEach-Object { "$($_.value ?? $_)" } | Where-Object { $_ }) + if ($TemplateIds.Count -eq 0) { return @{ Current = $null } } + + $CurrentAppNames = @($Apps | ForEach-Object { "$($_.displayName)" }) + $OfficeDeployed = @($Apps | Where-Object { "$($_.'@odata.type')" -eq '#microsoft.graph.officeSuiteApp' }).Count -gt 0 + + $Table = Get-CIPPTable -TableName 'templates' + $MissingApps = [System.Collections.Generic.List[object]]::new() + foreach ($TemplateId in $TemplateIds) { + $Entity = Get-CIPPAzDataTableEntity @Table -Filter "PartitionKey eq 'AppTemplate' and RowKey eq '$TemplateId'" + if (-not $Entity) { continue } + $TemplateData = $Entity.JSON | ConvertFrom-Json -Depth 100 + $AppTypes = @($TemplateData.Apps.appType) + $AppNames = @($TemplateData.Apps.appName) + $AppConfigs = @($TemplateData.Apps.config) + for ($i = 0; $i -lt $AppTypes.Count; $i++) { + $RawConfig = $AppConfigs[$i] + $Config = if ($RawConfig -is [string]) { $RawConfig | ConvertFrom-CippAppConfig } else { $RawConfig } + $AppType = [string]$AppTypes[$i] + $DisplayName = [string]($Config.ApplicationName ?? $Config.displayName ?? $AppNames[$i]) + if ([string]::IsNullOrWhiteSpace($DisplayName)) { + # A nameless app cannot be created in Intune and cannot be matched against + # deployed apps - broken template data, skipped with a log instead of an + # unnameable missing-app row and a doomed deploy. + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "App template '$($TemplateData.Displayname)' contains an app with no name (type $AppType) - skipped. Fix the template." -Sev 'Warning' + continue + } + $IsDeployed = if ($AppType -eq 'officeApp') { $OfficeDeployed } else { $DisplayName -in $CurrentAppNames } + if (-not $IsDeployed) { + $MissingApps.Add([PSCustomObject]@{ + TemplateId = "$TemplateId" + TemplateName = "$($TemplateData.Displayname)" + AppName = $DisplayName + AppType = $AppType + Config = $Config + }) + } + } + } + + $Current = [PSCustomObject]@{ + missingApps = @($MissingApps | ForEach-Object { "$($_.AppName)" } | Sort-Object) + } + # Carried for the executor. + $Current | Add-Member -NotePropertyName 'missingAppObjects' -NotePropertyValue @($MissingApps) + + @{ + Expected = [PSCustomObject]@{ missingApps = @() } + Current = $Current + } +} diff --git a/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineIntuneDeviceRetirementDaysState.ps1 b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineIntuneDeviceRetirementDaysState.ps1 new file mode 100644 index 0000000000000..ba81eefc9a1bf --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineIntuneDeviceRetirementDaysState.ps1 @@ -0,0 +1,42 @@ +function Get-CIPPBaselineIntuneDeviceRetirementDaysState { + <# + .SYNOPSIS + Prepare hook for intuneDeviceRetirementDays: the device cleanup rule's retirement + window. + .DESCRIPTION + Grades the inactivity-before-retirement day count on the default cleanup rule. The + tenant may carry several platform-scoped rules; the default all-platforms rule is + preferred, falling back to the first - the classic read the collection unfiltered + and compared whatever came back, which fans out to nonsense with several rules. + + A tenant with NO cleanup rule grades -1 against the configured days: not configured + is drift, and remediation creates the rule rather than patching one. Never-collected + stays No Data. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Item, + $TenantFilter + ) + + $Rules = @(Get-CIPPBaselineCacheRows -TenantFilter $TenantFilter -Type 'ManagedDeviceCleanupRules') + if ($Rules.Count -eq 0 -and -not (Test-CIPPBaselineCacheCollected -TenantFilter $TenantFilter -Type 'ManagedDeviceCleanupRules')) { + return @{ Current = $null } + } + + $Rule = @($Rules | Where-Object { "$($_.deviceCleanupRulePlatformType)" -eq 'all' }) | Select-Object -First 1 + if (-not $Rule) { $Rule = $Rules | Select-Object -First 1 } + + $Current = [PSCustomObject]@{ + deviceInactivityBeforeRetirementInDays = $(if ($null -eq $Rule.deviceInactivityBeforeRetirementInDays) { -1 } else { [int]$Rule.deviceInactivityBeforeRetirementInDays }) + } + # Carried for the executor: patch this rule, or create one when the tenant has none. + $Current | Add-Member -NotePropertyName 'ruleId' -NotePropertyValue $(if ($Rule.id) { "$($Rule.id)" } else { $null }) + + @{ + Expected = [PSCustomObject]@{ deviceInactivityBeforeRetirementInDays = [int]"$($Item.Variables.days)" } + Current = $Current + } +} diff --git a/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineIntuneTemplateState.ps1 b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineIntuneTemplateState.ps1 index 51fe78aac0736..c09008b121049 100644 --- a/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineIntuneTemplateState.ps1 +++ b/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/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineLegacyEmailReportAddinsState.ps1 b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineLegacyEmailReportAddinsState.ps1 new file mode 100644 index 0000000000000..a350998b0a7e1 --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineLegacyEmailReportAddinsState.ps1 @@ -0,0 +1,48 @@ +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) { + # 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' + '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/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineMDMScopeState.ps1 b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineMDMScopeState.ps1 new file mode 100644 index 0000000000000..89223cd78714c --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineMDMScopeState.ps1 @@ -0,0 +1,45 @@ +function Get-CIPPBaselineMDMScopeState { + <# + .SYNOPSIS + Prepare hook for MDMScope: the Intune MDM enrollment policy's URLs and user scope. + .DESCRIPTION + Grades Microsoft's three fixed Intune URLs plus the appliesTo scope, and - only in + 'selected' scope - whether the configured custom group is among the included + groups. The cache carries includedGroups expanded to display names for exactly this + check. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Item, + $TenantFilter + ) + + $Policy = @(Get-CIPPBaselineCacheRows -TenantFilter $TenantFilter -Type 'MobileDeviceManagementPolicies') | Select-Object -First 1 + if (-not $Policy) { return @{ Current = $null } } + + $AppliesTo = "$($Item.Variables.appliesTo.value ?? $Item.Variables.appliesTo)" + if ($AppliesTo -notin @('all', 'none', 'selected')) { return @{ Current = $null } } + $CustomGroup = "$($Item.Variables.customGroup)" + if ($AppliesTo -eq 'selected' -and [string]::IsNullOrWhiteSpace($CustomGroup)) { return @{ Current = $null } } + + $Expected = [PSCustomObject]@{ + termsOfUseUrl = 'https://portal.manage.microsoft.com/TermsofUse.aspx' + discoveryUrl = 'https://enrollment.manage.microsoft.com/enrollmentserver/discovery.svc' + complianceUrl = 'https://portal.manage.microsoft.com/?portalAction=Compliance' + appliesTo = $AppliesTo + } + $Current = [PSCustomObject]@{ + termsOfUseUrl = "$($Policy.termsOfUseUrl)" + discoveryUrl = "$($Policy.discoveryUrl)" + complianceUrl = "$($Policy.complianceUrl)" + appliesTo = "$($Policy.appliesTo)" + } + if ($AppliesTo -eq 'selected') { + $Expected | Add-Member -NotePropertyName 'customGroupIncluded' -NotePropertyValue $true + $Current | Add-Member -NotePropertyName 'customGroupIncluded' -NotePropertyValue ([bool](@($Policy.includedGroups.displayName) -contains $CustomGroup)) + } + + @{ Expected = $Expected; Current = $Current } +} diff --git a/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineMailContactsState.ps1 b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineMailContactsState.ps1 new file mode 100644 index 0000000000000..aac642d3255f6 --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineMailContactsState.ps1 @@ -0,0 +1,46 @@ +function Get-CIPPBaselineMailContactsState { + <# + .SYNOPSIS + Prepare hook for MailContacts: the organization's notification contact addresses. + .DESCRIPTION + Grades only the contacts the baseline configures - an empty field expresses no + opinion and must not strip a tenant's existing contact, the classic's own rule. + Marketing grades as CONTAINS (the tenant list may carry others); technical grades + the security+tech pair as a SET against technicalNotificationMails; general grades + the privacy profile contact exactly. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Item, + $TenantFilter + ) + + $Org = @(Get-CIPPBaselineCacheRows -TenantFilter $TenantFilter -Type 'Organization') | Select-Object -First 1 + if (-not $Org) { return @{ Current = $null } } + + $V = $Item.Variables + $TechSet = @(@("$($V.SecurityContact)", "$($V.TechContact)") | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -Unique | Sort-Object) + + $Expected = [PSCustomObject]@{} + $Current = [PSCustomObject]@{} + if (-not [string]::IsNullOrWhiteSpace("$($V.MarketingContact)")) { + $Expected | Add-Member -NotePropertyName 'marketingContactPresent' -NotePropertyValue $true + $Current | Add-Member -NotePropertyName 'marketingContactPresent' -NotePropertyValue ([bool](@($Org.marketingNotificationEmails) -contains "$($V.MarketingContact)")) + } + if ($TechSet.Count -gt 0) { + $Expected | Add-Member -NotePropertyName 'technicalContacts' -NotePropertyValue @($TechSet) + $Current | Add-Member -NotePropertyName 'technicalContacts' -NotePropertyValue @($Org.technicalNotificationMails | Sort-Object) + } + if (-not [string]::IsNullOrWhiteSpace("$($V.GeneralContact)")) { + $Expected | Add-Member -NotePropertyName 'generalContact' -NotePropertyValue "$($V.GeneralContact)" + $Current | Add-Member -NotePropertyName 'generalContact' -NotePropertyValue "$($Org.privacyProfile.contactEmail)" + } + if (@($Expected.PSObject.Properties).Count -eq 0) { return @{ Current = $null } } + + # Carried for the executor. + $Current | Add-Member -NotePropertyName 'organizationId' -NotePropertyValue "$($Org.id)" + + @{ Expected = $Expected; Current = $Current } +} diff --git a/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineMailboxRecipientLimitsState.ps1 b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineMailboxRecipientLimitsState.ps1 new file mode 100644 index 0000000000000..042cc16981df8 --- /dev/null +++ b/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/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineMalwareFilterPolicyState.ps1 b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineMalwareFilterPolicyState.ps1 new file mode 100644 index 0000000000000..e633388a38f17 --- /dev/null +++ b/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/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineNudgeMFAState.ps1 b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineNudgeMFAState.ps1 new file mode 100644 index 0000000000000..20031ef3349d0 --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineNudgeMFAState.ps1 @@ -0,0 +1,102 @@ +function Get-CIPPBaselineNudgeMFAState { + <# + .SYNOPSIS + Prepare hook for NudgeMFA: the authenticator registration campaign. + .DESCRIPTION + Grades the campaign's state, snooze duration, post-snooze enforcement, targeted + method, and the include/exclude target sets. The targeting semantics are the + classic's and they are DELIBERATE: blank include/exclude fields mean 'keep the + targets currently configured in the tenant' - NudgeMFA predates those fields and + existing deployments would otherwise have portal-configured targeting overwritten + to all_users. The literal 'AllUsers' entry targets everyone explicitly. + + Group entries resolve from the Groups cache with the classic's startsWith rule, + keeping EVERY match. An entry that resolves to nothing reports No Data - grading + against a half-resolved target list would drift forever. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Item, + $TenantFilter + ) + + $Policy = @(Get-CIPPBaselineCacheRows -TenantFilter $TenantFilter -Type 'AuthenticationMethodsPolicy') | Select-Object -First 1 + if (-not $Policy) { return @{ Current = $null } } + $Campaign = $Policy.registrationEnforcement.authenticationMethodsRegistrationCampaign + + $V = $Item.Variables + $State = "$($V.state.value ?? $V.state)" + if ($State -notin @('enabled', 'disabled', 'default')) { return @{ Current = $null } } + $Snooze = [int]"$($V.snoozeDurationInDays ?? 1)" + $EnforceAfter = if ($null -eq $V.enforceRegistrationAfterAllowedSnoozes) { $true } else { [bool]($V.enforceRegistrationAfterAllowedSnoozes -eq $true) } + $Method = "$($V.targetedAuthenticationMethod.value ?? $V.targetedAuthenticationMethod)" + if ([string]::IsNullOrWhiteSpace($Method)) { + $Method = "$((@($Campaign.includeTargets).targetedAuthenticationMethod | Select-Object -First 1) ?? 'microsoftAuthenticator')" + } + + $IncludeEntries = @(("$($V.includeTargets)") -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ }) + $ExcludeEntries = @(("$($V.excludeTargets)") -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ }) + + $Groups = @() + if ($IncludeEntries.Count -gt 0 -or $ExcludeEntries.Count -gt 0) { + $Groups = @(Get-CIPPBaselineCacheRows -TenantFilter $TenantFilter -Type 'Groups') + } + $Resolve = { + param($Entries) + $Resolved = [System.Collections.Generic.List[hashtable]]::new() + foreach ($Entry in $Entries) { + if ($Entry -match '^(all_users|allusers|all users)$') { $Resolved.Add(@{ id = 'all_users'; targetType = 'group' }); continue } + $Matched = @($Groups | Where-Object { "$($_.displayName)".StartsWith($Entry) }) + if ($Matched.Count -eq 0) { return $null } + foreach ($Group in $Matched) { $Resolved.Add(@{ id = "$($Group.id)"; targetType = 'group' }) } + } + , @($Resolved) + } + + if ($IncludeEntries.Count -gt 0) { + $RemediationInclude = & $Resolve $IncludeEntries + if ($null -eq $RemediationInclude -or @($RemediationInclude).Count -eq 0) { return @{ Current = $null } } + $DesiredIncludeIds = @($RemediationInclude | ForEach-Object { $_.id }) + } else { + $RemediationInclude = $null + $DesiredIncludeIds = @($Campaign.includeTargets.id) + if ($DesiredIncludeIds.Count -eq 0) { $DesiredIncludeIds = @('all_users') } + } + if ($ExcludeEntries.Count -gt 0) { + $RemediationExclude = & $Resolve $ExcludeEntries + if ($null -eq $RemediationExclude) { return @{ Current = $null } } + $DesiredExcludeIds = @($RemediationExclude | ForEach-Object { $_.id }) + } else { + $RemediationExclude = $null + $DesiredExcludeIds = @($Campaign.excludeTargets.id) + } + + $Expected = [PSCustomObject]@{ + state = $State + snoozeDurationInDays = $Snooze + enforceRegistrationAfterAllowedSnoozes = $EnforceAfter + targetedMethodCorrect = $true + includeTargetIds = @($DesiredIncludeIds | Sort-Object) + } + $Current = [PSCustomObject]@{ + state = "$($Campaign.state)" + snoozeDurationInDays = [int]"$($Campaign.snoozeDurationInDays)" + enforceRegistrationAfterAllowedSnoozes = [bool]$Campaign.enforceRegistrationAfterAllowedSnoozes + targetedMethodCorrect = (@($Campaign.includeTargets | Where-Object { "$($_.targetedAuthenticationMethod)" -ne $Method }).Count -eq 0) + includeTargetIds = @($Campaign.includeTargets.id | Sort-Object) + } + if ($ExcludeEntries.Count -gt 0) { + $Expected | Add-Member -NotePropertyName 'excludeTargetIds' -NotePropertyValue @($DesiredExcludeIds | Sort-Object) + $Current | Add-Member -NotePropertyName 'excludeTargetIds' -NotePropertyValue @($Campaign.excludeTargets.id | Sort-Object) + } + # Carried for the executor: Set-CIPPRegistrationCampaign's inputs ($null = keep current). + $Current | Add-Member -NotePropertyName 'campaignParams' -NotePropertyValue ([PSCustomObject]@{ + State = $State; TargetedAuthenticationMethod = $Method; SnoozeDurationInDays = $Snooze + EnforceRegistrationAfterAllowedSnoozes = $EnforceAfter + IncludeTargets = $RemediationInclude; ExcludeTargets = $RemediationExclude + }) + + @{ Expected = $Expected; Current = $Current } +} diff --git a/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineOMEBrandingState.ps1 b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineOMEBrandingState.ps1 new file mode 100644 index 0000000000000..258eb68a2e9cb --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineOMEBrandingState.ps1 @@ -0,0 +1,57 @@ +function Get-CIPPBaselineOMEBrandingState { + <# + .SYNOPSIS + Prepare hook for OMEBranding: encrypted-message branding configuration. + .DESCRIPTION + Grades only the fields the operator configured - an empty branding field expresses + no opinion, and grading it would strip existing branding on remediation. That + only-if-specified rule is the classic's own. + + A configured logo grades as permanent drift, exactly as the classic behaved: the + tenant never exposes the current image bytes, so the logo cannot be compared, and + the classic re-uploaded it on every remediation run. The drift row names logoApplied + so the operator can see why the row never settles while a logo is configured. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Item, + $TenantFilter + ) + + $Configs = @(Get-CIPPBaselineCacheRows -TenantFilter $TenantFilter -Type 'ExoOMEConfiguration') + if ($Configs.Count -eq 0 -and -not (Test-CIPPBaselineCacheCollected -TenantFilter $TenantFilter -Type 'ExoOMEConfiguration')) { + return @{ Current = $null } + } + $Config = @($Configs | Where-Object { "$($_.Identity)" -eq 'OME Configuration' }) | Select-Object -First 1 + if (-not $Config) { $Config = $Configs | Select-Object -First 1 } + + $V = $Item.Variables + $Expected = [PSCustomObject]@{} + $Current = [PSCustomObject]@{} + foreach ($Field in @('BackgroundColor', 'EmailText', 'IntroductionText', 'ReadButtonText', 'PortalText', 'DisclaimerText', 'PrivacyStatementUrl')) { + if ([string]::IsNullOrWhiteSpace("$($V.$Field)")) { continue } + $Expected | Add-Member -NotePropertyName $Field -NotePropertyValue "$($V.$Field)" + $Current | Add-Member -NotePropertyName $Field -NotePropertyValue "$($Config.$Field)" + } + foreach ($Switch in @('OTPEnabled', 'SocialIdSignIn')) { + $Value = $V.$Switch + if ($Value -is [System.Management.Automation.PSCustomObject] -and $Value.PSObject.Properties.Name -contains 'value') { $Value = $Value.value } + if ($null -eq $Value -or "$Value" -eq '') { continue } + $Expected | Add-Member -NotePropertyName $Switch -NotePropertyValue ([bool]($Value -eq $true -or "$Value" -eq 'True')) + $Current | Add-Member -NotePropertyName $Switch -NotePropertyValue ([bool]$Config.$Switch) + } + if (-not [string]::IsNullOrWhiteSpace("$($V.LogoUrl)")) { + # The image cannot be read back, so a configured logo is permanent drift - the + # classic's exact behaviour, made visible. + $Expected | Add-Member -NotePropertyName 'logoApplied' -NotePropertyValue $true + $Current | Add-Member -NotePropertyName 'logoApplied' -NotePropertyValue $false + } + + if (@($Expected.PSObject.Properties).Count -eq 0) { return @{ Current = $null } } + + $Current | Add-Member -NotePropertyName 'omeIdentity' -NotePropertyValue "$($Config.Identity ?? 'OME Configuration')" + + @{ Expected = $Expected; Current = $Current } +} diff --git a/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineOauthConsentState.ps1 b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineOauthConsentState.ps1 new file mode 100644 index 0000000000000..a756aab151f7c --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineOauthConsentState.ps1 @@ -0,0 +1,63 @@ +function Get-CIPPBaselineOauthConsentState { + <# + .SYNOPSIS + Prepare hook for OauthConsent: is user consent routed through the CIPP consent + policy with the allowed apps included. + .DESCRIPTION + Two graded facts, the classic's: the default user role's permission grant policy is + ManagePermissionGrantsForSelf.cipp-consent-policy, and every expected include exists + on that policy - the fixed Office 365 Management delegated include plus, per allowed + app, a delegated and an application include. Extra includes an operator added are + not graded; the remediation only ever adds, so grading extras would be permanent + unfixable drift. + + The includes read is LIVE: the policy's include children are not cached, and the + classic read them live for the same reason. A read failure on a tenant that has + never had the policy created reads as no includes, which is the honest state. + + The classic's OauthConsentLowSec conflict check is not ported: it inspected the + CLASSIC standards configuration, which does not exist for baselines - an equivalent + would compare against other baseline standards, which the engine's conflict + detection already covers. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Item, + $TenantFilter + ) + + $AuthPolicy = @(Get-CIPPBaselineCacheRows -TenantFilter $TenantFilter -Type 'AuthorizationPolicy') | Select-Object -First 1 + if (-not $AuthPolicy) { return @{ Current = $null } } + + $AllowedApps = @("$($Item.Variables.AllowedApps)" -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ } | Sort-Object -Unique) + + $Includes = @(try { + New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/policies/permissionGrantPolicies/cipp-consent-policy/includes' -tenantid $TenantFilter -ErrorAction Stop + } catch { @() }) + + $ExpectedIncludes = [System.Collections.Generic.List[string]]::new() + $ExpectedIncludes.Add('delegated|00b41c95-dab0-4487-9791-b9d2c32c80f2') + foreach ($App in $AllowedApps) { + $ExpectedIncludes.Add("delegated|$App") + $ExpectedIncludes.Add("application|$App") + } + $Missing = @(foreach ($Key in $ExpectedIncludes) { + $Type, $AppId = $Key -split '\|' + $Found = $Includes | Where-Object { "$($_.permissionType)".ToLowerInvariant() -eq $Type -and @($_.clientApplicationIds) -contains $AppId } + if (-not $Found) { $Key } + }) + + $Current = [PSCustomObject]@{ + consentPolicyAssigned = [bool](@($AuthPolicy.permissionGrantPolicyIdsAssignedToDefaultUserRole) -contains 'ManagePermissionGrantsForSelf.cipp-consent-policy') + missingIncludes = @($Missing | Sort-Object) + } + # Carried for the executor. + $Current | Add-Member -NotePropertyName 'policyExists' -NotePropertyValue ([bool]($Includes.Count -gt 0)) + + @{ + Expected = [PSCustomObject]@{ consentPolicyAssigned = $true; missingIncludes = @() } + Current = $Current + } +} diff --git a/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselinePasswordExpireDisabledState.ps1 b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselinePasswordExpireDisabledState.ps1 new file mode 100644 index 0000000000000..f21217c591e3e --- /dev/null +++ b/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/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselinePerUserMFAState.ps1 b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselinePerUserMFAState.ps1 new file mode 100644 index 0000000000000..d17ce55c19bea --- /dev/null +++ b/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/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselinePhishProtectionState.ps1 b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselinePhishProtectionState.ps1 new file mode 100644 index 0000000000000..2081cb12fcce4 --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselinePhishProtectionState.ps1 @@ -0,0 +1,50 @@ +function Get-CIPPBaselinePhishProtectionState { + <# + .SYNOPSIS + Prepare hook for PhishProtection: the phishing-check CSS on the sign-in branding. + .DESCRIPTION + Builds the tenant's expected canary CSS - the clone.cipp.app background-image URL + carrying this instance's CIPPURL from the Config table, the classic's exact string - + and grades whether the default branding localization's custom CSS contains it. The + branding singleton reads live: it is one small object with no cache, exactly what + the classic read. A tenant without premium branding reads as no CSS. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Item, + $TenantFilter + ) + + $TenantId = Get-Tenants -TenantFilter $TenantFilter + $Table = Get-CIPPTable -TableName Config + $CippConfig = Get-CIPPAzDataTableEntity @Table -Filter "PartitionKey eq 'InstanceProperties' and RowKey eq 'CIPPURL'" + $CIPPUrl = "$($CippConfig.Value)" + + $CurrentBody = $null + try { + $CurrentBody = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/organization/$($TenantId.customerId)/branding/localizations/0/customCSS" -tenantid $TenantFilter + } catch { + Write-Information "Baselines: could not read the branding CSS for $TenantFilter (tenant may lack premium branding): $($_.Exception.Message)" + } + + $CSS = @" +.ext-sign-in-box { + background-image: url(https://clone.cipp.app/api/PublicPhishingCheck?Tenantid=$($TenantFilter)&URL=https://$($CIPPUrl)); +} +"@ + + $Current = [PSCustomObject]@{ + phishingCSSEnabled = [bool]("$CurrentBody" -like "*$CSS*") + } + # Carried for the executor. + $Current | Add-Member -NotePropertyName 'currentBody' -NotePropertyValue "$CurrentBody" + $Current | Add-Member -NotePropertyName 'expectedCss' -NotePropertyValue $CSS + $Current | Add-Member -NotePropertyName 'customerId' -NotePropertyValue "$($TenantId.customerId)" + + @{ + Expected = [PSCustomObject]@{ phishingCSSEnabled = $true } + Current = $Current + } +} diff --git a/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselinePhishSimSpoofIntelligenceState.ps1 b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselinePhishSimSpoofIntelligenceState.ps1 new file mode 100644 index 0000000000000..0ec8bf35980f3 --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselinePhishSimSpoofIntelligenceState.ps1 @@ -0,0 +1,45 @@ +function Get-CIPPBaselinePhishSimSpoofIntelligenceState { + <# + .SYNOPSIS + Prepare hook for PhishSimSpoofIntelligence: spoof intelligence allowances for + phishing simulation senders. + .DESCRIPTION + Additive by default: grades which configured sending infrastructures are missing + from the spoof allow list, leaving operator-added entries alone. The + RemoveExtraDomains switch turns on the strict mode the classic offered, where + entries outside the configured set grade (and remediate) as removals - the operator + opts into ownership of the whole list. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Item, + $TenantFilter + ) + + $SpoofItems = @(Get-CIPPBaselineCacheRows -TenantFilter $TenantFilter -Type 'ExoTenantAllowBlockListSpoofItems') + if ($SpoofItems.Count -eq 0 -and -not (Test-CIPPBaselineCacheCollected -TenantFilter $TenantFilter -Type 'ExoTenantAllowBlockListSpoofItems')) { + return @{ Current = $null } + } + + $Allowed = @(@($Item.Variables.AllowedDomains) | ForEach-Object { "$($_.value ?? $_)" } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) + if ($Allowed.Count -eq 0) { return @{ Current = $null } } + $RemoveExtra = [bool]($Item.Variables.RemoveExtraDomains -eq $true) + + $ExistingInfra = @($SpoofItems | ForEach-Object { "$($_.SendingInfrastructure)" }) + $Missing = @($Allowed | Where-Object { $ExistingInfra -notcontains $_ } | Sort-Object) + + $Expected = [PSCustomObject]@{ missingDomains = @() } + $Current = [PSCustomObject]@{ missingDomains = @($Missing) } + $ExtraItems = @() + if ($RemoveExtra) { + $ExtraItems = @($SpoofItems | Where-Object { $Allowed -notcontains "$($_.SendingInfrastructure)" }) + $Expected | Add-Member -NotePropertyName 'extraDomains' -NotePropertyValue @() + $Current | Add-Member -NotePropertyName 'extraDomains' -NotePropertyValue @($ExtraItems | ForEach-Object { "$($_.SendingInfrastructure)" } | Sort-Object) + } + # Carried for the executor: removals key on the item Identity, not the domain. + $Current | Add-Member -NotePropertyName 'extraItemIds' -NotePropertyValue @($ExtraItems | ForEach-Object { "$($_.Identity)" }) + + @{ Expected = $Expected; Current = $Current } +} diff --git a/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselinePhishingSimulationsState.ps1 b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselinePhishingSimulationsState.ps1 new file mode 100644 index 0000000000000..29a5bab474ba7 --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselinePhishingSimulationsState.ps1 @@ -0,0 +1,77 @@ +function Get-CIPPBaselinePhishingSimulationsState { + <# + .SYNOPSIS + Prepare hook for PhishingSimulations: the phish sim override policy, its rule, and + the advanced-delivery URL allowances. + .DESCRIPTION + Three legs, graded separately so the drift row names the broken one: the override + POLICY must exist enabled, the override RULE must carry the configured sender IP + ranges and domains, and the configured simulation URLs must be on the Url + advanced-delivery allow list. + + Rule lists and URLs are additive by default; RemoveExtraUrls opts into strict + ownership where entries outside the configured set grade as removals - the classic's + exact switch, applied to all three lists. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Item, + $TenantFilter + ) + + $Policies = @(Get-CIPPBaselineCacheRows -TenantFilter $TenantFilter -Type 'ExoPhishSimOverridePolicy' -CollectorType 'ExoPhishSimConfig') + $Rules = @(Get-CIPPBaselineCacheRows -TenantFilter $TenantFilter -Type 'ExoPhishSimOverrideRule' -CollectorType 'ExoPhishSimConfig') + $UrlItems = @(Get-CIPPBaselineCacheRows -TenantFilter $TenantFilter -Type 'ExoPhishSimUrlAllowItems' -CollectorType 'ExoPhishSimConfig') + if ($Policies.Count -eq 0 -and $Rules.Count -eq 0 -and -not (Test-CIPPBaselineCacheCollected -TenantFilter $TenantFilter -Type 'ExoPhishSimOverridePolicy')) { + return @{ Current = $null } + } + + $Unwrap = { param($Value) @(@($Value) | ForEach-Object { "$($_.value ?? $_)" } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) } + $WantedDomains = & $Unwrap $Item.Variables.Domains + $WantedRanges = & $Unwrap $Item.Variables.SenderIpRanges + $WantedUrls = & $Unwrap $Item.Variables.PhishingSimUrls + if ($WantedDomains.Count -eq 0 -or $WantedRanges.Count -eq 0) { return @{ Current = $null } } + $RemoveExtra = [bool]($Item.Variables.RemoveExtraUrls -eq $true) + + $Policy = $Policies | Where-Object { "$($_.Name)" -eq 'PhishSimOverridePolicy' } | Select-Object -First 1 + $Rule = $Rules | Where-Object { "$($_.Name)" -like '*PhishSimOverr*' } | Select-Object -First 1 + + $CurrentRanges = @($Rule.SenderIpRanges | ForEach-Object { "$_" }) + $CurrentDomains = @($Rule.Domains | ForEach-Object { "$_" }) + $CurrentUrls = @($UrlItems | ForEach-Object { "$($_.Value)" }) + + $MissingRanges = @($WantedRanges | Where-Object { $CurrentRanges -notcontains $_ } | Sort-Object) + $MissingDomains = @($WantedDomains | Where-Object { $CurrentDomains -notcontains $_ } | Sort-Object) + $MissingUrls = @($WantedUrls | Where-Object { $CurrentUrls -notcontains $_ } | Sort-Object) + $ExtraRanges = @(); $ExtraDomains = @(); $ExtraUrls = @() + if ($RemoveExtra) { + $ExtraRanges = @($CurrentRanges | Where-Object { $WantedRanges -notcontains $_ } | Sort-Object) + $ExtraDomains = @($CurrentDomains | Where-Object { $WantedDomains -notcontains $_ } | Sort-Object) + $ExtraUrls = @($CurrentUrls | Where-Object { $WantedUrls -notcontains $_ } | Sort-Object) + } + + $Expected = [PSCustomObject]@{ + policyEnabled = $true + missingDomains = @(); missingSenderIpRanges = @(); missingUrls = @() + } + $Current = [PSCustomObject]@{ + policyEnabled = [bool]($Policy -and $Policy.Enabled -eq $true) + missingDomains = @($MissingDomains); missingSenderIpRanges = @($MissingRanges); missingUrls = @($MissingUrls) + } + if ($RemoveExtra) { + $Expected | Add-Member -NotePropertyName 'extraDomains' -NotePropertyValue @() + $Expected | Add-Member -NotePropertyName 'extraSenderIpRanges' -NotePropertyValue @() + $Expected | Add-Member -NotePropertyName 'extraUrls' -NotePropertyValue @() + $Current | Add-Member -NotePropertyName 'extraDomains' -NotePropertyValue @($ExtraDomains) + $Current | Add-Member -NotePropertyName 'extraSenderIpRanges' -NotePropertyValue @($ExtraRanges) + $Current | Add-Member -NotePropertyName 'extraUrls' -NotePropertyValue @($ExtraUrls) + } + # Carried for the executor. + $Current | Add-Member -NotePropertyName 'policyExists' -NotePropertyValue ([bool]$Policy) + $Current | Add-Member -NotePropertyName 'ruleExists' -NotePropertyValue ([bool]$Rule) + $Current | Add-Member -NotePropertyName 'ruleIdentity' -NotePropertyValue "$($Rule.Identity)" + + @{ Expected = $Expected; Current = $Current } +} diff --git a/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineProfilePhotosState.ps1 b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineProfilePhotosState.ps1 new file mode 100644 index 0000000000000..67cac6e6cb89a --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineProfilePhotosState.ps1 @@ -0,0 +1,49 @@ +function Get-CIPPBaselineProfilePhotosState { + <# + .SYNOPSIS + Prepare hook for ProfilePhotos: whether users can change their profile photos. + .DESCRIPTION + Two surfaces must agree, exactly as the classic graded them: the Graph photo update + settings (an EMPTY allowedRoles list means users may change photos; disabled means + the Global admin and User admin role ids are on the list) and the default OWA + mailbox policy's SetPhotoEnabled flag. One surface enabled while the other is off + leaves users a working side door, which is why both grade. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Item, + $TenantFilter + ) + + $PhotoSettings = @(Get-CIPPBaselineCacheRows -TenantFilter $TenantFilter -Type 'PhotoUpdateSettings') | Select-Object -First 1 + $OwaPolicies = @(Get-CIPPBaselineCacheRows -TenantFilter $TenantFilter -Type 'OwaMailboxPolicy') + if (-not $PhotoSettings -and $OwaPolicies.Count -eq 0 -and -not (Test-CIPPBaselineCacheCollected -TenantFilter $TenantFilter -Type 'PhotoUpdateSettings')) { + return @{ Current = $null } + } + $OwaPolicy = @($OwaPolicies | Where-Object { "$($_.Identity)" -eq 'OwaMailboxPolicy-Default' }) | Select-Object -First 1 + + $StateValue = "$($Item.Variables.state.value ?? $Item.Variables.state)" + if ($StateValue -notin @('enabled', 'disabled')) { return @{ Current = $null } } + $Desired = $StateValue -eq 'enabled' + + $UsersCanChange = [string]::IsNullOrWhiteSpace("$($PhotoSettings.allowedRoles)") + $GraphCorrect = $UsersCanChange -eq $Desired + if (-not $UsersCanChange -and -not $Desired) { + $GraphCorrect = (@($PhotoSettings.allowedRoles) -contains '62e90394-69f5-4237-9190-012177145e10') -and + (@($PhotoSettings.allowedRoles) -contains 'fe930be7-5e62-47db-91af-98c3a49a38b1') + } + + $Current = [PSCustomObject]@{ + photoUpdatePolicyCorrect = [bool]$GraphCorrect + owaSetPhotoEnabled = [bool]$OwaPolicy.SetPhotoEnabled + } + # Carried for the executor. + $Current | Add-Member -NotePropertyName 'owaPolicyIdentity' -NotePropertyValue "$($OwaPolicy.Identity ?? 'OwaMailboxPolicy-Default')" + + @{ + Expected = [PSCustomObject]@{ photoUpdatePolicyCorrect = $true; owaSetPhotoEnabled = $Desired } + Current = $Current + } +} diff --git a/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineQuarantineRequestAlertState.ps1 b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineQuarantineRequestAlertState.ps1 new file mode 100644 index 0000000000000..b3ea82d116613 --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineQuarantineRequestAlertState.ps1 @@ -0,0 +1,62 @@ +function Get-CIPPBaselineQuarantineRequestAlertState { + <# + .SYNOPSIS + 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: + + 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. + + 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 - in every mode, including Removed. + .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 + + if ("$($Item.Variables.State)" -eq 'removed') { + return @{ + Expected = [PSCustomObject]@{ AlertPresent = $false } + Current = [PSCustomObject]@{ AlertPresent = [bool]$Alert } + } + } + + $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/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineQuarantineTemplateState.ps1 b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineQuarantineTemplateState.ps1 new file mode 100644 index 0000000000000..ca96f6bdeb8e6 --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineQuarantineTemplateState.ps1 @@ -0,0 +1,96 @@ +function Get-CIPPBaselineQuarantineTemplateState { + <# + .SYNOPSIS + Prepare hook for QuarantineTemplate: is this instance's quarantine policy deployed + and in sync. + .DESCRIPTION + One instance is one POLICY CONFIGURATION, keyed on its display name - this family + has no templates table at all; the settings ARE the baseline variables. The classic + was already MULTIPLE:True with the same per-policy shape. + + The graded fields are the classic's: ESNEnabled, + IncludeMessagesFromBlockedSenderAddress, and the six end-user permissions. + PermissionToViewHeader and PermissionToDownload are forced false on both sides - + the classic's comment records that Exchange ignores the values. + + Permissions are graded PER KEY where the classic compared the unordered value + multisets - two policies that swapped which permissions were on could read equal + there. Per-key is what Set-CIPPQuarantinePolicy actually writes, so the compare and + the write agree. + + The default quarantine policies carry the all-zeros Guid and are excluded exactly as + the classic excluded them - a custom policy named like a default must not adopt one. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Item, + $TenantFilter + ) + + $Policies = @(Get-CIPPBaselineCacheRows -TenantFilter $TenantFilter -Type 'ExoQuarantinePolicy') + if ($Policies.Count -eq 0 -and -not (Test-CIPPBaselineCacheCollected -TenantFilter $TenantFilter -Type 'ExoQuarantinePolicy')) { + return @{ Current = $null } + } + $Policies = @($Policies | Where-Object { "$($_.Guid)" -ne '00000000-0000-0000-0000-000000000000' }) + + $V = $Item.Variables + $PolicyName = $V.displayName + if ($PolicyName -is [System.Management.Automation.PSCustomObject] -and $PolicyName.PSObject.Properties.Name -contains 'value') { $PolicyName = $PolicyName.value } + $PolicyName = "$PolicyName" + if ([string]::IsNullOrWhiteSpace($PolicyName)) { return @{ Current = $null } } + + $ReleaseAction = "$($V.ReleaseAction)" + if ($V.ReleaseAction -is [System.Management.Automation.PSCustomObject] -and $V.ReleaseAction.PSObject.Properties.Name -contains 'value') { $ReleaseAction = "$($V.ReleaseAction.value)" } + + $Expected = [PSCustomObject]@{ + deployed = $true + esnEnabled = [bool]($V.ESNEnabled -eq $true) + includeMessagesFromBlockedSenderAddress = [bool]($V.IncludeMessagesFromBlockedSenderAddress -eq $true) + permissionToViewHeader = $false + permissionToDownload = $false + permissionToBlockSender = [bool]($V.PermissionToBlockSender -eq $true) + permissionToDelete = [bool]($V.PermissionToDelete -eq $true) + permissionToPreview = [bool]($V.PermissionToPreview -eq $true) + permissionToRelease = [bool]($ReleaseAction -eq 'PermissionToRelease') + permissionToRequestRelease = [bool]($ReleaseAction -eq 'PermissionToRequestRelease') + permissionToAllowSender = [bool]($V.PermissionToAllowSender -eq $true) + } + + $Existing = $Policies | Where-Object { "$($_.Name)" -eq $PolicyName } | Select-Object -First 1 + if (-not $Existing) { + $Current = [PSCustomObject]@{ deployed = $false } + $Current | Add-Member -NotePropertyName 'policyName' -NotePropertyValue $PolicyName + return @{ + Expected = [PSCustomObject]@{ deployed = $true } + Current = $Current + } + } + + # The tenant stores permissions as an encoded value; the same helper the classic used + # decodes it back to the per-key booleans. + $ExistingPermissions = try { + Convert-QuarantinePermissionsValue -InputObject $Existing.EndUserQuarantinePermissions -ErrorAction Stop + } catch { + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "Could not decode quarantine permissions for policy '$PolicyName': $($_.Exception.Message)" -Sev 'Error' + return @{ Current = $null } + } + + $Current = [PSCustomObject]@{ + deployed = $true + esnEnabled = [bool]$Existing.ESNEnabled + includeMessagesFromBlockedSenderAddress = [bool]$Existing.IncludeMessagesFromBlockedSenderAddress + permissionToViewHeader = $false + permissionToDownload = $false + permissionToBlockSender = [bool]$ExistingPermissions.PermissionToBlockSender + permissionToDelete = [bool]$ExistingPermissions.PermissionToDelete + permissionToPreview = [bool]$ExistingPermissions.PermissionToPreview + permissionToRelease = [bool]$ExistingPermissions.PermissionToRelease + permissionToRequestRelease = [bool]$ExistingPermissions.PermissionToRequestRelease + permissionToAllowSender = [bool]$ExistingPermissions.PermissionToAllowSender + } + $Current | Add-Member -NotePropertyName 'policyName' -NotePropertyValue $PolicyName + + @{ Expected = $Expected; Current = $Current } +} diff --git a/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineRestrictThirdPartyStorageServicesState.ps1 b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineRestrictThirdPartyStorageServicesState.ps1 new file mode 100644 index 0000000000000..1313c5f859915 --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineRestrictThirdPartyStorageServicesState.ps1 @@ -0,0 +1,30 @@ +function Get-CIPPBaselineRestrictThirdPartyStorageServicesState { + <# + .SYNOPSIS + Prepare hook for RestrictThirdPartyStorageServices: the Microsoft 365 on the web + service principal. + .DESCRIPTION + Grades one fact from the ServicePrincipals cache: the Microsoft 365 on the web + service principal (appId c1f33bc0-bdb4-4248-ba9b-096807ddb43e) exists AND is + disabled. A missing service principal means third-party storage is available (the + platform default), so it grades unrestricted - the classic's exact semantics. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Item, + $TenantFilter + ) + + $ServicePrincipals = @(Get-CIPPBaselineCacheRows -TenantFilter $TenantFilter -Type 'ServicePrincipals') + if ($ServicePrincipals.Count -eq 0 -and -not (Test-CIPPBaselineCacheCollected -TenantFilter $TenantFilter -Type 'ServicePrincipals')) { + return @{ Current = $null } + } + $Principal = @($ServicePrincipals | Where-Object { "$($_.appId)" -eq 'c1f33bc0-bdb4-4248-ba9b-096807ddb43e' }) | Select-Object -First 1 + + @{ + Expected = [PSCustomObject]@{ thirdPartyStorageRestricted = $true } + Current = [PSCustomObject]@{ thirdPartyStorageRestricted = [bool]($null -ne $Principal -and $Principal.accountEnabled -eq $false) } + } +} diff --git a/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineRetentionCompliancePolicyTemplateState.ps1 b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineRetentionCompliancePolicyTemplateState.ps1 new file mode 100644 index 0000000000000..0e9854a5519e2 --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineRetentionCompliancePolicyTemplateState.ps1 @@ -0,0 +1,62 @@ +function Get-CIPPBaselineRetentionCompliancePolicyTemplateState { + <# + .SYNOPSIS + Prepare hook for RetentionCompliancePolicyTemplate: is this instance's policy + deployed. + .DESCRIPTION + One instance grades ONE template - the baseline stores an instance per selected + template (instanceIdentity), so the hook resolves a single reference. + + Grades PRESENCE BY NAME only, matching the classic standard. Retention policies + carry locations, rules and durations that the classic never diffed, and the deploy + path rewrites them wholesale, so grading them would report drift the engine would + resolve by overwriting. + + Unlike the sensitivity label family this one matches on Name ALONE - the classic + pulled just Name off Get-RetentionCompliancePolicy and used -notcontains. Retention + policies have no separate display name to fall back on. + + The cache behind this reads with the APPLICATION token (-AsApp). Retention cmdlets + are restricted for GDAP delegated identities, so a delegated read returns nothing + and every policy would read as missing. + + Template resolution is written out here rather than shared: PartitionKey + 'RetentionCompliancePolicyTemplate', RowKey alone. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Item, + $TenantFilter + ) + + $Policies = @(Get-CIPPBaselineCacheRows -TenantFilter $TenantFilter -Type 'ComplianceRetentionPolicies') + if ($Policies.Count -eq 0 -and -not (Test-CIPPBaselineCacheCollected -TenantFilter $TenantFilter -Type 'ComplianceRetentionPolicies')) { + return @{ Current = $null } + } + + $Reference = $Item.Variables.retentionCompliancePolicyTemplate + if ($Reference -is [System.Management.Automation.PSCustomObject] -and $Reference.PSObject.Properties.Name -contains 'value') { $Reference = $Reference.value } + if ([string]::IsNullOrWhiteSpace("$Reference")) { return @{ Current = $null } } + + $Table = Get-CippTable -tablename 'templates' + $SafeReference = ConvertTo-CIPPODataFilterValue -Value "$Reference" + $Entity = Get-CIPPAzDataTableEntity @Table -Filter "PartitionKey eq 'RetentionCompliancePolicyTemplate' and RowKey eq '$SafeReference'" | Select-Object -First 1 + $Template = $(if ($Entity -and -not [string]::IsNullOrWhiteSpace($Entity.JSON)) { try { $Entity.JSON | ConvertFrom-Json -Depth 20 -ErrorAction Stop } catch { $null } }) + # A deleted or unreadable template is not a compliant tenant - report No Data rather + # than an empty success. + $TemplateName = "$($Template.Name)" + if (-not $Template -or [string]::IsNullOrWhiteSpace($TemplateName)) { return @{ Current = $null } } + + $Deployed = [bool]($Policies | Where-Object { "$($_.Name)" -eq $TemplateName }) + + $Current = [PSCustomObject]@{ missingPolicies = @(if (-not $Deployed) { $TemplateName }) } + # Carried for the executor, not graded. + $Current | Add-Member -NotePropertyName 'templateBodies' -NotePropertyValue @($Template) + + @{ + Expected = [PSCustomObject]@{ missingPolicies = @() } + Current = $Current + } +} diff --git a/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineRetentionPolicyTagState.ps1 b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineRetentionPolicyTagState.ps1 new file mode 100644 index 0000000000000..08b11d244c59f --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineRetentionPolicyTagState.ps1 @@ -0,0 +1,59 @@ +function Get-CIPPBaselineRetentionPolicyTagState { + <# + .SYNOPSIS + Prepare hook for RetentionPolicyTag: the CIPP Deleted Items retention tag and its + MRM policy link. + .DESCRIPTION + Grades the classic's six facts about the fixed 'CIPP Deleted Items' tag: it exists, + retention is on, the action is PermanentlyDelete, the age limit matches, the type is + DeletedItems, and the tag is LINKED into the Default MRM Policy - an unlinked tag + does nothing, which is why the link is graded separately. + + The tenant reports the age limit as a timespan string ('30.00:00:00'); it grades in + whole days. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Item, + $TenantFilter + ) + + $Tags = @(Get-CIPPBaselineCacheRows -TenantFilter $TenantFilter -Type 'ExoRetentionPolicyTags') + $Policies = @(Get-CIPPBaselineCacheRows -TenantFilter $TenantFilter -Type 'ExoRetentionPolicies') + if ($Tags.Count -eq 0 -and -not (Test-CIPPBaselineCacheCollected -TenantFilter $TenantFilter -Type 'ExoRetentionPolicyTags')) { + return @{ Current = $null } + } + + $TagName = 'CIPP Deleted Items' + $Tag = $Tags | Where-Object { "$($_.Identity)" -eq $TagName } | Select-Object -First 1 + $MrmPolicy = $Policies | Where-Object { "$($_.Identity)" -eq 'Default MRM Policy' } | Select-Object -First 1 + + $CurrentDays = if ($Tag -and $Tag.AgeLimitForRetention) { + try { [int]([timespan]"$($Tag.AgeLimitForRetention)").TotalDays } catch { -1 } + } else { -1 } + + $Current = [PSCustomObject]@{ + tagExists = [bool]$Tag + retentionEnabled = [bool]($Tag -and $Tag.RetentionEnabled -eq $true) + retentionAction = "$($Tag.RetentionAction)" + ageLimitDays = [int]$CurrentDays + tagType = "$($Tag.Type)" + linkedToPolicy = [bool]($MrmPolicy -and @($MrmPolicy.RetentionPolicyTagLinks) -contains $TagName) + } + # Carried for the executor: the link write must resend the FULL link list plus ours. + $Current | Add-Member -NotePropertyName 'existingLinks' -NotePropertyValue @($MrmPolicy.RetentionPolicyTagLinks) + + @{ + Expected = [PSCustomObject]@{ + tagExists = $true + retentionEnabled = $true + retentionAction = 'PermanentlyDelete' + ageLimitDays = [int]"$($Item.Variables.AgeLimitForRetention)" + tagType = 'DeletedItems' + linkedToPolicy = $true + } + Current = $Current + } +} diff --git a/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineReusableSettingsTemplateState.ps1 b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineReusableSettingsTemplateState.ps1 new file mode 100644 index 0000000000000..907dd5b3bcad4 --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineReusableSettingsTemplateState.ps1 @@ -0,0 +1,83 @@ +function Get-CIPPBaselineReusableSettingsTemplateState { + <# + .SYNOPSIS + Prepare hook for ReusableSettingsTemplate: is this instance's reusable setting + deployed and in sync. + .DESCRIPTION + One instance grades ONE template. The deep diff runs through + Compare-CIPPIntuneObject with compareType 'ReusablePolicySetting' - the SAME + comparer, same compareType, the classic used. The classic also null-stripped both + sides first; that is omitted here because the comparer already treats null, empty + and absent as equal in every branch - verified empirically - so the strip changed + no verdict. + + The cache carries settingInstance explicitly: the list endpoint omits it unless + selected, and without it every compare would fail exactly as the classic's own + comment warns. + + Template resolution stays per-family: PartitionKey 'IntuneReusableSettingTemplate' + (one of the three partitions that do NOT match their standard name), name from + DisplayName ?? Name, body from RawJSON ?? JSON. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Item, + $TenantFilter + ) + + $Settings = @(Get-CIPPBaselineCacheRows -TenantFilter $TenantFilter -Type 'IntuneReusableSettings') + if ($Settings.Count -eq 0 -and -not (Test-CIPPBaselineCacheCollected -TenantFilter $TenantFilter -Type 'IntuneReusableSettings')) { + return @{ Current = $null } + } + + $Reference = $Item.Variables.reusableSettingsTemplate + if ($Reference -is [System.Management.Automation.PSCustomObject] -and $Reference.PSObject.Properties.Name -contains 'value') { $Reference = $Reference.value } + if ([string]::IsNullOrWhiteSpace("$Reference")) { return @{ Current = $null } } + + $Table = Get-CippTable -tablename 'templates' + $SafeReference = ConvertTo-CIPPODataFilterValue -Value "$Reference" + $Entity = Get-CIPPAzDataTableEntity @Table -Filter "PartitionKey eq 'IntuneReusableSettingTemplate' and RowKey eq '$SafeReference'" | Select-Object -First 1 + $Template = $(if ($Entity -and -not [string]::IsNullOrWhiteSpace($Entity.JSON)) { try { $Entity.JSON | ConvertFrom-Json -Depth 50 -ErrorAction Stop } catch { $null } }) + if (-not $Template) { return @{ Current = $null } } + + $DisplayName = "$($Template.DisplayName ?? $Template.Name)" + $RawJSON = $Template.RawJSON ?? $Template.JSON + $Body = $(try { $RawJSON | ConvertFrom-Json -Depth 50 -ErrorAction Stop } catch { $null }) + if ([string]::IsNullOrWhiteSpace($DisplayName) -or -not $Body) { return @{ Current = $null } } + + $Existing = $Settings | Where-Object { "$($_.displayName)" -eq $DisplayName } | Select-Object -First 1 + + if (-not $Existing) { + $Current = [PSCustomObject]@{ deployed = $false; drift = @() } + $Current | Add-Member -NotePropertyName 'rawJSON' -NotePropertyValue "$RawJSON" + $Current | Add-Member -NotePropertyName 'existingId' -NotePropertyValue $null + return @{ + Expected = [PSCustomObject]@{ deployed = $true; drift = @() } + Current = $Current + } + } + + $Differences = try { + $ExistingSanitized = $Existing | Select-Object -Property * -ExcludeProperty id, createdDateTime, lastModifiedDateTime, version, referencingConfigurationPolicyCount, '*odata*' + @(Compare-CIPPIntuneObject -ReferenceObject $Body -DifferenceObject $ExistingSanitized -compareType 'ReusablePolicySetting' -ErrorAction Stop | Where-Object { $_ }) + } catch { + # A failed compare is not drift - report unknown rather than inventing a verdict. + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "Could not compare reusable setting '$DisplayName': $($_.Exception.Message)" -Sev 'Error' + return @{ Current = $null } + } + + $Current = [PSCustomObject]@{ + deployed = $true + drift = @($Differences | ForEach-Object { "$($_.Property ?? $_.Setting ?? $_)" }) + } + # Carried for the executor, not graded. + $Current | Add-Member -NotePropertyName 'rawJSON' -NotePropertyValue "$RawJSON" + $Current | Add-Member -NotePropertyName 'existingId' -NotePropertyValue "$($Existing.id)" + + @{ + Expected = [PSCustomObject]@{ deployed = $true; drift = @() } + Current = $Current + } +} diff --git a/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineRotateDKIMState.ps1 b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineRotateDKIMState.ps1 new file mode 100644 index 0000000000000..0201bfe295d36 --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineRotateDKIMState.ps1 @@ -0,0 +1,33 @@ +function Get-CIPPBaselineRotateDKIMState { + <# + .SYNOPSIS + Prepare hook for RotateDKIM: enabled DKIM configs still on 1024-bit keys. + .DESCRIPTION + Grades the domains whose enabled DKIM signing still uses a 1024-bit selector key - + the classic's exact selection. Disabled configs are not graded: rotating a disabled + config does nothing, and AddDKIM owns enabling. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Item, + $TenantFilter + ) + + $DkimConfigs = @(Get-CIPPBaselineCacheRows -TenantFilter $TenantFilter -Type 'ExoDkimSigningConfig') + if ($DkimConfigs.Count -eq 0 -and -not (Test-CIPPBaselineCacheCollected -TenantFilter $TenantFilter -Type 'ExoDkimSigningConfig')) { + return @{ Current = $null } + } + + $Weak = @($DkimConfigs | Where-Object { + ($_.Selector1KeySize -eq 1024 -or $_.Selector2KeySize -eq 1024) -and $_.Enabled -eq $true + }) + + $Current = [PSCustomObject]@{ domainsWith1024BitDkim = @($Weak | ForEach-Object { "$($_.Identity)" } | Sort-Object) } + + @{ + Expected = [PSCustomObject]@{ domainsWith1024BitDkim = @() } + Current = $Current + } +} diff --git a/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineSPDirectSharingState.ps1 b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineSPDirectSharingState.ps1 new file mode 100644 index 0000000000000..cb838e75af531 --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineSPDirectSharingState.ps1 @@ -0,0 +1,28 @@ +function Get-CIPPBaselineSPDirectSharingState { + <# + .SYNOPSIS + Prepare hook for SPDirectSharing: the SharePoint default sharing link type. + .DESCRIPTION + Grades one fact: the tenant's default sharing link type is Direct (specific + people). The SPO admin endpoint reports it as the string 'Direct' or the numeric 1 + depending on the API vintage; both grade compliant, exactly as the classic + accepted both. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Item, + $TenantFilter + ) + + $State = @(Get-CIPPBaselineCacheRows -TenantFilter $TenantFilter -Type 'SPOTenant') | Select-Object -First 1 + if (-not $State) { return @{ Current = $null } } + + @{ + Expected = [PSCustomObject]@{ defaultSharingLinkIsDirect = $true } + Current = [PSCustomObject]@{ + defaultSharingLinkIsDirect = [bool]("$($State.DefaultSharingLinkType)" -eq 'Direct' -or "$($State.DefaultSharingLinkType)" -eq '1') + } + } +} diff --git a/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineSPOVersionControlState.ps1 b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineSPOVersionControlState.ps1 new file mode 100644 index 0000000000000..35a746c212ad4 --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineSPOVersionControlState.ps1 @@ -0,0 +1,48 @@ +function Get-CIPPBaselineSPOVersionControlState { + <# + .SYNOPSIS + Prepare hook for SPOVersionControl: the SharePoint file version policy. + .DESCRIPTION + Auto-trim on grades one fact - the trim flag - because SharePoint manages the + limits itself in that mode. Auto-trim off grades the flag plus the major version + limit and expiry days. + + SharePoint accepts only 0 (never) or 30-36500 days for expiry; a configured value + in the 1-29 gap reports No Data rather than grading a value the tenant would refuse + to store - the classic's own validation gate. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Item, + $TenantFilter + ) + + $State = @(Get-CIPPBaselineCacheRows -TenantFilter $TenantFilter -Type 'SPOTenant') | Select-Object -First 1 + if (-not $State) { return @{ Current = $null } } + + $AutoTrim = [bool]($Item.Variables.EnableAutoTrim -eq $true -or "$($Item.Variables.EnableAutoTrim)" -eq 'True') + $MajorLimit = [int]"$($Item.Variables.MajorVersionLimit ?? 50)" + $ExpireDays = [int]"$($Item.Variables.ExpireVersionsAfterDays ?? 0)" + if (-not $AutoTrim -and $ExpireDays -ne 0 -and ($ExpireDays -lt 30 -or $ExpireDays -gt 36500)) { return @{ Current = $null } } + + if ($AutoTrim) { + return @{ + Expected = [PSCustomObject]@{ autoExpirationVersionTrim = $true } + Current = [PSCustomObject]@{ autoExpirationVersionTrim = [bool]$State.EnableAutoExpirationVersionTrim } + } + } + @{ + Expected = [PSCustomObject]@{ + autoExpirationVersionTrim = $false + majorVersionLimit = $MajorLimit + expireVersionsAfterDays = $ExpireDays + } + Current = [PSCustomObject]@{ + autoExpirationVersionTrim = [bool]$State.EnableAutoExpirationVersionTrim + majorVersionLimit = [int]"$($State.MajorVersionLimit)" + expireVersionsAfterDays = [int]"$($State.ExpireVersionsAfterDays)" + } + } +} diff --git a/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineSafeAttachmentPolicyState.ps1 b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineSafeAttachmentPolicyState.ps1 new file mode 100644 index 0000000000000..cc9c6b4ade548 --- /dev/null +++ b/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/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineSafeLinksPolicyState.ps1 b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineSafeLinksPolicyState.ps1 new file mode 100644 index 0000000000000..3c5fffa3511ad --- /dev/null +++ b/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/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineSafeLinksTemplatePolicyState.ps1 b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineSafeLinksTemplatePolicyState.ps1 new file mode 100644 index 0000000000000..d489d2998803e --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineSafeLinksTemplatePolicyState.ps1 @@ -0,0 +1,61 @@ +function Get-CIPPBaselineSafeLinksTemplatePolicyState { + <# + .SYNOPSIS + Prepare hook for SafeLinksTemplatePolicy: are this instance's policy and rule + deployed. + .DESCRIPTION + One instance grades ONE template. Grades PRESENCE of the policy AND its rule by + name, which is all the classic graded - policy settings drift is repaired by the + executor's Set- branches on every remediation run (checkBeforeRun:false), which + reapply the full template exactly as the classic's remediation did. + + The names come from the template with the classic's fallbacks: PolicyName ?? Name + for the policy, RuleName ?? '_Rule' for the rule. Both graded separately - + a policy without its rule protects nobody, and the classic alerted on each + independently. + + Template resolution stays per-family: PartitionKey 'SafeLinksTemplate'. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Item, + $TenantFilter + ) + + $Policies = @(Get-CIPPBaselineCacheRows -TenantFilter $TenantFilter -Type 'ExoSafeLinksPolicies') + # Rules are written by the policies collector, so a rule miss re-runs that collector. + $Rules = @(Get-CIPPBaselineCacheRows -TenantFilter $TenantFilter -Type 'ExoSafeLinksRules' -CollectorType 'ExoSafeLinksPolicies') + if ($Policies.Count -eq 0 -and $Rules.Count -eq 0 -and -not (Test-CIPPBaselineCacheCollected -TenantFilter $TenantFilter -Type 'ExoSafeLinksPolicies')) { + return @{ Current = $null } + } + + $Reference = $Item.Variables.safeLinksTemplate + if ($Reference -is [System.Management.Automation.PSCustomObject] -and $Reference.PSObject.Properties.Name -contains 'value') { $Reference = $Reference.value } + if ([string]::IsNullOrWhiteSpace("$Reference")) { return @{ Current = $null } } + + $Table = Get-CippTable -tablename 'templates' + $SafeReference = ConvertTo-CIPPODataFilterValue -Value "$Reference" + $Entity = Get-CIPPAzDataTableEntity @Table -Filter "PartitionKey eq 'SafeLinksTemplate' and RowKey eq '$SafeReference'" | Select-Object -First 1 + $Template = $(if ($Entity -and -not [string]::IsNullOrWhiteSpace($Entity.JSON)) { try { $Entity.JSON | ConvertFrom-Json -Depth 20 -ErrorAction Stop } catch { $null } }) + if (-not $Template) { return @{ Current = $null } } + + $PolicyName = "$($Template.PolicyName ?? $Template.Name)" + if ([string]::IsNullOrWhiteSpace($PolicyName)) { return @{ Current = $null } } + $RuleName = "$($Template.RuleName ?? "$($PolicyName)_Rule")" + + $Current = [PSCustomObject]@{ + policyDeployed = [bool]($Policies | Where-Object { "$($_.Name)" -eq $PolicyName }) + ruleDeployed = [bool]($Rules | Where-Object { "$($_.Name)" -eq $RuleName }) + } + # Carried for the executor, not graded. + $Current | Add-Member -NotePropertyName 'templateBody' -NotePropertyValue $Template + $Current | Add-Member -NotePropertyName 'policyName' -NotePropertyValue $PolicyName + $Current | Add-Member -NotePropertyName 'ruleName' -NotePropertyValue $RuleName + + @{ + Expected = [PSCustomObject]@{ policyDeployed = $true; ruleDeployed = $true } + Current = $Current + } +} diff --git a/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineSafeSendersDisableState.ps1 b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineSafeSendersDisableState.ps1 new file mode 100644 index 0000000000000..28f9a9e4bfe55 --- /dev/null +++ b/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/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineSecureScoreRemediationState.ps1 b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineSecureScoreRemediationState.ps1 new file mode 100644 index 0000000000000..f0a0094511ead --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineSecureScoreRemediationState.ps1 @@ -0,0 +1,60 @@ +function Get-CIPPBaselineSecureScoreRemediationState { + <# + .SYNOPSIS + Prepare hook for SecureScoreRemediation: secure score control states. + .DESCRIPTION + A secure score control profile has no top-level state - the effective state is the + NEWEST controlStateUpdates entry, and no entries at all means the control sits at + default. That derivation is the classic's and it is the whole reason this is a + hook. + + Grades each configured control (three lists: back to default, ignored, marked + third-party) against its effective state. Controls outside the configured lists are + never graded - operators mark controls for their own reasons. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Item, + $TenantFilter + ) + + $Profiles = @(Get-CIPPBaselineCacheRows -TenantFilter $TenantFilter -Type 'SecureScoreControlProfiles') + if ($Profiles.Count -eq 0 -and -not (Test-CIPPBaselineCacheCollected -TenantFilter $TenantFilter -Type 'SecureScoreControlProfiles')) { + return @{ Current = $null } + } + + $States = @{} + foreach ($ControlProfile in $Profiles) { + $Latest = @($ControlProfile.controlStateUpdates) | Sort-Object updatedDateTime | Select-Object -Last 1 + $States["$($ControlProfile.id)"] = $(if ([string]::IsNullOrEmpty("$($Latest.state)")) { 'default' } else { "$($Latest.state)" }) + } + + $Unwrap = { param($Value) @(@($Value) | ForEach-Object { "$($_.value ?? $_)" } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) } + $Wanted = [System.Collections.Generic.List[object]]::new() + foreach ($Control in (& $Unwrap $Item.Variables.Default)) { $Wanted.Add(@{ Control = $Control; State = 'default'; Reason = 'Default' }) } + foreach ($Control in (& $Unwrap $Item.Variables.Ignored)) { $Wanted.Add(@{ Control = $Control; State = 'ignored'; Reason = 'Ignored' }) } + foreach ($Control in (& $Unwrap $Item.Variables.ThirdParty)) { $Wanted.Add(@{ Control = $Control; State = 'thirdParty'; Reason = 'ThirdParty' }) } + foreach ($Control in (& $Unwrap $Item.Variables.Reviewed)) { $Wanted.Add(@{ Control = $Control; State = 'reviewed'; Reason = 'Reviewed' }) } + if ($Wanted.Count -eq 0) { return @{ Current = $null } } + + $Drifted = [System.Collections.Generic.List[object]]::new() + foreach ($Want in $Wanted) { + $Effective = "$($States["$($Want.Control)"])" + if ($Effective -ne $Want.State) { + $Drifted.Add([PSCustomObject]@{ Control = "$($Want.Control)"; State = "$($Want.State)"; Reason = "$($Want.Reason)"; CurrentState = $Effective }) + } + } + + $Current = [PSCustomObject]@{ + controlsOutOfState = @($Drifted | ForEach-Object { "$($_.Control): '$($_.CurrentState)' should be '$($_.State)'" } | Sort-Object) + } + # Carried for the executor. + $Current | Add-Member -NotePropertyName 'driftedControls' -NotePropertyValue @($Drifted) + + @{ + Expected = [PSCustomObject]@{ controlsOutOfState = @() } + Current = $Current + } +} diff --git a/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineSendReceiveLimitTenantState.ps1 b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineSendReceiveLimitTenantState.ps1 new file mode 100644 index 0000000000000..9bde6b58400ff --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineSendReceiveLimitTenantState.ps1 @@ -0,0 +1,50 @@ +function Get-CIPPBaselineSendReceiveLimitTenantState { + <# + .SYNOPSIS + Prepare hook for SendReceiveLimitTenant: mailbox plan send/receive limits. + .DESCRIPTION + Grades which mailbox PLANS are off the configured limits - Exchange reports sizes as + display strings ('35 MB (36,700,160 bytes)'), so the byte count is parsed out the + way the classic parsed it, and 'Unlimited' always counts as an offender. New + mailboxes inherit their plan, which is why the plan is the graded object rather than + any mailbox. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Item, + $TenantFilter + ) + + $Plans = @(Get-CIPPBaselineCacheRows -TenantFilter $TenantFilter -Type 'ExoMailboxPlans') + if ($Plans.Count -eq 0 -and -not (Test-CIPPBaselineCacheCollected -TenantFilter $TenantFilter -Type 'ExoMailboxPlans')) { + return @{ Current = $null } + } + + $SendLimit = [int]"$($Item.Variables.SendLimit)" + $ReceiveLimit = [int]"$($Item.Variables.ReceiveLimit)" + if ($SendLimit -lt 1 -or $SendLimit -gt 150 -or $ReceiveLimit -lt 1 -or $ReceiveLimit -gt 150) { return @{ Current = $null } } + $MaxSendBytes = [int64]$SendLimit * 1MB + $MaxReceiveBytes = [int64]$ReceiveLimit * 1MB + + $Offenders = [System.Collections.Generic.List[object]]::new() + foreach ($Plan in $Plans) { + if ("$($Plan.MaxSendSize)" -match 'Unlimited' -or "$($Plan.MaxReceiveSize)" -match 'Unlimited') { + $Offenders.Add($Plan) + continue + } + $PlanSend = [int64]("$($Plan.MaxSendSize)" -replace '.*\(([\d,]+).*', '$1' -replace ',', '') + $PlanReceive = [int64]("$($Plan.MaxReceiveSize)" -replace '.*\(([\d,]+).*', '$1' -replace ',', '') + if ($PlanSend -ne $MaxSendBytes -or $PlanReceive -ne $MaxReceiveBytes) { $Offenders.Add($Plan) } + } + + $Current = [PSCustomObject]@{ plansOffLimits = @($Offenders | ForEach-Object { "$($_.DisplayName)" } | Sort-Object) } + # Carried for the executor. + $Current | Add-Member -NotePropertyName 'offenderGuids' -NotePropertyValue @($Offenders | ForEach-Object { "$($_.Guid ?? $_.GUID)" }) + + @{ + Expected = [PSCustomObject]@{ plansOffLimits = @() } + Current = $Current + } +} diff --git a/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineSensitiveInfoTypeTemplateState.ps1 b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineSensitiveInfoTypeTemplateState.ps1 new file mode 100644 index 0000000000000..7a7a9c5d6cee8 --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineSensitiveInfoTypeTemplateState.ps1 @@ -0,0 +1,68 @@ +function Get-CIPPBaselineSensitiveInfoTypeTemplateState { + <# + .SYNOPSIS + Prepare hook for SensitiveInfoTypeTemplate: this instance's SIT sync state against + the live rule pack. + .DESCRIPTION + One instance grades ONE template - the baseline stores an instance per selected + template (instanceIdentity), so the hook resolves a single reference. + + The only one of the template families that diffs rather than checking presence, and + it reads LIVE rather than from a cache. Compare-CIPPSensitiveInfoType needs the + SIT's rule package XML, which is a second per-SIT call + (Get-DlpSensitiveInformationTypeRulePackage keyed on the SIT's RulePackId) that no + cache carries. Grading off the SIT list alone would only ever detect a missing SIT, + never a drifted one - which is the whole point of this standard. + + The compare returns one of five states. Missing, Drift and Invalid are + non-compliant, exactly as the classic graded them. InSync is compliant, and so is + BuiltIn: a Microsoft SIT cannot be modified, so a template that collides with one is + left alone rather than reported as a failure forever. + + Only Missing and Drift are remediable. Invalid means the TEMPLATE is broken - + neither Pattern nor FileDataBase64 - so deploying it would fail; it is reported and + left, and remediableTemplates stays empty. + + Template resolution is written out here rather than shared: PartitionKey + 'SensitiveInfoTypeTemplate', RowKey alone. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Item, + $TenantFilter + ) + + $Reference = $Item.Variables.sensitiveInfoTypeTemplate + if ($Reference -is [System.Management.Automation.PSCustomObject] -and $Reference.PSObject.Properties.Name -contains 'value') { $Reference = $Reference.value } + if ([string]::IsNullOrWhiteSpace("$Reference")) { return @{ Current = $null } } + + $Table = Get-CippTable -tablename 'templates' + $SafeReference = ConvertTo-CIPPODataFilterValue -Value "$Reference" + $Entity = Get-CIPPAzDataTableEntity @Table -Filter "PartitionKey eq 'SensitiveInfoTypeTemplate' and RowKey eq '$SafeReference'" | Select-Object -First 1 + $Template = $(if ($Entity -and -not [string]::IsNullOrWhiteSpace($Entity.JSON)) { try { $Entity.JSON | ConvertFrom-Json -Depth 20 -ErrorAction Stop } catch { $null } }) + if (-not $Template) { return @{ Current = $null } } + + # A failed compare is NOT drift - the tenant read failed, and reporting the SIT as + # broken would be a lie that remediation would then act on. + $Comparison = try { + Compare-CIPPSensitiveInfoType -TenantFilter $TenantFilter -Template $Template -ErrorAction Stop + } catch { + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "Could not compare Sensitive Information Type '$($Template.Name)': $($_.Exception.Message)" -Sev 'Error' + return @{ Current = $null } + } + if (-not $Comparison) { return @{ Current = $null } } + + $NonCompliant = @(if ("$($Comparison.State)" -in @('Missing', 'Drift', 'Invalid')) { $Comparison }) + $Remediable = @(if ("$($Comparison.State)" -in @('Missing', 'Drift')) { $Template }) + + $Current = [PSCustomObject]@{ nonCompliantSensitiveInfoTypes = @($NonCompliant) } + # Carried for the executor, not graded. + $Current | Add-Member -NotePropertyName 'remediableTemplates' -NotePropertyValue @($Remediable) + + @{ + Expected = [PSCustomObject]@{ nonCompliantSensitiveInfoTypes = @() } + Current = $Current + } +} diff --git a/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineSensitivityLabelTemplateState.ps1 b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineSensitivityLabelTemplateState.ps1 new file mode 100644 index 0000000000000..1b271ab44f1b2 --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineSensitivityLabelTemplateState.ps1 @@ -0,0 +1,64 @@ +function Get-CIPPBaselineSensitivityLabelTemplateState { + <# + .SYNOPSIS + Prepare hook for SensitivityLabelTemplate: is this instance's label deployed. + .DESCRIPTION + One instance grades ONE template - the baseline stores an instance per selected + template (instanceIdentity), so the hook resolves a single reference rather than a + selection list. + + Grades PRESENCE BY NAME only, which is what the classic standard graded. A label + carries far more than its name - encryption, marking, scope - but the classic never + diffed any of it, and the deploy path overwrites those settings wholesale on every + run, so grading them would report drift the engine would then 'fix' by overwriting + operator changes it was never asked to manage. + + The template name matches an existing label on EITHER Name or DisplayName. Purview + returns the two independently and a label created outside CIPP routinely has a + GUID-ish Name with the human name only in DisplayName, so matching one alone reports + a deployed label as missing and redeploys it. + + Template resolution is written out here rather than shared. This family looks up + PartitionKey 'SensitivityLabelTemplate' by RowKey alone and reads the label name + from the payload's Name. Other template families use different partitions and + different name fields; borrowing one would silently resolve the wrong rows. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Item, + $TenantFilter + ) + + $Labels = @(Get-CIPPBaselineCacheRows -TenantFilter $TenantFilter -Type 'ExoLabels') + if ($Labels.Count -eq 0 -and -not (Test-CIPPBaselineCacheCollected -TenantFilter $TenantFilter -Type 'ExoLabels')) { + return @{ Current = $null } + } + + # The picker stores either a plain id or a { label, value } object depending on how the + # baseline was saved. + $Reference = $Item.Variables.sensitivityLabelTemplate + if ($Reference -is [System.Management.Automation.PSCustomObject] -and $Reference.PSObject.Properties.Name -contains 'value') { $Reference = $Reference.value } + if ([string]::IsNullOrWhiteSpace("$Reference")) { return @{ Current = $null } } + + $Table = Get-CippTable -tablename 'templates' + $SafeReference = ConvertTo-CIPPODataFilterValue -Value "$Reference" + $Entity = Get-CIPPAzDataTableEntity @Table -Filter "PartitionKey eq 'SensitivityLabelTemplate' and RowKey eq '$SafeReference'" | Select-Object -First 1 + $Template = $(if ($Entity -and -not [string]::IsNullOrWhiteSpace($Entity.JSON)) { try { $Entity.JSON | ConvertFrom-Json -Depth 20 -ErrorAction Stop } catch { $null } }) + # A deleted or unreadable template is not a compliant tenant - the standard cannot + # evaluate, so it reports No Data rather than an empty success. + $TemplateName = "$($Template.Name)" + if (-not $Template -or [string]::IsNullOrWhiteSpace($TemplateName)) { return @{ Current = $null } } + + $Deployed = [bool]($Labels | Where-Object { "$($_.Name)" -eq $TemplateName -or "$($_.DisplayName)" -eq $TemplateName }) + + $Current = [PSCustomObject]@{ missingLabels = @(if (-not $Deployed) { $TemplateName }) } + # Carried for the executor, not graded. + $Current | Add-Member -NotePropertyName 'templateBodies' -NotePropertyValue @($Template) + + @{ + Expected = [PSCustomObject]@{ missingLabels = @() } + Current = $Current + } +} diff --git a/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineSmartLockoutState.ps1 b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineSmartLockoutState.ps1 new file mode 100644 index 0000000000000..060f4596215c1 --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineSmartLockoutState.ps1 @@ -0,0 +1,56 @@ +function Get-CIPPBaselineSmartLockoutState { + <# + .SYNOPSIS + Prepare hook for SmartLockout: the password-rule directory setting's lockout values. + .DESCRIPTION + Grades the four lockout values on the password rule settings template + (5cf42378-d67d-4f36-ba46-e8b86229381d). Directory setting values are STRINGS on the + wire, so both sides grade as strings. A tenant with no password-rule setting object + grades every value against empty - not configured is drift, and remediation creates + the object. + + 'Enforced' normalizes to 'Enforce' - Graph only accepts Audit/Enforce, and older + saved baselines carry the misspelling. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Item, + $TenantFilter + ) + + $Settings = @(Get-CIPPBaselineCacheRows -TenantFilter $TenantFilter -Type 'Settings') + if ($Settings.Count -eq 0 -and -not (Test-CIPPBaselineCacheCollected -TenantFilter $TenantFilter -Type 'Settings')) { + return @{ Current = $null } + } + + $V = $Item.Variables + $Duration = "$($V.LockoutDurationInSeconds.value ?? $V.LockoutDurationInSeconds)" + $Threshold = "$($V.LockoutThreshold.value ?? $V.LockoutThreshold)" + $OnPrem = if ($V.EnableBannedPasswordCheckOnPremises -eq $true -or "$($V.EnableBannedPasswordCheckOnPremises)" -eq 'True') { 'True' } else { 'False' } + $Mode = "$($V.BannedPasswordCheckOnPremisesMode.value ?? $V.BannedPasswordCheckOnPremisesMode)" + if ($Mode -eq 'Enforced') { $Mode = 'Enforce' } + if ([string]::IsNullOrWhiteSpace($Mode)) { $Mode = 'Audit' } + + $Existing = @($Settings | Where-Object { "$($_.templateId)" -eq '5cf42378-d67d-4f36-ba46-e8b86229381d' }) | Select-Object -First 1 + $ValueOf = { param($Name) "$((@($Existing.values) | Where-Object { $_.name -eq $Name }).value)" } + + $Current = [PSCustomObject]@{ + lockoutDurationInSeconds = $(if ($Existing) { & $ValueOf 'LockoutDurationInSeconds' } else { '' }) + lockoutThreshold = $(if ($Existing) { & $ValueOf 'LockoutThreshold' } else { '' }) + enableBannedPasswordCheckOnPremises = $(if ($Existing) { & $ValueOf 'EnableBannedPasswordCheckOnPremises' } else { '' }) + bannedPasswordCheckOnPremisesMode = $(if ($Existing) { & $ValueOf 'BannedPasswordCheckOnPremisesMode' } else { '' }) + } + $Current | Add-Member -NotePropertyName 'settingId' -NotePropertyValue "$($Existing.id)" + + @{ + Expected = [PSCustomObject]@{ + lockoutDurationInSeconds = $Duration + lockoutThreshold = $Threshold + enableBannedPasswordCheckOnPremises = $OnPrem + bannedPasswordCheckOnPremisesMode = $Mode + } + Current = $Current + } +} diff --git a/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineSpamFilterPolicyState.ps1 b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineSpamFilterPolicyState.ps1 new file mode 100644 index 0000000000000..dcf830c6698d7 --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineSpamFilterPolicyState.ps1 @@ -0,0 +1,195 @@ +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 + # 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. + $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/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineStaleEntraDevicesState.ps1 b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineStaleEntraDevicesState.ps1 new file mode 100644 index 0000000000000..746dab947a054 --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineStaleEntraDevicesState.ps1 @@ -0,0 +1,65 @@ +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) { + # 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)) + + $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/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineTeamsDisableResourceAccountsState.ps1 b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineTeamsDisableResourceAccountsState.ps1 new file mode 100644 index 0000000000000..517d00660666f --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineTeamsDisableResourceAccountsState.ps1 @@ -0,0 +1,49 @@ +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) { + # 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') + $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/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineTeamsFederationConfigurationState.ps1 b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineTeamsFederationConfigurationState.ps1 new file mode 100644 index 0000000000000..67c0706ada5d6 --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineTeamsFederationConfigurationState.ps1 @@ -0,0 +1,94 @@ +function Get-CIPPBaselineTeamsFederationConfigurationState { + <# + .SYNOPSIS + Prepare hook for TeamsFederationConfiguration: external federation posture. + .DESCRIPTION + Grades the classic's five facts - the two consumer-access switches, whether + federation is on at all, and the allow/block domain lists - normalized the way the + classic's report normalized them, because the ConfigAPI GET shape is asymmetric with + its PUT shape: the GET nests the allow-list under AllowedDomains.AllowedDomain + (allow-all = an empty object), items arrive as strings or {Domain} objects, and both + lists sort before comparing. An allow-all posture reads as the literal string + 'AllowAllKnownDomains' so the drift row says what it means. + + The four modes decide which facts have which expected value; the domain list only + participates in the Specific modes. Everything the executor needs to rebuild the PUT + payload is carried, because the PUT shape cannot be derived from the graded values. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Item, + $TenantFilter + ) + + $State = @(Get-CIPPBaselineCacheRows -TenantFilter $TenantFilter -Type 'CsTenantFederationConfiguration') | Select-Object -First 1 + if (-not $State) { return @{ Current = $null } } + + $V = $Item.Variables + $DomainControl = "$($V.DomainControl.value ?? $V.DomainControl)" + if ($DomainControl -notin @('AllowAllExternal', 'BlockAllExternal', 'AllowSpecificExternal', 'BlockSpecificExternal')) { + return @{ Current = $null } + } + $AllowTeamsConsumer = [bool]($V.AllowTeamsConsumer -eq $true) + $AllowTeamsConsumerInbound = [bool]($V.AllowTeamsConsumerInbound -eq $true) + + $DomainList = @() + if (-not [string]::IsNullOrWhiteSpace("$($V.DomainList)")) { + $DomainList = @("$($V.DomainList)".Split(',').Trim() | Where-Object { $_ }) | Sort-Object + } + + switch ($DomainControl) { + 'AllowAllExternal' { $AllowFederatedUsers = $true; $ExpectedAllowed = 'AllowAllKnownDomains'; $ExpectedBlocked = @(); $AllowedPayload = @() } + 'BlockAllExternal' { $AllowFederatedUsers = $false; $ExpectedAllowed = 'AllowAllKnownDomains'; $ExpectedBlocked = @(); $AllowedPayload = @() } + 'AllowSpecificExternal' { $AllowFederatedUsers = $true; $ExpectedAllowed = @($DomainList); $ExpectedBlocked = @(); $AllowedPayload = @{ AllowList = @($DomainList) } } + 'BlockSpecificExternal' { $AllowFederatedUsers = $true; $ExpectedAllowed = 'AllowAllKnownDomains'; $ExpectedBlocked = @($DomainList); $AllowedPayload = @{ AllowList = @() } } + } + + # GET shape: allow-all = AllowedDomains with no AllowedDomain member. + $CurrentAllowedDomains = @() + $AllowedNode = $State.AllowedDomains + if ($AllowedNode -and ($AllowedNode.PSObject.Properties.Name -contains 'AllowedDomain') -and $AllowedNode.AllowedDomain) { + $CurrentAllowedDomains = @($AllowedNode.AllowedDomain | ForEach-Object { if ($_ -is [string]) { $_ } elseif ($_.Domain) { "$($_.Domain)" } else { "$_" } }) | Sort-Object + } + $CurrentBlockedDomains = @() + if ($State.BlockedDomains) { + $CurrentBlockedDomains = @($State.BlockedDomains | ForEach-Object { if ($_ -is [string]) { $_ } elseif ($_.Domain) { "$($_.Domain)" } else { "$_" } }) | Sort-Object + } + # The comma is load-bearing: an if-expression's pipeline output unwraps one array + # level, which turned a single allowed domain into a SCALAR - graded against the + # expected ARRAY, identical text drifted forever. + $CurrentAllowed = if ($CurrentAllowedDomains.Count -eq 0) { 'AllowAllKnownDomains' } else { , @($CurrentAllowedDomains) } + + # BlockAllExternal only grades the federation switch - the classic ignored both lists there. + if ($DomainControl -eq 'BlockAllExternal') { + $CurrentAllowed = $ExpectedAllowed + $CurrentBlockedDomains = @() + } + + $Expected = [PSCustomObject]@{ + allowTeamsConsumer = $AllowTeamsConsumer + allowTeamsConsumerInbound = $AllowTeamsConsumerInbound + allowFederatedUsers = $AllowFederatedUsers + allowedDomains = $ExpectedAllowed + blockedDomains = @($ExpectedBlocked) + } + $Current = [PSCustomObject]@{ + allowTeamsConsumer = [bool]$State.AllowTeamsConsumer + allowTeamsConsumerInbound = [bool]$State.AllowTeamsConsumerInbound + allowFederatedUsers = [bool]$State.AllowFederatedUsers + allowedDomains = $CurrentAllowed + blockedDomains = @($CurrentBlockedDomains) + } + # Carried for the executor - the ConfigAPI PUT shape differs from the graded one. + $Current | Add-Member -NotePropertyName 'writePayload' -NotePropertyValue ([PSCustomObject]@{ + AllowTeamsConsumer = $AllowTeamsConsumer + AllowTeamsConsumerInbound = $AllowTeamsConsumerInbound + AllowFederatedUsers = $AllowFederatedUsers + AllowedDomains = $AllowedPayload + BlockedDomains = @($ExpectedBlocked) + }) + + @{ Expected = $Expected; Current = $Current } +} diff --git a/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineTenantAllowBlockListTemplateState.ps1 b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineTenantAllowBlockListTemplateState.ps1 new file mode 100644 index 0000000000000..4fc5c0feb5b78 --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineTenantAllowBlockListTemplateState.ps1 @@ -0,0 +1,71 @@ +function Get-CIPPBaselineTenantAllowBlockListTemplateState { + <# + .SYNOPSIS + Prepare hook for TenantAllowBlockListTemplate: which of this instance's entries are + missing. + .DESCRIPTION + One instance grades ONE template, but a template here is a SET of entries for one + list type, not a single object - the graded shape is the missing-entries list, the + same additive compare the classic ran. Entries an operator added by hand are never + graded or touched; this family only ever ADDS. + + Entries split on commas and semicolons and trim, exactly as the classic parsed them. + Presence is checked against the cached Tenant Allow/Block List rows for the + template's listType; matching is case-insensitive, which is what the classic's + -notcontains did. + + Expired entries drop out of the tenant list, so a previously-deployed entry whose + expiration passed reads as missing again and remediation re-adds it - for a + block-list template that re-detection is the point. + + Template resolution stays per-family: PartitionKey 'TenantAllowBlockListTemplate', + RowKey alone. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Item, + $TenantFilter + ) + + $ListItems = @(Get-CIPPBaselineCacheRows -TenantFilter $TenantFilter -Type 'ExoTenantAllowBlockList') + if ($ListItems.Count -eq 0 -and -not (Test-CIPPBaselineCacheCollected -TenantFilter $TenantFilter -Type 'ExoTenantAllowBlockList')) { + return @{ Current = $null } + } + + $Reference = $Item.Variables.tenantAllowBlockListTemplate + if ($Reference -is [System.Management.Automation.PSCustomObject] -and $Reference.PSObject.Properties.Name -contains 'value') { $Reference = $Reference.value } + if ([string]::IsNullOrWhiteSpace("$Reference")) { return @{ Current = $null } } + + $Table = Get-CippTable -tablename 'templates' + $SafeReference = ConvertTo-CIPPODataFilterValue -Value "$Reference" + $Entity = Get-CIPPAzDataTableEntity @Table -Filter "PartitionKey eq 'TenantAllowBlockListTemplate' and RowKey eq '$SafeReference'" | Select-Object -First 1 + $Template = $(if ($Entity -and -not [string]::IsNullOrWhiteSpace($Entity.JSON)) { try { $Entity.JSON | ConvertFrom-Json -Depth 10 -ErrorAction Stop } catch { $null } }) + if (-not $Template) { return @{ Current = $null } } + + $Entries = @("$($Template.entries)" -split '[,;]' | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | ForEach-Object { $_.Trim() }) + $ListType = "$($Template.listType)" + if ($Entries.Count -eq 0 -or [string]::IsNullOrWhiteSpace($ListType)) { + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "Tenant Allow/Block List template '$Reference' has no entries or no list type and cannot be evaluated." -Sev 'Error' + return @{ Current = $null } + } + + $ExistingValues = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($ListItem in $ListItems) { + if ("$($ListItem.ListType)" -eq $ListType -and -not [string]::IsNullOrWhiteSpace("$($ListItem.Value)")) { + [void]$ExistingValues.Add("$($ListItem.Value)") + } + } + + $Missing = @($Entries | Where-Object { -not $ExistingValues.Contains($_) }) + + $Current = [PSCustomObject]@{ missingEntries = @($Missing) } + # Carried for the executor, not graded. + $Current | Add-Member -NotePropertyName 'templateBody' -NotePropertyValue $Template + + @{ + Expected = [PSCustomObject]@{ missingEntries = @() } + Current = $Current + } +} diff --git a/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineTransportRuleTemplateState.ps1 b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineTransportRuleTemplateState.ps1 new file mode 100644 index 0000000000000..e1d607cac7b1e --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineTransportRuleTemplateState.ps1 @@ -0,0 +1,78 @@ +function Get-CIPPBaselineTransportRuleTemplateState { + <# + .SYNOPSIS + Prepare hook for TransportRuleTemplate: is this instance's transport rule deployed. + .DESCRIPTION + One instance grades ONE template - the baseline stores an instance per selected + template (instanceIdentity), so the hook resolves a single reference. + + Reports the pair the classic standard reported - the rule the template names, and + whether it is missing from the tenant. It compares PRESENCE BY NAME and nothing + else: the classic never diffed rule bodies, and a transport rule is arbitrary schema + that operators tune by hand, so grading fields it never compared would report drift + on every hand-edit. + + Template resolution is deliberately written out here rather than shared. The + families differ: this one looks templates up by RowKey ALONE under PartitionKey + 'TransportTemplate', with no GUID or displayName fallback, and it keys the rule on + the payload's 'name'. Borrowing another family's lookup would silently change which + template resolves. + + Names are matched against Identity, DisplayName and Name, because Exchange returns + the rule under different ones depending on how it was created - the classic built + its set from Identity and DisplayName for the same reason. + + A template reference that resolves to nothing reports No Data. The classic + 'continue'd past it and read as compliant; under the instance model the template IS + the instance, so a deleted template means the standard cannot evaluate. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Item, + $TenantFilter + ) + + $Rules = @(Get-CIPPBaselineCacheRows -TenantFilter $TenantFilter -Type 'ExoTransportRules') + if ($Rules.Count -eq 0 -and -not (Test-CIPPBaselineCacheCollected -TenantFilter $TenantFilter -Type 'ExoTransportRules')) { + return @{ Current = $null } + } + + # The picker stores either a plain id or a { label, value } object depending on how the + # baseline was saved - the classic filtered on $_.value for exactly that reason. + $Reference = $Item.Variables.transportRuleTemplate + if ($Reference -is [System.Management.Automation.PSCustomObject] -and $Reference.PSObject.Properties.Name -contains 'value') { $Reference = $Reference.value } + if ([string]::IsNullOrWhiteSpace("$Reference")) { return @{ Current = $null } } + + $Table = Get-CippTable -tablename 'templates' + $SafeReference = ConvertTo-CIPPODataFilterValue -Value "$Reference" + $Entity = Get-CIPPAzDataTableEntity @Table -Filter "PartitionKey eq 'TransportTemplate' and RowKey eq '$SafeReference'" | Select-Object -First 1 + $Body = $(if ($Entity -and -not [string]::IsNullOrWhiteSpace($Entity.JSON)) { try { $Entity.JSON | ConvertFrom-Json -Depth 20 -ErrorAction Stop } catch { $null } }) + $RuleName = "$($Body.name)" + if (-not $Body -or [string]::IsNullOrWhiteSpace($RuleName)) { return @{ Current = $null } } + + $Deployed = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($Rule in $Rules) { + foreach ($Candidate in @("$($Rule.Identity)", "$($Rule.DisplayName)", "$($Rule.Name)")) { + if (-not [string]::IsNullOrWhiteSpace($Candidate)) { [void]$Deployed.Add($Candidate) } + } + } + $IsDeployed = $Deployed.Contains($RuleName) + + $Current = [PSCustomObject]@{ + deployedTransportRules = @(if ($IsDeployed) { $RuleName }) + missingTransportRules = @(if (-not $IsDeployed) { $RuleName }) + } + # Carried for the executor, not graded. + $Current | Add-Member -NotePropertyName 'ruleBodies' -NotePropertyValue @($Body) + $Current | Add-Member -NotePropertyName 'deployedNames' -NotePropertyValue @(if ($IsDeployed) { $RuleName }) + + @{ + Expected = [PSCustomObject]@{ + deployedTransportRules = @($RuleName) + missingTransportRules = @() + } + Current = $Current + } +} diff --git a/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineUserPreferredLanguageState.ps1 b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineUserPreferredLanguageState.ps1 new file mode 100644 index 0000000000000..22dad7da5d01d --- /dev/null +++ b/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/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineUserSubmissionsState.ps1 b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineUserSubmissionsState.ps1 new file mode 100644 index 0000000000000..edb4e1ea2b776 --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineUserSubmissionsState.ps1 @@ -0,0 +1,74 @@ +function Get-CIPPBaselineUserSubmissionsState { + <# + .SYNOPSIS + Prepare hook for UserSubmissions: the report submission policy and rule posture. + .DESCRIPTION + Ports the classic's three-way matrix: enabled reporting to Microsoft, enabled + reporting to a custom address (all three report types must carry exactly that + address, with the rule enabled and routing to it), or disabled outright (no policy + at all also counts as disabled). The graded shape is a flat set of booleans naming + which leg is wrong, so the drift row says what to fix rather than 'not correct'. + + The email address runs through the tenant text replacement first, exactly as the + classic replaced it - %variables% in the configured address resolve per tenant. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Item, + $TenantFilter + ) + + $Policies = @(Get-CIPPBaselineCacheRows -TenantFilter $TenantFilter -Type 'ReportSubmissionPolicy') + $Rules = @(Get-CIPPBaselineCacheRows -TenantFilter $TenantFilter -Type 'ReportSubmissionRule') + if ($Policies.Count -eq 0 -and $Rules.Count -eq 0 -and -not (Test-CIPPBaselineCacheCollected -TenantFilter $TenantFilter -Type 'ReportSubmissionPolicy')) { + return @{ Current = $null } + } + $Policy = $Policies | Select-Object -First 1 + $Rule = $Rules | Select-Object -First 1 + + $State = "$($Item.Variables.state.value ?? $Item.Variables.state)" + if ($State -notin @('enable', 'disable')) { return @{ Current = $null } } + $Email = "$($Item.Variables.email)" + if (-not [string]::IsNullOrWhiteSpace($Email)) { + $Email = Get-CIPPTextReplacement -TenantFilter $TenantFilter -Text $Email + if ($Email -notmatch '@') { return @{ Current = $null } } + } + + if ($State -eq 'enable' -and -not [string]::IsNullOrWhiteSpace($Email)) { + $Expected = [PSCustomObject]@{ reportToMicrosoft = $true; customAddressCorrect = $true; ruleCorrect = $true } + $Current = [PSCustomObject]@{ + reportToMicrosoft = [bool]$Policy.EnableReportToMicrosoft + customAddressCorrect = [bool]($Policy.ReportJunkToCustomizedAddress -eq $true -and @($Policy.ReportJunkAddresses) -eq $Email -and + $Policy.ReportNotJunkToCustomizedAddress -eq $true -and @($Policy.ReportNotJunkAddresses) -eq $Email -and + $Policy.ReportPhishToCustomizedAddress -eq $true -and @($Policy.ReportPhishAddresses) -eq $Email) + ruleCorrect = [bool]($Rule -and "$($Rule.State)" -eq 'Enabled' -and @($Rule.SentTo) -eq $Email) + } + } elseif ($State -eq 'enable') { + $Expected = [PSCustomObject]@{ reportToMicrosoft = $true; customAddressCorrect = $true; ruleCorrect = $true } + $Current = [PSCustomObject]@{ + reportToMicrosoft = [bool]$Policy.EnableReportToMicrosoft + customAddressCorrect = [bool]($Policy.ReportJunkToCustomizedAddress -ne $true -and $Policy.ReportNotJunkToCustomizedAddress -ne $true -and + $Policy.ReportPhishToCustomizedAddress -ne $true -and @($Policy.ReportJunkAddresses).Count -eq 0 -and + @($Policy.ReportNotJunkAddresses).Count -eq 0 -and @($Policy.ReportPhishAddresses).Count -eq 0) + ruleCorrect = [bool](-not $Rule -or "$($Rule.State)" -ne 'Enabled') + } + } else { + $Expected = [PSCustomObject]@{ reportingDisabled = $true; ruleCorrect = $true } + $Current = [PSCustomObject]@{ + reportingDisabled = [bool](-not $Policy -or ($Policy.EnableReportToMicrosoft -ne $true -and + $Policy.ReportJunkToCustomizedAddress -ne $true -and $Policy.ReportNotJunkToCustomizedAddress -ne $true -and + $Policy.ReportPhishToCustomizedAddress -ne $true)) + ruleCorrect = [bool](-not $Rule -or "$($Rule.State)" -ne 'Enabled') + } + } + + # Carried for the executor: which objects exist decides New- vs Set- vs Remove-. + $Current | Add-Member -NotePropertyName 'policyExists' -NotePropertyValue ([bool]$Policy) + $Current | Add-Member -NotePropertyName 'ruleExists' -NotePropertyValue ([bool]$Rule) + $Current | Add-Member -NotePropertyName 'ruleEnabled' -NotePropertyValue ([bool]($Rule -and "$($Rule.State)" -eq 'Enabled')) + $Current | Add-Member -NotePropertyName 'resolvedEmail' -NotePropertyValue $Email + + @{ Expected = $Expected; Current = $Current } +} diff --git a/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineWindowsBackupRestoreState.ps1 b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineWindowsBackupRestoreState.ps1 new file mode 100644 index 0000000000000..cb0583f716fab --- /dev/null +++ b/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/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineWorkItems.ps1 b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineWorkItems.ps1 index f01b224fa41e6..1f8ca82c16add 100644 --- a/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineWorkItems.ps1 +++ b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineWorkItems.ps1 @@ -64,7 +64,9 @@ function Get-CIPPBaselineWorkItems { $Definition = $Definitions | Where-Object { $_.name -eq $BaseName } | Select-Object -First 1 if ($Definition.instanceIdentity) { $IdentityValue = $Variables.$($Definition.instanceIdentity) - if ($IdentityValue -is [System.Management.Automation.PSCustomObject]) { $IdentityValue = $IdentityValue.value } + # .value ?? unwraps option objects whether they arrive as PSCustomObject or + # Hashtable - the durable pipeline delivers both shapes. + $IdentityValue = $IdentityValue.value ?? $IdentityValue if ("$IdentityValue") { return ('{0}#{1}' -f $BaseName, $IdentityValue) } } $InstanceKey diff --git a/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselinecalDefaultState.ps1 b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselinecalDefaultState.ps1 new file mode 100644 index 0000000000000..59a2456e6d8b3 --- /dev/null +++ b/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/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselinesharingDomainRestrictionState.ps1 b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselinesharingDomainRestrictionState.ps1 new file mode 100644 index 0000000000000..7601394f2fbab --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselinesharingDomainRestrictionState.ps1 @@ -0,0 +1,46 @@ +function Get-CIPPBaselinesharingDomainRestrictionState { + <# + .SYNOPSIS + Prepare hook for sharingDomainRestriction: SharePoint external sharing domain + restrictions. + .DESCRIPTION + Mode 'none' grades the mode alone; allowList/blockList grade the mode plus the + SORTED domain list on the matching side - the classic compared the sorted joined + lists, so order never matters and the off-side list is not graded. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Item, + $TenantFilter + ) + + $Settings = @(Get-CIPPBaselineCacheRows -TenantFilter $TenantFilter -Type 'SharePointAdminSettings') | Select-Object -First 1 + if (-not $Settings) { return @{ Current = $null } } + + $Mode = "$($Item.Variables.Mode.value ?? $Item.Variables.Mode)" + if ($Mode -notin @('none', 'allowList', 'blockList')) { return @{ Current = $null } } + + if ($Mode -eq 'none') { + return @{ + Expected = [PSCustomObject]@{ sharingDomainRestrictionMode = 'none' } + Current = [PSCustomObject]@{ sharingDomainRestrictionMode = "$($Settings.sharingDomainRestrictionMode)" } + } + } + + $Domains = @("$($Item.Variables.Domains)".Split(',').Trim() | Where-Object { $_ } | Sort-Object) + if ($Domains.Count -eq 0) { return @{ Current = $null } } + $CurrentList = if ($Mode -eq 'allowList') { @($Settings.sharingAllowedDomainList) } else { @($Settings.sharingBlockedDomainList) } + + @{ + Expected = [PSCustomObject]@{ + sharingDomainRestrictionMode = $Mode + restrictedDomains = @($Domains) + } + Current = [PSCustomObject]@{ + sharingDomainRestrictionMode = "$($Settings.sharingDomainRestrictionMode)" + restrictedDomains = @($CurrentList | Where-Object { $_ } | Sort-Object) + } + } +} diff --git a/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineActivityBasedTimeout.ps1 b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineActivityBasedTimeout.ps1 index 2a6336b525933..88b90ad193e06 100644 --- a/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineActivityBasedTimeout.ps1 +++ b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineActivityBasedTimeout.ps1 @@ -1,149 +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. - $CurrentTimeout = $(try { (@($Policy.definition)[0] | ConvertFrom-Json).ActivityBasedTimeoutPolicy.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) { - $PolicyDefinition = ConvertTo-Json -Compress -Depth 10 -InputObject ([PSCustomObject]@{ - ActivityBasedTimeoutPolicy = [PSCustomObject]@{ Version = 1; 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/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineAddDKIM.ps1 b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineAddDKIM.ps1 new file mode 100644 index 0000000000000..5b35d0ed90949 --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineAddDKIM.ps1 @@ -0,0 +1,39 @@ +function Invoke-CIPPBaselineAddDKIM { + <# + .SYNOPSIS + AddDKIM executor: creates missing DKIM configs and enables disabled ones. + .DESCRIPTION + Two bulk batches, the classic's: New-DkimSigningConfig (2048-bit, enabled) for + domains with no config, Set-DkimSigningConfig enabled for configs that exist + disabled. Partial failures log and continue; a batch where everything failed throws + so the run reports the failure honestly. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Remediate, + $TenantFilter, + $Current + ) + + $ToCreate = @($Current.domainsToCreate | Where-Object { $_ }) + $ToEnable = @($Current.domainsToEnable | Where-Object { $_ }) + if ($ToCreate.Count -eq 0 -and $ToEnable.Count -eq 0) { return } + + $Requests = [System.Collections.Generic.List[hashtable]]::new() + foreach ($Domain in $ToCreate) { + $Requests.Add(@{ CmdletInput = @{ CmdletName = 'New-DkimSigningConfig'; Parameters = @{ KeySize = 2048; DomainName = "$Domain"; Enabled = $true } } }) + } + foreach ($Domain in $ToEnable) { + $Requests.Add(@{ CmdletInput = @{ CmdletName = 'Set-DkimSigningConfig'; Parameters = @{ Identity = "$Domain"; Enabled = $true } } }) + } + + $Results = New-ExoBulkRequest -tenantid $TenantFilter -cmdletArray @($Requests) -useSystemMailbox $true + $Errors = @($Results | Where-Object { $_.error }) + foreach ($ErrorResult in $Errors) { + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "DKIM write failed: $(Get-NormalizedError -Message $ErrorResult.error)" -Sev 'Error' + } + if ($Errors.Count -ge $Requests.Count) { throw "Every DKIM write failed for $TenantFilter - see the log for the first error." } + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "DKIM: created $($ToCreate.Count) config(s), enabled $($ToEnable.Count)." -Sev 'Info' +} diff --git a/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineAppDeploy.ps1 b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineAppDeploy.ps1 new file mode 100644 index 0000000000000..58a1d2ab441b7 --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineAppDeploy.ps1 @@ -0,0 +1,119 @@ +function Invoke-CIPPBaselineAppDeploy { + <# + .SYNOPSIS + AppDeploy executor: deploys the configured applications to the tenant. + .DESCRIPTION + The classic's write. Copy mode: New-CIPPApplicationCopy per app id, permissions + copied from the source. Template mode: each App Approval template deploys by type - + gallery templates instantiate then copy permissions, application manifests create the + app+SP and consent the manifest's permissions (or reconcile permissions on an + existing same-name app), enterprise apps create the SP and apply the template's + permissions. Per-app failures log and continue, as the classic continued. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Remediate, + $TenantFilter, + $Current + ) + + $ServicePrincipals = @(New-CIPPDbRequest -TenantFilter $TenantFilter -Type 'ServicePrincipals') + $Mode = [string]($Remediate.mode.value ?? $Remediate.mode ?? 'copy') + + if ($Mode -eq 'copy') { + foreach ($App in @("$($Remediate.appids)" -split ',')) { + $App = $App.Trim() + if (-not $App) { continue } + $Application = $ServicePrincipals | Where-Object -Property appId -EQ $App + try { + New-CIPPApplicationCopy -App $App -Tenant $TenantFilter + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "Added application $($Application.displayName) ($App) and updated its permissions." -Sev 'Info' + } catch { + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "Failed to add app $($Application.displayName) ($App): $($_.Exception.Message)" -Sev 'Error' + } + } + return + } + + $TemplateIds = @($Remediate.templateIds | ForEach-Object { "$($_.value ?? $_)" } | Where-Object { $_ }) + $Table = Get-CIPPTable -TableName 'templates' + foreach ($TemplateId in $TemplateIds) { + try { + $Template = Get-CIPPAzDataTableEntity @Table -Filter "PartitionKey eq 'AppApprovalTemplate' and RowKey eq '$TemplateId'" + if (-not $Template) { + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "App approval template $TemplateId was not found." -Sev 'Error' + continue + } + $TemplateData = $Template.JSON | ConvertFrom-Json + $AppType = "$($TemplateData.AppType ?? 'EnterpriseApp')" + + if ($AppType -eq 'GalleryTemplate') { + $GalleryTemplateId = "$($TemplateData.GalleryTemplateId)" + if (-not $GalleryTemplateId) { + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "Gallery template id missing on template $($TemplateData.TemplateName)." -Sev 'Error' + continue + } + if ($GalleryTemplateId -in @($ServicePrincipals.applicationTemplateId)) { + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "Gallery template app $($TemplateData.AppName) already exists." -Sev 'Info' + continue + } + $InstantiateBody = @{ displayName = "$($TemplateData.AppName)" } | ConvertTo-Json -Depth 10 + $InstantiateResult = New-GraphPostRequest -uri "https://graph.microsoft.com/beta/applicationTemplates/$GalleryTemplateId/instantiate" -type POST -tenantid $TenantFilter -body $InstantiateBody + if ("$($InstantiateResult.application.appId)") { + New-CIPPApplicationCopy -App $InstantiateResult.application.appId -Tenant $TenantFilter + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "Deployed gallery template $($TemplateData.AppName) as $($InstantiateResult.application.appId)." -Sev 'Info' + } else { + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "Gallery template deployment returned no application id for $($TemplateData.AppName)." -Sev 'Warning' + } + } elseif ($AppType -eq 'ApplicationManifest') { + $ApplicationManifest = $TemplateData.ApplicationManifest + if (-not $ApplicationManifest) { + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "Application manifest missing on template $($TemplateData.TemplateName)." -Sev 'Error' + continue + } + $ExistingApp = $ServicePrincipals | Where-Object { "$($_.displayName)" -eq "$($TemplateData.AppName)" } | Select-Object -First 1 + if ($ExistingApp) { + # Same-name app: reconcile permissions instead of creating a duplicate. + $App = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/applications(appId='$($ExistingApp.appId)')" -tenantid $TenantFilter + $ExistingPermissions = $App.requiredResourceAccess | ConvertTo-Json -Depth 10 + $NewPermissions = $ApplicationManifest.requiredResourceAccess | ConvertTo-Json -Depth 10 + if ($ExistingPermissions -ne $NewPermissions) { + $UpdateBody = @{ requiredResourceAccess = $ApplicationManifest.requiredResourceAccess } | ConvertTo-Json -Depth 10 + $null = New-GraphPostRequest -type PATCH -uri "https://graph.microsoft.com/beta/applications(appId='$($ExistingApp.appId)')" -tenantid $TenantFilter -body $UpdateBody + Add-CIPPDelegatedPermission -RequiredResourceAccess $ApplicationManifest.requiredResourceAccess -ApplicationId $ExistingApp.appId -Tenantfilter $TenantFilter + Add-CIPPApplicationPermission -RequiredResourceAccess $ApplicationManifest.requiredResourceAccess -ApplicationId $ExistingApp.appId -Tenantfilter $TenantFilter + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "Updated permissions for the existing application '$($TemplateData.AppName)'." -Sev 'Info' + } + continue + } + $CleanManifest = $ApplicationManifest | ConvertTo-Json -Depth 10 | ConvertFrom-Json + foreach ($Property in @('appId', 'id', 'createdDateTime', 'deletedDateTime', 'createdByAppId', 'publisherDomain', 'servicePrincipalLockConfiguration', 'identifierUris', 'applicationIdUris')) { + $CleanManifest.PSObject.Properties.Remove($Property) + } + $CreatedApp = New-GraphPostRequest -uri 'https://graph.microsoft.com/beta/applications' -type POST -tenantid $TenantFilter -body ($CleanManifest | ConvertTo-Json -Depth 10) + if ("$($CreatedApp.appId)") { + $null = New-GraphPostRequest -uri 'https://graph.microsoft.com/beta/servicePrincipals' -type POST -tenantid $TenantFilter -body (@{ appId = $CreatedApp.appId } | ConvertTo-Json) + if ($CreatedApp.requiredResourceAccess) { + Add-CIPPDelegatedPermission -RequiredResourceAccess $CreatedApp.requiredResourceAccess -ApplicationId $CreatedApp.appId -Tenantfilter $TenantFilter + Add-CIPPApplicationPermission -RequiredResourceAccess $CreatedApp.requiredResourceAccess -ApplicationId $CreatedApp.appId -Tenantfilter $TenantFilter + } + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "Deployed application manifest $($TemplateData.AppName) as $($CreatedApp.appId)." -Sev 'Info' + } else { + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "Application manifest deployment returned no application id for $($TemplateData.AppName)." -Sev 'Error' + } + } else { + $AppId = "$($TemplateData.AppId)" + if ($AppId -notin @($ServicePrincipals.appId)) { + $null = New-GraphPostRequest -uri 'https://graph.microsoft.com/beta/servicePrincipals' -type POST -tenantid $TenantFilter -body "{ `"appId`": `"$AppId`" }" + } + Add-CIPPApplicationPermission -TemplateId $TemplateId -TenantFilter $TenantFilter + Add-CIPPDelegatedPermission -TemplateId $TemplateId -TenantFilter $TenantFilter + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "Added application $($TemplateData.AppName) from the enterprise app template and updated its permissions." -Sev 'Info' + } + } catch { + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "Failed to deploy template ${TemplateId}: $($_.Exception.Message)" -Sev 'Error' + } + } +} diff --git a/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineAppManagementPolicy.ps1 b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineAppManagementPolicy.ps1 new file mode 100644 index 0000000000000..9c256beefb8d6 --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineAppManagementPolicy.ps1 @@ -0,0 +1,23 @@ +function Invoke-CIPPBaselineAppManagementPolicy { + <# + .SYNOPSIS + AppManagementPolicy executor: patches the default app management policy. + .DESCRIPTION + The PATCH body is the hook's desired state verbatim - the graded expected and the + write are the same object by construction, so there is nothing to rebuild here. + App-only, v1.0, matching the classic's write. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Remediate, + $TenantFilter, + $Current + ) + + if (-not $Current.desiredState) { return } + $Body = ConvertTo-Json -Compress -Depth 20 -InputObject $Current.desiredState + $null = New-GraphPostRequest -tenantid $TenantFilter -uri 'https://graph.microsoft.com/v1.0/policies/defaultAppManagementPolicy' -type PATCH -body $Body -AsApp $true + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message 'Updated the default app management policy credential restrictions.' -Sev 'Info' +} diff --git a/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineAppleEnrollmentTypeProfile.ps1 b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineAppleEnrollmentTypeProfile.ps1 new file mode 100644 index 0000000000000..e60733b5d5b8d --- /dev/null +++ b/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/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineAssignmentFilterTemplate.ps1 b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineAssignmentFilterTemplate.ps1 new file mode 100644 index 0000000000000..8504a71da33f8 --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineAssignmentFilterTemplate.ps1 @@ -0,0 +1,41 @@ +function Invoke-CIPPBaselineAssignmentFilterTemplate { + <# + .SYNOPSIS + AssignmentFilterTemplate executor: creates the filter or patches the drifted fields. + .DESCRIPTION + Create goes through New-CIPPAssignmentFilter, the same path the classic and the + assignment filter UI use. Update PATCHes only the fields that differ - description, + platform, rule, assignmentFilterManagementType - matching the classic's field-level + update; displayName is the identity and is never rewritten. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Remediate, + $TenantFilter, + $Current + ) + + $Template = $Current.templateBody + if (-not $Template) { return } + + if (-not $Current.existingId) { + $Result = New-CIPPAssignmentFilter -FilterObject $Template -TenantFilter $TenantFilter -APIName 'Baselines' + if ($Result.Success -ne $true) { + throw "Failed to create assignment filter '$($Template.displayName)': $($Result.Message)" + } + return + } + + $PatchBody = [PSCustomObject]@{} + foreach ($Field in @('description', 'platform', 'rule', 'assignmentFilterManagementType')) { + if ("$($Current.$Field)" -ne "$($Template.$Field)") { + $PatchBody | Add-Member -NotePropertyName $Field -NotePropertyValue $Template.$Field + } + } + if (@($PatchBody.PSObject.Properties).Count -eq 0) { return } + + $null = New-GraphPostRequest -uri "https://graph.microsoft.com/beta/deviceManagement/assignmentFilters/$($Current.existingId)" -tenantid $TenantFilter -type PATCH -body (ConvertTo-Json -InputObject $PatchBody -Depth 10) + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "Updated assignment filter '$($Template.displayName)'." -Sev 'Info' +} diff --git a/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineAuthenticationMethods.ps1 b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineAuthenticationMethods.ps1 new file mode 100644 index 0000000000000..7c27ddc3efed3 --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineAuthenticationMethods.ps1 @@ -0,0 +1,43 @@ +function Invoke-CIPPBaselineAuthenticationMethods { + <# + .SYNOPSIS + AuthenticationMethods executor: writes every drifted method's configuration. + .DESCRIPTION + One Set-CIPPAuthenticationPolicy call per parameter set the hook carried - the + shared helper the classic and the identity UI use, which owns the per-method PATCH + shapes. Only drifted methods write; compliant ones are never touched. Partial + failures log and continue so one method's error cannot block the rest; a round + where every method failed throws. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Remediate, + $TenantFilter, + $Current + ) + + $Sets = @($Current.remediationSets | Where-Object { $_ }) + if ($Sets.Count -eq 0) { return } + + $Failures = 0 + foreach ($Set in $Sets) { + try { + # The hook carries the parameter set as a hashtable in-process; a JSON round-trip + # (resume, serialization) would hand it back as a PSCustomObject - accept both. + $Params = @{ Tenant = $TenantFilter; APIName = 'Baselines' } + if ($Set.Params -is [hashtable]) { + foreach ($Key in $Set.Params.Keys) { $Params[$Key] = $Set.Params[$Key] } + } else { + foreach ($Property in $Set.Params.PSObject.Properties) { $Params[$Property.Name] = $Property.Value } + } + $null = Set-CIPPAuthenticationPolicy @Params + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "Applied the $($Set.Label) authentication method configuration." -Sev 'Info' + } catch { + $Failures++ + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "Authentication method write failed for $($Set.Label): $($_.Exception.Message)" -Sev 'Error' + } + } + if ($Failures -ge $Sets.Count) { throw "Every authentication method write failed for $TenantFilter - see the log for the first error." } +} diff --git a/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineAutopilotProfile.ps1 b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineAutopilotProfile.ps1 new file mode 100644 index 0000000000000..d09a8e24d70be --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineAutopilotProfile.ps1 @@ -0,0 +1,42 @@ +function Invoke-CIPPBaselineAutopilotProfile { + <# + .SYNOPSIS + AutopilotProfile executor: deploys or updates the named Autopilot profile. + .DESCRIPTION + The classic's write, verbatim: one Set-CIPPDefaultAPDeploymentProfile call carrying + the derived deployment mode and user type. HideChangeAccount is always true in the + helper call - the classic hardcoded it despite exposing a switch. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Remediate, + $TenantFilter, + $Current + ) + + $UserType = $(if ($Remediate.notLocalAdmin -eq $true) { 'standard' } else { 'administrator' }) + $SelfDeploying = $Remediate.selfDeployingMode -eq $true + $DeploymentMode = $(if ($SelfDeploying) { 'shared' } else { 'singleUser' }) + $AllowWhiteGlove = $(if ($SelfDeploying) { $false } else { [bool]$Remediate.allowWhiteGlove }) + + $Parameters = @{ + TenantFilter = $TenantFilter + DisplayName = "$($Remediate.displayName.value ?? $Remediate.displayName)" + Description = "$($Remediate.description)" + UserType = $UserType + DeploymentMode = $DeploymentMode + AssignTo = ($Remediate.assignToAllDevices -eq $true) + DeviceNameTemplate = "$($Remediate.deviceNameTemplate)" + AllowWhiteGlove = $AllowWhiteGlove + CollectHash = [bool]$Remediate.collectHash + HideChangeAccount = $true + HidePrivacy = [bool]$Remediate.hidePrivacy + HideTerms = [bool]$Remediate.hideTerms + AutoKeyboard = [bool]$Remediate.autoKeyboard + Language = "$($Remediate.languages.value ?? $Remediate.languages)" + } + Set-CIPPDefaultAPDeploymentProfile @Parameters + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "Deployed the Autopilot profile '$($Remediate.displayName)'." -Sev 'Info' +} diff --git a/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineCATemplate.ps1 b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineCATemplate.ps1 index c9e2c687651ad..c369e476cb591 100644 --- a/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineCATemplate.ps1 +++ b/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/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineCollaborationDomainRestriction.ps1 b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineCollaborationDomainRestriction.ps1 new file mode 100644 index 0000000000000..dea0db79f17a5 --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineCollaborationDomainRestriction.ps1 @@ -0,0 +1,46 @@ +function Invoke-CIPPBaselineCollaborationDomainRestriction { + <# + .SYNOPSIS + CollaborationDomainRestriction executor: writes the B2B invitation allow-list. + .DESCRIPTION + Rebuilds the domain policy ON TOP of the carried existing definition so sibling + settings inside the JSON blob (AutoRedeemPolicy) survive - overwriting the whole + definition would silently reset them. Allowed and blocked lists are mutually + exclusive in the portal, so setting the allow-list clears the block-list, exactly + as the classic wrote it. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Remediate, + $TenantFilter, + $Current + ) + + $Desired = @("$($Remediate.allowedDomains)" -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ }) + if ($Desired.Count -eq 0) { return } + + $Definition = $Current.existingDefinition + if ($null -eq $Definition) { $Definition = [PSCustomObject]@{} } + if ($null -eq $Definition.B2BManagementPolicy) { + $Definition | Add-Member -NotePropertyName 'B2BManagementPolicy' -NotePropertyValue ([PSCustomObject]@{}) -Force + } + $Definition.B2BManagementPolicy | Add-Member -NotePropertyName 'InvitationsAllowedAndBlockedDomainsPolicy' -NotePropertyValue ([PSCustomObject]@{ + AllowedDomains = @($Desired) + BlockedDomains = @() + }) -Force + + $Body = @{ + displayName = 'B2BManagementPolicy' + definition = @(($Definition | ConvertTo-Json -Depth 10 -Compress)) + isOrganizationDefault = $true + } | ConvertTo-Json -Depth 10 -Compress + + if (-not [string]::IsNullOrWhiteSpace("$($Current.policyId)")) { + $null = New-GraphPostRequest -tenantid $TenantFilter -uri "https://graph.microsoft.com/beta/policies/b2bManagementPolicies/$($Current.policyId)" -type PATCH -body $Body + } else { + $null = New-GraphPostRequest -tenantid $TenantFilter -uri 'https://graph.microsoft.com/beta/policies/b2bManagementPolicies' -type POST -body $Body + } + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "Restricted B2B collaboration to $($Desired.Count) domain(s)." -Sev 'Info' +} diff --git a/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineColleagueImpersonationAlert.ps1 b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineColleagueImpersonationAlert.ps1 new file mode 100644 index 0000000000000..ee7f8ab0ca9d6 --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineColleagueImpersonationAlert.ps1 @@ -0,0 +1,83 @@ +function Invoke-CIPPBaselineColleagueImpersonationAlert { + <# + .SYNOPSIS + ColleagueImpersonationAlert executor: creates or updates the five impersonation + transport rules. + .DESCRIPTION + The classic's merge-preserving write. Per rule: the exempt sender list is the + configured additions PLUS whatever the existing rule already carries, the exempt + domain list is the accepted-domain exemptions PLUS the existing rule's - manual + operator exemptions are never stripped. The disclaimer HTML falls back to an + existing rule's when the baseline leaves it blank, and a rule that cannot fall + back refuses to write rather than deploying an empty banner. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Remediate, + $TenantFilter, + $Current + ) + + $RuleHtml = "$($Remediate.disclaimerHtml)" + if ([string]::IsNullOrWhiteSpace($RuleHtml)) { + $Fallback = @($Current.ruleStates | Where-Object { -not [string]::IsNullOrWhiteSpace("$($_.ExistingDisclaimer)") }) | Select-Object -First 1 + if ($Fallback) { + $RuleHtml = "$($Fallback.ExistingDisclaimer)" + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message 'Disclaimer HTML not configured - using the HTML from an existing rule.' -Sev 'Info' + } else { + throw 'Disclaimer HTML is not configured and no existing rule carries one - configure the Disclaimer HTML field on the baseline.' + } + } + + $Failures = 0 + $RuleStates = @($Current.ruleStates) + foreach ($RuleState in $RuleStates) { + $SeenSenders = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + $ExemptSenders = [System.Collections.Generic.List[string]]::new() + foreach ($Address in @($Current.additionalExemptSenders) + @($RuleState.ExistingExemptSender)) { + $Trimmed = "$Address".Trim() + if (-not [string]::IsNullOrWhiteSpace($Trimmed) -and $SeenSenders.Add($Trimmed)) { $ExemptSenders.Add($Trimmed) } + } + $SeenDomains = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + $ExemptDomains = [System.Collections.Generic.List[string]]::new() + foreach ($Domain in @($Current.autoExemptDomains) + @($RuleState.ExistingExemptDomain)) { + $Trimmed = "$Domain".Trim() + if (-not [string]::IsNullOrWhiteSpace($Trimmed) -and $SeenDomains.Add($Trimmed)) { $ExemptDomains.Add($Trimmed) } + } + + $CmdParams = @{ + FromScope = 'NotInOrganization' + ApplyHtmlDisclaimerLocation = 'Prepend' + ApplyHtmlDisclaimerFallbackAction = 'Wrap' + ApplyHtmlDisclaimerText = $RuleHtml + HeaderMatchesMessageHeader = 'From' + HeaderMatchesPatterns = @($RuleState.Names) + Comments = "CIPP managed rule ($($RuleState.Range)) - Letters $($RuleState.Range)" + } + # Both exception lists only when non-empty: Exchange rejects an empty + # ExceptIfSenderDomainIs outright, which broke every onmicrosoft-only tenant + # (their sole accepted domain is filtered off the auto-exemption list). + if ($ExemptDomains.Count -gt 0) { $CmdParams['ExceptIfSenderDomainIs'] = @($ExemptDomains) } + if ($ExemptSenders.Count -gt 0) { $CmdParams['ExceptIfFromAddressContainsWords'] = @($ExemptSenders) } + + try { + if ($RuleState.RuleExists) { + $CmdParams['Identity'] = "$($RuleState.RuleName)" + $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Set-TransportRule' -cmdParams $CmdParams -UseSystemMailbox $true + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "Updated the impersonation rule '$($RuleState.RuleName)' ($(@($RuleState.Names).Count) pattern(s))." -Sev 'Info' + } else { + $CmdParams['Name'] = "$($RuleState.RuleName)" + $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'New-TransportRule' -cmdParams $CmdParams -UseSystemMailbox $true + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "Created the impersonation rule '$($RuleState.RuleName)' ($(@($RuleState.Names).Count) pattern(s))." -Sev 'Info' + } + } catch { + $Failures++ + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "Failed to write the impersonation rule '$($RuleState.RuleName)': $($_.Exception.Message)" -Sev 'Error' + } + } + if ($RuleStates.Count -gt 0 -and $Failures -ge $RuleStates.Count) { + throw "Every impersonation rule write failed for $TenantFilter - see the log for the first error." + } +} diff --git a/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineCopilotLimitedMode.ps1 b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineCopilotLimitedMode.ps1 new file mode 100644 index 0000000000000..b992b46727e25 --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineCopilotLimitedMode.ps1 @@ -0,0 +1,31 @@ +function Invoke-CIPPBaselineCopilotLimitedMode { + <# + .SYNOPSIS + CopilotLimitedMode executor: sets Copilot limited mode and its scoping group. + .DESCRIPTION + One PATCH with the odata-typed body, DELEGATED - the Copilot admin settings API + rejects app-only tokens, which is why the classic (and the cache collector) stay on + the delegated token. Disabling clears the group id, matching the classic. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Remediate, + $TenantFilter, + $Current + ) + + $Enabled = [bool]($Remediate.limitedModeEnabled -eq $true -or "$($Remediate.limitedModeEnabled)" -eq 'True') + if ($Enabled -and [string]::IsNullOrWhiteSpace("$($Current.resolvedGroupId)")) { + throw 'Copilot limited mode: no resolved scoping group - refusing to enable without one.' + } + + $Body = [ordered]@{ + '@odata.type' = '#microsoft.graph.copilotAdminLimitedMode' + isEnabledForGroup = $Enabled + groupId = if ($Enabled) { "$($Current.resolvedGroupId)" } else { $null } + } | ConvertTo-Json -Compress + $null = New-GraphPostRequest -tenantid $TenantFilter -uri 'https://graph.microsoft.com/beta/copilot/admin/settings/limitedMode' -type PATCH -body $Body -ContentType 'application/json' + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "Set Copilot limited mode to $Enabled." -Sev 'Info' +} diff --git a/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineCustomBannedPasswordList.ps1 b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineCustomBannedPasswordList.ps1 new file mode 100644 index 0000000000000..ec10ef34a53fd --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineCustomBannedPasswordList.ps1 @@ -0,0 +1,63 @@ +function Invoke-CIPPBaselineCustomBannedPasswordList { + <# + .SYNOPSIS + CustomBannedPasswordList executor: merges the configured words into the banned + password list and enables the check. + .DESCRIPTION + The classic's additive merge: missing words prepend to the tenant's current list, + deduplicated and capped at Entra's 1000, tab-joined on the wire. Creating from + scratch carries the classic's defaults for the sibling lockout values; updating + PATCHes only the check flag and the list so the SmartLockout standard's values are + never touched. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Remediate, + $TenantFilter, + $Current + ) + + $Missing = @($Current.missingBannedWords | Where-Object { $_ }) + if ($Missing.Count -eq 0 -and $Current.bannedPasswordCheckEnabled -eq $true) { return } + + $AllWords = @(@($Missing) + @($Current.currentWords) | Where-Object { $_ } | Select-Object -Unique -First 1000) + $ListValue = $AllWords -join ([char]9) + + # Always read the object LIVE: a cached id can be mid-rewrite stale (concurrent + # one-offs share this object), the merge must include the live words so nothing drops, + # and the update must resend EVERY template value - Graph rejects a partial values + # array - so the lockout values come from the live object rather than this standard + # guessing them. + $Live = @(New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/settings' -tenantid $TenantFilter) | Where-Object { "$($_.templateId)" -eq '5cf42378-d67d-4f36-ba46-e8b86229381d' } | Select-Object -First 1 + if ($Live) { + $LiveWords = @("$((@($Live.values) | Where-Object { $_.name -eq 'BannedPasswordList' }).value)" -split ([char]9) | Where-Object { $_ }) + $AllWords = @(@($Missing) + @($LiveWords) | Where-Object { $_ } | Select-Object -Unique -First 1000) + $ListValue = $AllWords -join ([char]9) + $LiveValueOf = { param($Name, $Fallback) $Value = "$((@($Live.values) | Where-Object { $_.name -eq $Name }).value)"; if ([string]::IsNullOrWhiteSpace($Value)) { $Fallback } else { $Value } } + $Body = @{ values = @( + @{ name = 'EnableBannedPasswordCheck'; value = 'True' } + @{ name = 'BannedPasswordList'; value = $ListValue } + @{ name = 'LockoutDurationInSeconds'; value = (& $LiveValueOf 'LockoutDurationInSeconds' '60') } + @{ name = 'LockoutThreshold'; value = (& $LiveValueOf 'LockoutThreshold' '10') } + @{ name = 'EnableBannedPasswordCheckOnPremises'; value = (& $LiveValueOf 'EnableBannedPasswordCheckOnPremises' 'False') } + @{ name = 'BannedPasswordCheckOnPremisesMode'; value = (& $LiveValueOf 'BannedPasswordCheckOnPremisesMode' 'Audit') } + ) } | ConvertTo-Json -Depth 10 -Compress + $null = New-GraphPostRequest -tenantid $TenantFilter -uri "https://graph.microsoft.com/beta/settings/$($Live.id)" -type PATCH -body $Body + } else { + $Body = @{ + templateId = '5cf42378-d67d-4f36-ba46-e8b86229381d' + values = @( + @{ name = 'EnableBannedPasswordCheck'; value = 'True' } + @{ name = 'BannedPasswordList'; value = $ListValue } + @{ name = 'LockoutDurationInSeconds'; value = '60' } + @{ name = 'LockoutThreshold'; value = '10' } + @{ name = 'EnableBannedPasswordCheckOnPremises'; value = 'False' } + @{ name = 'BannedPasswordCheckOnPremisesMode'; value = 'Audit' } + ) + } | ConvertTo-Json -Depth 10 -Compress + $null = New-GraphPostRequest -tenantid $TenantFilter -uri 'https://graph.microsoft.com/beta/settings' -type POST -body $Body + } + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "Banned password list: added $($Missing.Count) word(s), list now $($AllWords.Count)." -Sev 'Info' +} diff --git a/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineDefenderASRPolicy.ps1 b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineDefenderASRPolicy.ps1 new file mode 100644 index 0000000000000..07c6caf1d7886 --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineDefenderASRPolicy.ps1 @@ -0,0 +1,50 @@ +function Invoke-CIPPBaselineDefenderASRPolicy { + <# + .SYNOPSIS + DefenderASRPolicy executor: recreates the 'ASR Default rules' settings catalog policy. + .DESCRIPTION + The classic's write: delete the drifted policy, then recreate through the + Set-CIPPDefenderASRPolicy helper which owns the rules group and the assignment. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Remediate, + $TenantFilter, + $Current + ) + + if (-not [string]::IsNullOrWhiteSpace("$($Current.policyId)")) { + $null = New-GraphPostRequest -uri "https://graph.microsoft.com/beta/deviceManagement/configurationPolicies('$($Current.policyId)')" -tenantid $TenantFilter -type DELETE + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message 'Deleted the drifted Defender ASR policy for recreation.' -Sev 'Info' + } + + $Pick = { param($Value, $Default) [string]($Value.value ?? $Value ?? $Default) } + $ASRSettings = @{ + Mode = (& $Pick $Remediate.mode 'block') + BlockObfuscatedScripts = [bool]$Remediate.blockObfuscatedScripts + BlockAdobeChild = [bool]$Remediate.blockAdobeChild + BlockWin32Macro = [bool]$Remediate.blockWin32Macro + BlockCredentialStealing = [bool]$Remediate.blockCredentialStealing + BlockPSExec = [bool]$Remediate.blockPSExec + WMIPersistence = [bool]$Remediate.wmiPersistence + BlockSystemTools = [bool]$Remediate.blockSystemTools + BlockOfficeExes = [bool]$Remediate.blockOfficeExes + BlockOfficeApps = [bool]$Remediate.blockOfficeApps + BlockSafeMode = [bool]$Remediate.blockSafeMode + BlockYoungExe = [bool]$Remediate.blockYoungExe + blockJSVB = [bool]$Remediate.blockJSVB + BlockWebshellForServers = [bool]$Remediate.blockWebshellForServers + blockOfficeComChild = [bool]$Remediate.blockOfficeComChild + blockOfficeChild = [bool]$Remediate.blockOfficeChild + BlockUntrustedUSB = [bool]$Remediate.blockUntrustedUSB + EnableRansomwareVac = [bool]$Remediate.enableRansomwareVac + BlockExesMail = [bool]$Remediate.blockExesMail + BlockUnsignedDrivers = [bool]$Remediate.blockUnsignedDrivers + AssignTo = (& $Pick $Remediate.assignTo 'none') + } + + $Result = Set-CIPPDefenderASRPolicy -TenantFilter $TenantFilter -ASR $ASRSettings -APIName 'Baselines' + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "$Result" -Sev 'Info' +} diff --git a/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineDefenderAVPolicy.ps1 b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineDefenderAVPolicy.ps1 new file mode 100644 index 0000000000000..8b2565390f08e --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineDefenderAVPolicy.ps1 @@ -0,0 +1,62 @@ +function Invoke-CIPPBaselineDefenderAVPolicy { + <# + .SYNOPSIS + DefenderAVPolicy executor: recreates the 'Default AV Policy' settings catalog policy. + .DESCRIPTION + The classic's write: a drifted policy is DELETED and recreated through the + Set-CIPPDefenderAVPolicy helper - settings catalog policies are replaced whole, not + patched. The helper owns the settingInstance tree and the assignment. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Remediate, + $TenantFilter, + $Current + ) + + if (-not [string]::IsNullOrWhiteSpace("$($Current.policyId)")) { + $null = New-GraphPostRequest -uri "https://graph.microsoft.com/beta/deviceManagement/configurationPolicies('$($Current.policyId)')" -tenantid $TenantFilter -type DELETE + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message 'Deleted the drifted Defender AV policy for recreation.' -Sev 'Info' + } + + $Pick = { param($Value, $Default) [string]($Value.value ?? $Value ?? $Default) } + $PolicySettings = @{ + ScanArchives = [bool]$Remediate.scanArchives + AllowBehavior = [bool]$Remediate.allowBehavior + AllowCloudProtection = [bool]$Remediate.allowCloudProtection + AllowEmailScanning = [bool]$Remediate.allowEmailScanning + AllowFullScanNetwork = [bool]$Remediate.allowFullScanNetwork + AllowFullScanRemovable = [bool]$Remediate.allowFullScanRemovable + AllowScriptScan = [bool]$Remediate.allowScriptScan + AllowDownloadable = [bool]$Remediate.allowDownloadable + AllowRealTime = [bool]$Remediate.allowRealTime + AllowNetwork = [bool]$Remediate.allowNetwork + AllowUI = [bool]$Remediate.allowUI + CheckSigs = [bool]$Remediate.checkSigs + DisableCatchupFullScan = [bool]$Remediate.disableCatchupFullScan + DisableCatchupQuickScan = [bool]$Remediate.disableCatchupQuickScan + LowCPU = [bool]$Remediate.lowCPU + MeteredConnectionUpdates = [bool]$Remediate.meteredConnectionUpdates + DisableLocalAdminMerge = [bool]$Remediate.disableLocalAdminMerge + AvgCPULoadFactor = [int]"$($Remediate.avgCPULoadFactor ?? 50)" + SignatureUpdateInterval = [int]"$($Remediate.signatureUpdateInterval ?? 8)" + CloudExtendedTimeout = [int]"$($Remediate.cloudExtendedTimeout ?? 0)" + AssignTo = (& $Pick $Remediate.assignTo 'none') + } + if ($Remediate.allowOnAccessProtection) { $PolicySettings['AllowOnAccessProtection'] = @{ value = (& $Pick $Remediate.allowOnAccessProtection '1') } } + if ($Remediate.submitSamplesConsent) { $PolicySettings['SubmitSamplesConsent'] = @{ value = (& $Pick $Remediate.submitSamplesConsent '1') } } + if ($Remediate.enableNetworkProtection) { $PolicySettings['EnableNetworkProtection'] = @{ value = (& $Pick $Remediate.enableNetworkProtection '0') } } + if ($Remediate.cloudBlockLevel) { $PolicySettings['CloudBlockLevel'] = @{ value = (& $Pick $Remediate.cloudBlockLevel '0') } } + + $RemediationActions = @{} + if ($Remediate.remediationLow) { $RemediationActions['Low'] = @{ value = (& $Pick $Remediate.remediationLow 'quarantine') } } + if ($Remediate.remediationModerate) { $RemediationActions['Moderate'] = @{ value = (& $Pick $Remediate.remediationModerate 'quarantine') } } + if ($Remediate.remediationHigh) { $RemediationActions['High'] = @{ value = (& $Pick $Remediate.remediationHigh 'quarantine') } } + if ($Remediate.remediationSevere) { $RemediationActions['Severe'] = @{ value = (& $Pick $Remediate.remediationSevere 'quarantine') } } + if ($RemediationActions.Count -gt 0) { $PolicySettings['Remediation'] = $RemediationActions } + + $Result = Set-CIPPDefenderAVPolicy -TenantFilter $TenantFilter -PolicySettings $PolicySettings -APIName 'Baselines' + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "$Result" -Sev 'Info' +} diff --git a/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineDefenderCompliancePolicy.ps1 b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineDefenderCompliancePolicy.ps1 new file mode 100644 index 0000000000000..f208ba8b7ffe4 --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineDefenderCompliancePolicy.ps1 @@ -0,0 +1,57 @@ +function Invoke-CIPPBaselineDefenderCompliancePolicy { + <# + .SYNOPSIS + DefenderCompliancePolicy executor: writes the MDE connector settings. + .DESCRIPTION + The classic's write: enable the MDE connector first (nothing else can succeed without + it), then PATCH the existing connector or POST a new one carrying the FULL settings + object - the connector endpoint replaces state, not merges it. App-only, as the + classic wrote it. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Remediate, + $TenantFilter, + $Current + ) + + $ConnectorStatus = Enable-CIPPMDEConnector -TenantFilter $TenantFilter + if (-not $ConnectorStatus.Success) { + throw "Failed to enable the MDE connector: $($ConnectorStatus.ErrorMessage)" + } + + $ConnectWindows = [bool]$Remediate.connectWindows + $SettingsObj = @{ + id = 'fc780465-2017-40d4-a0c5-307022471b92' + partnerUnresponsivenessThresholdInDays = 7 + androidEnabled = [bool]$Remediate.connectAndroid + iosEnabled = [bool]$Remediate.connectIos + windowsEnabled = $ConnectWindows + macEnabled = [bool]$Remediate.connectMac + partnerUnsupportedOsVersionBlocked = [bool]$Remediate.blockUnsupportedOS + allowPartnerToCollectIOSApplicationMetadata = [bool]$Remediate.appSync + allowPartnerToCollectIOSPersonalApplicationMetadata = [bool]$Remediate.allowPartnerToCollectIosPersonalApplicationMetadata + androidDeviceBlockedOnMissingPartnerData = [bool]$Remediate.androidDeviceBlockedOnMissingPartnerData + iosDeviceBlockedOnMissingPartnerData = [bool]$Remediate.iosDeviceBlockedOnMissingPartnerData + windowsDeviceBlockedOnMissingPartnerData = $(if ($ConnectWindows) { $true } else { [bool]$Remediate.windowsDeviceBlockedOnMissingPartnerData }) + macDeviceBlockedOnMissingPartnerData = [bool]$Remediate.macDeviceBlockedOnMissingPartnerData + androidMobileApplicationManagementEnabled = [bool]$Remediate.connectAndroidCompliance + iosMobileApplicationManagementEnabled = [bool]$Remediate.connectIosCompliance + windowsMobileApplicationManagementEnabled = [bool]$Remediate.windowsMobileApplicationManagementEnabled + allowPartnerToCollectIosCertificateMetadata = [bool]$Remediate.allowPartnerToCollectIosCertificateMetadata + allowPartnerToCollectIosPersonalCertificateMetadata = [bool]$Remediate.allowPartnerToCollectIosPersonalCertificateMetadata + grantMobileThreatDefensePartnerRole = [bool]$Remediate.grantMobileThreatDefensePartnerRole + microsoftDefenderForEndpointAttachEnabled = $true + } + $Body = $SettingsObj | ConvertTo-Json -Compress + + if ($Current.connectorExists) { + $null = New-GraphPostRequest -uri 'https://graph.microsoft.com/beta/deviceManagement/mobileThreatDefenseConnectors/fc780465-2017-40d4-a0c5-307022471b92' -tenantid $TenantFilter -type PATCH -body $Body -AsApp $true + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message 'Updated the Defender compliance connector settings.' -Sev 'Info' + } else { + $null = New-GraphPostRequest -uri 'https://graph.microsoft.com/beta/deviceManagement/mobileThreatDefenseConnectors/' -tenantid $TenantFilter -type POST -body $Body -AsApp $true + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message 'Created the Defender compliance connector settings.' -Sev 'Info' + } +} diff --git a/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineDefenderEDRPolicy.ps1 b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineDefenderEDRPolicy.ps1 new file mode 100644 index 0000000000000..2a422e4408cc9 --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineDefenderEDRPolicy.ps1 @@ -0,0 +1,31 @@ +function Invoke-CIPPBaselineDefenderEDRPolicy { + <# + .SYNOPSIS + DefenderEDRPolicy executor: recreates the 'EDR Configuration' settings catalog policy. + .DESCRIPTION + The classic's write: delete the drifted policy, then recreate through the + Set-CIPPDefenderEDRPolicy helper which owns the settings tree and the assignment. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Remediate, + $TenantFilter, + $Current + ) + + if (-not [string]::IsNullOrWhiteSpace("$($Current.policyId)")) { + $null = New-GraphPostRequest -uri "https://graph.microsoft.com/beta/deviceManagement/configurationPolicies('$($Current.policyId)')" -tenantid $TenantFilter -type DELETE + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message 'Deleted the drifted Defender EDR policy for recreation.' -Sev 'Info' + } + + $EDRSettings = @{ + Config = [bool]$Remediate.config + SampleSharing = [bool]$Remediate.sampleSharing + AssignTo = [string]($Remediate.assignTo.value ?? $Remediate.assignTo ?? 'none') + } + + $Result = Set-CIPPDefenderEDRPolicy -TenantFilter $TenantFilter -EDR $EDRSettings -APIName 'Baselines' + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "$Result" -Sev 'Info' +} diff --git a/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineDefenderExclusionPolicy.ps1 b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineDefenderExclusionPolicy.ps1 new file mode 100644 index 0000000000000..dd1e41c19fac3 --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineDefenderExclusionPolicy.ps1 @@ -0,0 +1,37 @@ +function Invoke-CIPPBaselineDefenderExclusionPolicy { + <# + .SYNOPSIS + DefenderExclusionPolicy executor: recreates the 'Default AV Exclusion Policy' + settings catalog policy. + .DESCRIPTION + The classic's write: delete the drifted policy, then recreate through the + Set-CIPPDefenderExclusionPolicy helper with only the configured collections. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Remediate, + $TenantFilter, + $Current + ) + + if (-not [string]::IsNullOrWhiteSpace("$($Current.policyId)")) { + $null = New-GraphPostRequest -uri "https://graph.microsoft.com/beta/deviceManagement/configurationPolicies('$($Current.policyId)')" -tenantid $TenantFilter -type DELETE + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message 'Deleted the drifted Defender exclusion policy for recreation.' -Sev 'Info' + } + + $Extensions = @(("$($Remediate.excludedExtensions)" -replace ' ', '') -split ',' | Where-Object { $_ } | Sort-Object) + $Paths = @("$($Remediate.excludedPaths)" -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ } | Sort-Object) + $Processes = @("$($Remediate.excludedProcesses)" -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ } | Sort-Object) + + $ExclusionSettings = @{ + AssignTo = [string]($Remediate.assignTo.value ?? $Remediate.assignTo ?? 'none') + } + if ($Extensions.Count -gt 0) { $ExclusionSettings['excludedExtensions'] = $Extensions } + if ($Paths.Count -gt 0) { $ExclusionSettings['excludedPaths'] = $Paths } + if ($Processes.Count -gt 0) { $ExclusionSettings['excludedProcesses'] = $Processes } + + $Result = Set-CIPPDefenderExclusionPolicy -TenantFilter $TenantFilter -DefenderExclusions $ExclusionSettings -APIName 'Baselines' + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "$Result" -Sev 'Info' +} diff --git a/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineDeployCheckChromeExtension.ps1 b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineDeployCheckChromeExtension.ps1 new file mode 100644 index 0000000000000..fdaae901542c3 --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineDeployCheckChromeExtension.ps1 @@ -0,0 +1,335 @@ +function Invoke-CIPPBaselineDeployCheckChromeExtension { + <# + .SYNOPSIS + DeployCheckChromeExtension executor: deploys the Check by CyberDrain Win32 app. + .DESCRIPTION + The classic's write, verbatim: build the install/uninstall/detection scripts from the + configured settings, fingerprint the install script into the app description + ([cfg:hash]), clean up the legacy OMA-URI policies, and redeploy ONLY when the + fingerprint changed - Intune sees a new detection script and re-runs it fleet-wide. + The definition runs checkBeforeRun:false so this self-gating write happens every + remediation run, exactly as the classic's remediate block did. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Remediate, + $TenantFilter, + $Current + ) + + $ChromeExtensionId = 'benimdeioplgkhanklclahllklceahbe' + $EdgeExtensionId = 'knepjpocdagponkonnbggpcnhnaikajg' + $AppDisplayName = 'Check by CyberDrain - Browser Extension' + + $CippConfigTable = Get-CippTable -tablename Config + $CippConfig = Get-CIPPAzDataTableEntity @CippConfigTable -Filter "PartitionKey eq 'InstanceProperties' and RowKey eq 'CIPPURL'" + $CIPPURL = 'https://{0}' -f $CippConfig.Value + + $ShowNotifications = [int][bool]($Remediate.showNotifications ?? $true) + $EnableValidPageBadge = [int][bool]($Remediate.enableValidPageBadge ?? $false) + $EnablePageBlocking = [int][bool]($Remediate.enablePageBlocking ?? $true) + $ForceToolbarPin = [int][bool]($Remediate.forceToolbarPin ?? $true) + $EnableCippReporting = [int][bool]($Remediate.enableCippReporting ?? $false) + $CippServerUrl = $CIPPURL + $CippTenantId = $TenantFilter + $CustomRulesUrl = "$($Remediate.customRulesUrl ?? '')" + $UpdateInterval = [int]"$($Remediate.updateInterval ?? 24)" + $EnableDebugLogging = [int][bool]($Remediate.enableDebugLogging ?? $false) + $EnableGenericWebhook = [int][bool]($Remediate.enableGenericWebhook ?? $false) + $WebhookUrl = "$($Remediate.webhookUrl ?? '')" + $WebhookEvents = @($Remediate.webhookEvents | ForEach-Object { "$($_.value ?? $_)" } | Where-Object { $_ }) + $UrlAllowlist = @($Remediate.urlAllowlist | ForEach-Object { "$($_.value ?? $_)" } | Where-Object { $_ }) + $DomainSquattingEnabled = [int][bool]($Remediate.domainSquattingEnabled ?? $true) + $CompanyName = "$($Remediate.companyName ?? '')" + $ProductName = "$($Remediate.productName ?? '')" + $SupportEmail = "$($Remediate.supportEmail ?? '')" + $SupportUrl = "$($Remediate.supportUrl ?? '')" + $PrivacyPolicyUrl = "$($Remediate.privacyPolicyUrl ?? '')" + $AboutUrl = "$($Remediate.aboutUrl ?? '')" + $PrimaryColor = if ($Remediate.primaryColor) { '#{0}' -f ("$($Remediate.primaryColor)" -replace '^#+', '') } else { '#F77F00' } + $LogoUrl = "$($Remediate.logoUrl ?? '')" + + $InstallScript = @" +# Check Chrome Extension - Install Script (generated by CIPP) +`$chromeExtensionId = '$ChromeExtensionId' +`$edgeExtensionId = '$EdgeExtensionId' + +# Extension settings per browser +`$browsers = @( + @{ + ExtensionId = `$chromeExtensionId + UpdateUrl = 'https://clients2.google.com/service/update2/crx' + ManagedStorageKey = "HKLM:\SOFTWARE\Policies\Google\Chrome\3rdparty\extensions\`$chromeExtensionId\policy" + ExtSettingsKey = "HKLM:\SOFTWARE\Policies\Google\Chrome\ExtensionSettings\`$chromeExtensionId" + ToolbarProp = 'toolbar_pin' + ToolbarPinned = 'force_pinned' + ToolbarUnpinned = 'default_unpinned' + }, + @{ + ExtensionId = `$edgeExtensionId + UpdateUrl = 'https://edge.microsoft.com/extensionwebstorebase/v1/crx' + ManagedStorageKey = "HKLM:\SOFTWARE\Policies\Microsoft\Edge\3rdparty\extensions\`$edgeExtensionId\policy" + ExtSettingsKey = "HKLM:\SOFTWARE\Policies\Microsoft\Edge\ExtensionSettings\`$edgeExtensionId" + ToolbarProp = 'toolbar_state' + ToolbarPinned = 'force_shown' + ToolbarUnpinned = 'default_hidden' + } +) + +foreach (`$b in `$browsers) { + # Managed storage - core settings + if (!(Test-Path `$b.ManagedStorageKey)) { New-Item -Path `$b.ManagedStorageKey -Force | Out-Null } + New-ItemProperty -Path `$b.ManagedStorageKey -Name 'showNotifications' -PropertyType DWord -Value $ShowNotifications -Force | Out-Null + New-ItemProperty -Path `$b.ManagedStorageKey -Name 'enableValidPageBadge' -PropertyType DWord -Value $EnableValidPageBadge -Force | Out-Null + New-ItemProperty -Path `$b.ManagedStorageKey -Name 'enablePageBlocking' -PropertyType DWord -Value $EnablePageBlocking -Force | Out-Null + New-ItemProperty -Path `$b.ManagedStorageKey -Name 'enableCippReporting' -PropertyType DWord -Value $EnableCippReporting -Force | Out-Null + New-ItemProperty -Path `$b.ManagedStorageKey -Name 'cippServerUrl' -PropertyType String -Value '$CippServerUrl' -Force | Out-Null + New-ItemProperty -Path `$b.ManagedStorageKey -Name 'cippTenantId' -PropertyType String -Value '$CippTenantId' -Force | Out-Null + New-ItemProperty -Path `$b.ManagedStorageKey -Name 'customRulesUrl' -PropertyType String -Value '$CustomRulesUrl' -Force | Out-Null + New-ItemProperty -Path `$b.ManagedStorageKey -Name 'updateInterval' -PropertyType DWord -Value $UpdateInterval -Force | Out-Null + New-ItemProperty -Path `$b.ManagedStorageKey -Name 'enableDebugLogging' -PropertyType DWord -Value $EnableDebugLogging -Force | Out-Null + + # Managed storage - domainSquatting subkey + `$domainSquattingKey = "`$(`$b.ManagedStorageKey)\domainSquatting" + if (!(Test-Path `$domainSquattingKey)) { New-Item -Path `$domainSquattingKey -Force | Out-Null } + New-ItemProperty -Path `$domainSquattingKey -Name 'enabled' -PropertyType DWord -Value $DomainSquattingEnabled -Force | Out-Null + + # Managed storage - customBranding subkey + `$brandingKey = "`$(`$b.ManagedStorageKey)\customBranding" + if (!(Test-Path `$brandingKey)) { New-Item -Path `$brandingKey -Force | Out-Null } + New-ItemProperty -Path `$brandingKey -Name 'companyName' -PropertyType String -Value '$($CompanyName -replace "'", "''")' -Force | Out-Null + + New-ItemProperty -Path `$brandingKey -Name 'productName' -PropertyType String -Value '$($ProductName -replace "'", "''")' -Force | Out-Null + New-ItemProperty -Path `$brandingKey -Name 'supportEmail' -PropertyType String -Value '$($SupportEmail -replace "'", "''")' -Force | Out-Null + New-ItemProperty -Path `$brandingKey -Name 'supportUrl' -PropertyType String -Value '$($SupportUrl -replace "'", "''")' -Force | Out-Null + New-ItemProperty -Path `$brandingKey -Name 'privacyPolicyUrl' -PropertyType String -Value '$($PrivacyPolicyUrl -replace "'", "''")' -Force | Out-Null + New-ItemProperty -Path `$brandingKey -Name 'aboutUrl' -PropertyType String -Value '$($AboutUrl -replace "'", "''")' -Force | Out-Null + New-ItemProperty -Path `$brandingKey -Name 'primaryColor' -PropertyType String -Value '$PrimaryColor' -Force | Out-Null + New-ItemProperty -Path `$brandingKey -Name 'logoUrl' -PropertyType String -Value '$($LogoUrl -replace "'", "''")' -Force | Out-Null + + # Managed storage - genericWebhook subkey + `$webhookKey = "`$(`$b.ManagedStorageKey)\genericWebhook" + if (!(Test-Path `$webhookKey)) { New-Item -Path `$webhookKey -Force | Out-Null } + New-ItemProperty -Path `$webhookKey -Name 'enabled' -PropertyType DWord -Value $EnableGenericWebhook -Force | Out-Null + New-ItemProperty -Path `$webhookKey -Name 'url' -PropertyType String -Value '$($WebhookUrl -replace "'", "''")' -Force | Out-Null + + # Managed storage - genericWebhook\events subkey + `$webhookEventsKey = "`$(`$b.ManagedStorageKey)\genericWebhook\events" + if (Test-Path `$webhookEventsKey) { Remove-Item -Path `$webhookEventsKey -Recurse -Force | Out-Null } +$(if ($WebhookEvents.Count -gt 0) { + " if (!(Test-Path `$webhookEventsKey)) { New-Item -Path `$webhookEventsKey -Force | Out-Null }`n" + $i = 1 + foreach ($evt in $WebhookEvents) { + " New-ItemProperty -Path `$webhookEventsKey -Name '$i' -PropertyType String -Value '$($evt -replace "'", "''")' -Force | Out-Null`n" + $i++ + } +}) + # Managed storage - urlAllowlist subkey + `$allowlistKey = "`$(`$b.ManagedStorageKey)\urlAllowlist" + if (Test-Path `$allowlistKey) { Remove-Item -Path `$allowlistKey -Recurse -Force | Out-Null } +$(if ($UrlAllowlist.Count -gt 0) { + " if (!(Test-Path `$allowlistKey)) { New-Item -Path `$allowlistKey -Force | Out-Null }`n" + $i = 1 + foreach ($url in $UrlAllowlist) { + " New-ItemProperty -Path `$allowlistKey -Name '$i' -PropertyType String -Value '$($url -replace "'", "''")' -Force | Out-Null`n" + $i++ + } +}) + # Extension settings (installation + toolbar) + if (!(Test-Path `$b.ExtSettingsKey)) { New-Item -Path `$b.ExtSettingsKey -Force | Out-Null } + New-ItemProperty -Path `$b.ExtSettingsKey -Name 'installation_mode' -PropertyType String -Value 'force_installed' -Force | Out-Null + New-ItemProperty -Path `$b.ExtSettingsKey -Name 'update_url' -PropertyType String -Value `$b.UpdateUrl -Force | Out-Null + if ($ForceToolbarPin -eq 1) { + New-ItemProperty -Path `$b.ExtSettingsKey -Name `$b.ToolbarProp -PropertyType String -Value `$b.ToolbarPinned -Force | Out-Null + } else { + New-ItemProperty -Path `$b.ExtSettingsKey -Name `$b.ToolbarProp -PropertyType String -Value `$b.ToolbarUnpinned -Force | Out-Null + } +} + +Write-Output 'Check Chrome Extension registry keys configured successfully.' +"@ + + $UninstallScript = @" +# Check Chrome Extension - Uninstall Script (generated by CIPP) +`$chromeExtensionId = '$ChromeExtensionId' +`$edgeExtensionId = '$EdgeExtensionId' + +`$keysToRemove = @( + "HKLM:\SOFTWARE\Policies\Google\Chrome\3rdparty\extensions\`$chromeExtensionId", + "HKLM:\SOFTWARE\Policies\Google\Chrome\ExtensionSettings\`$chromeExtensionId", + "HKLM:\SOFTWARE\Policies\Microsoft\Edge\3rdparty\extensions\`$edgeExtensionId", + "HKLM:\SOFTWARE\Policies\Microsoft\Edge\ExtensionSettings\`$edgeExtensionId" +) + +foreach (`$key in `$keysToRemove) { + if (Test-Path `$key) { + Remove-Item -Path `$key -Recurse -Force -ErrorAction SilentlyContinue + Write-Output "Removed: `$key" + } +} + +Write-Output 'Check Chrome Extension registry keys removed.' +"@ + + $DetectionScript = @" +# Check Chrome Extension - Detection Script (generated by CIPP) +`$chromeKey = 'HKLM:\SOFTWARE\Policies\Google\Chrome\3rdparty\extensions\$ChromeExtensionId\policy' +`$edgeKey = 'HKLM:\SOFTWARE\Policies\Microsoft\Edge\3rdparty\extensions\$EdgeExtensionId\policy' + +# Verify both managed storage keys exist +if (!(Test-Path `$chromeKey) -or !(Test-Path `$edgeKey)) { exit 1 } + +# Helper to check a registry value matches expected +function Test-RegValue(`$Path, `$Name, `$Expected) { + `$val = (Get-ItemProperty -Path `$Path -Name `$Name -ErrorAction SilentlyContinue).`$Name + return (`$null -ne `$val -and `$val -eq `$Expected) +} + +foreach (`$key in @(`$chromeKey, `$edgeKey)) { + # Core DWORD settings + if (!(Test-RegValue `$key 'showNotifications' $ShowNotifications)) { exit 1 } + if (!(Test-RegValue `$key 'enableValidPageBadge' $EnableValidPageBadge)) { exit 1 } + if (!(Test-RegValue `$key 'enablePageBlocking' $EnablePageBlocking)) { exit 1 } + if (!(Test-RegValue `$key 'enableCippReporting' $EnableCippReporting)) { exit 1 } + if (!(Test-RegValue `$key 'updateInterval' $UpdateInterval)) { exit 1 } + if (!(Test-RegValue `$key 'enableDebugLogging' $EnableDebugLogging)) { exit 1 } + + # Core string settings + if (!(Test-RegValue `$key 'cippServerUrl' '$CippServerUrl')) { exit 1 } + if (!(Test-RegValue `$key 'cippTenantId' '$CippTenantId')) { exit 1 } + if (!(Test-RegValue `$key 'customRulesUrl' '$CustomRulesUrl')) { exit 1 } + + # domainSquatting subkey + `$domainSquattingKey = "`$key\domainSquatting" + if (!(Test-Path `$domainSquattingKey)) { exit 1 } + if (!(Test-RegValue `$domainSquattingKey 'enabled' $DomainSquattingEnabled)) { exit 1 } + + # customBranding subkey + `$brandingKey = "`$key\customBranding" + if (!(Test-Path `$brandingKey)) { exit 1 } + if (!(Test-RegValue `$brandingKey 'companyName' '$($CompanyName -replace "'", "''")')) { exit 1 } + + if (!(Test-RegValue `$brandingKey 'productName' '$($ProductName -replace "'", "''")')) { exit 1 } + if (!(Test-RegValue `$brandingKey 'supportEmail' '$($SupportEmail -replace "'", "''")')) { exit 1 } + if (!(Test-RegValue `$brandingKey 'supportUrl' '$($SupportUrl -replace "'", "''")')) { exit 1 } + if (!(Test-RegValue `$brandingKey 'privacyPolicyUrl' '$($PrivacyPolicyUrl -replace "'", "''")')) { exit 1 } + if (!(Test-RegValue `$brandingKey 'aboutUrl' '$($AboutUrl -replace "'", "''")')) { exit 1 } + if (!(Test-RegValue `$brandingKey 'primaryColor' '$PrimaryColor')) { exit 1 } + if (!(Test-RegValue `$brandingKey 'logoUrl' '$($LogoUrl -replace "'", "''")')) { exit 1 } + + # genericWebhook subkey + `$webhookKey = "`$key\genericWebhook" + if (!(Test-Path `$webhookKey)) { exit 1 } + if (!(Test-RegValue `$webhookKey 'enabled' $EnableGenericWebhook)) { exit 1 } + if (!(Test-RegValue `$webhookKey 'url' '$($WebhookUrl -replace "'", "''")')) { exit 1 } + + # genericWebhook\events subkey - verify exact count and values + `$eventsKey = "`$key\genericWebhook\events" +$(if ($WebhookEvents.Count -gt 0) { + " if (!(Test-Path `$eventsKey)) { exit 1 }`n" + $i = 1 + foreach ($evt in $WebhookEvents) { + " if (!(Test-RegValue `$eventsKey '$i' '$($evt -replace "'", "''")')) { exit 1 }`n" + $i++ + } + " `$eventsCount = (Get-Item `$eventsKey).Property.Count`n" + " if (`$eventsCount -ne $($WebhookEvents.Count)) { exit 1 }`n" +} else { + " if (Test-Path `$eventsKey) {`n" + " `$eventsCount = (Get-Item `$eventsKey).Property.Count`n" + " if (`$eventsCount -gt 0) { exit 1 }`n" + " }`n" +}) + # urlAllowlist subkey - verify exact count and values + `$allowlistKey = "`$key\urlAllowlist" +$(if ($UrlAllowlist.Count -gt 0) { + " if (!(Test-Path `$allowlistKey)) { exit 1 }`n" + $i = 1 + foreach ($url in $UrlAllowlist) { + " if (!(Test-RegValue `$allowlistKey '$i' '$($url -replace "'", "''")')) { exit 1 }`n" + $i++ + } + " `$allowlistCount = (Get-Item `$allowlistKey).Property.Count`n" + " if (`$allowlistCount -ne $($UrlAllowlist.Count)) { exit 1 }`n" +} else { + " if (Test-Path `$allowlistKey) {`n" + " `$allowlistCount = (Get-Item `$allowlistKey).Property.Count`n" + " if (`$allowlistCount -gt 0) { exit 1 }`n" + " }`n" +}) +} + +# Verify extension settings keys exist +`$chromeExtSettings = 'HKLM:\SOFTWARE\Policies\Google\Chrome\ExtensionSettings\$ChromeExtensionId' +`$edgeExtSettings = 'HKLM:\SOFTWARE\Policies\Microsoft\Edge\ExtensionSettings\$EdgeExtensionId' +if (!(Test-Path `$chromeExtSettings) -or !(Test-Path `$edgeExtSettings)) { exit 1 } + +Write-Output 'Check Chrome Extension is correctly configured.' +exit 0 +"@ + + $Sha256 = [System.Security.Cryptography.SHA256]::Create() + $SettingsHash = ([System.BitConverter]::ToString( + $Sha256.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($InstallScript)) + ) -replace '-', '').Substring(0, 16) + $AppDescription = "Deploys and configures the Check by CyberDrain phishing protection extension for Chrome and Edge browsers. Managed by CIPP. [cfg:$SettingsHash]" + + $AssignTo = "$($Remediate.assignTo.value ?? $Remediate.assignTo ?? 'AllDevices')" + if ("$($Remediate.customGroup)") { $AssignTo = "$($Remediate.customGroup)" } + $ExcludeGroup = "$($Remediate.excludeGroup)" + + # Clean up the legacy OMA-URI policies from the old deployment approach. + $LegacyPolicyNames = @('Deploy Check Chrome Extension (Chrome)', 'Deploy Check Chrome Extension (Edge)') + $LegacyPolicies = @(New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/deviceManagement/deviceConfigurations?$select=id,displayName' -tenantid $TenantFilter | Where-Object { $_.displayName -in $LegacyPolicyNames }) + if ($LegacyPolicies.Count -gt 0) { + $DeleteRequests = @($LegacyPolicies | ForEach-Object { + @{ id = "delete-$($_.id)"; method = 'DELETE'; url = "deviceManagement/deviceConfigurations/$($_.id)" } + }) + $BulkResults = New-GraphBulkRequest -tenantid $TenantFilter -Requests $DeleteRequests + foreach ($Policy in $LegacyPolicies) { + $Result = $BulkResults | Where-Object { $_.id -eq "delete-$($Policy.id)" } + if ("$($Result.status)" -match '^2') { + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "Removed the legacy OMA-URI policy '$($Policy.displayName)'." -Sev 'Info' + } else { + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "Failed to remove the legacy OMA-URI policy '$($Policy.displayName)': $($Result.body.error.message)" -Sev 'Warning' + } + } + } + + # Live app read: the fingerprint gate must see the CURRENT description, not a cached one. + $Baseuri = 'https://graph.microsoft.com/beta/deviceAppManagement/mobileApps' + $ExistingApps = @(New-GraphGetRequest -uri "$Baseuri`?`$filter=displayName eq '$AppDisplayName'&`$select=id,displayName,description" -tenantid $TenantFilter | Where-Object { $_.'@odata.type' -eq '#microsoft.graph.win32LobApp' }) + + if ($ExistingApps.Count -gt 0) { + $ExistingHash = $null + if ("$($ExistingApps[0].description)" -match '\[cfg:([0-9A-Fa-f]{16})\]') { $ExistingHash = $Matches[1] } + if ($ExistingHash -eq $SettingsHash) { + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "$AppDisplayName settings unchanged - skipped the redeploy." -Sev 'Info' + return + } + foreach ($App in $ExistingApps) { + $null = New-GraphPostRequest -uri "$Baseuri/$($App.id)" -type DELETE -tenantid $TenantFilter + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "Removed the existing $AppDisplayName app to redeploy with updated settings." -Sev 'Info' + } + Start-Sleep -Seconds 2 + } + + $AppProperties = [PSCustomObject]@{ + displayName = $AppDisplayName + description = $AppDescription + publisher = 'CIPP' + installScript = $InstallScript + uninstallScript = $UninstallScript + detectionScript = $DetectionScript + runAsAccount = 'system' + deviceRestartBehavior = 'suppress' + } + $NewApp = Add-CIPPW32ScriptApplication -TenantFilter $TenantFilter -Properties $AppProperties + if ($NewApp -and $AssignTo -ne 'On') { + Start-Sleep -Milliseconds 500 + Set-CIPPAssignedApplication -ApplicationId $NewApp.Id -TenantFilter $TenantFilter -GroupName $AssignTo -ExcludeGroup $ExcludeGroup -Intent 'Required' -AppType 'Win32Lob' -APIName 'Baselines' + } + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "Successfully deployed $AppDisplayName." -Sev 'Info' +} diff --git a/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineDeployContactTemplates.ps1 b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineDeployContactTemplates.ps1 new file mode 100644 index 0000000000000..63dcc6d9d299c --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineDeployContactTemplates.ps1 @@ -0,0 +1,85 @@ +function Invoke-CIPPBaselineDeployContactTemplates { + <# + .SYNOPSIS + DeployContactTemplates executor: creates the mail contact or brings it in line. + .DESCRIPTION + Create is the classic's two-pass write: New-MailContact carries only the identity + fields (display name, email, first/last name), then Set-Contact and Set-MailContact + apply the extended properties - Exchange rejects them on the New- call. + + Update writes the same property sets against the existing contact. Template fields + with no value are never written, mirroring the compare: an empty template field + expresses no opinion and must not blank operator data. hidefromGAL is boolean and + is always written on update so it can be turned OFF as well as on - the compare + grades it in both directions. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Remediate, + $TenantFilter, + $Current + ) + + $Template = $Current.templateBody + if (-not $Template) { return } + $ContactName = "$($Template.displayName)" + + if ($Current.deployed -eq $true) { + $Identity = "$($Current.existingContact.Identity)" + } else { + $NewParams = @{ + displayName = $ContactName + name = $ContactName + ExternalEmailAddress = "$($Template.email)" + } + if (-not [string]::IsNullOrWhiteSpace("$($Template.firstName)")) { $NewParams.FirstName = "$($Template.firstName)" } + if (-not [string]::IsNullOrWhiteSpace("$($Template.lastName)")) { $NewParams.LastName = "$($Template.lastName)" } + $NewContact = New-ExoRequest -tenantid $TenantFilter -cmdlet 'New-MailContact' -cmdParams $NewParams -UseSystemMailbox $true + $Identity = "$($NewContact.Identity ?? $ContactName)" + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "Created mail contact '$ContactName'." -Sev 'Info' + } + + if ($Current.deployed -eq $true) { + # Email and names live on different cmdlets; write both like the classic did. + $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Set-MailContact' -cmdParams @{ + Identity = $Identity + ExternalEmailAddress = "$($Template.email)" + } -UseSystemMailbox $true + $NameParams = @{ Identity = $Identity } + if (-not [string]::IsNullOrWhiteSpace("$($Template.firstName)")) { $NameParams.FirstName = "$($Template.firstName)" } + if (-not [string]::IsNullOrWhiteSpace("$($Template.lastName)")) { $NameParams.LastName = "$($Template.lastName)" } + if ($NameParams.Count -gt 1) { + $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Set-Contact' -cmdParams $NameParams -UseSystemMailbox $true + } + } + + # Extended properties: only the ones the template specifies. + $SetContactParams = @{ Identity = $Identity } + $PropertyMap = @{ + 'Company' = $Template.companyName + 'StateOrProvince' = $Template.state + 'StreetAddress' = $Template.streetAddress + 'Phone' = $Template.businessPhone + 'WebPage' = $Template.website + 'Title' = $Template.jobTitle + 'City' = $Template.city + 'PostalCode' = $Template.postalCode + 'CountryOrRegion' = $Template.country + 'MobilePhone' = $Template.mobilePhone + } + foreach ($Property in $PropertyMap.GetEnumerator()) { + if (-not [string]::IsNullOrWhiteSpace("$($Property.Value)")) { $SetContactParams[$Property.Key] = "$($Property.Value)" } + } + if ($SetContactParams.Count -gt 1) { + $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Set-Contact' -cmdParams $SetContactParams -UseSystemMailbox $true + } + + $MailContactParams = @{ Identity = $Identity } + $MailContactParams.HiddenFromAddressListsEnabled = [bool]$Template.hidefromGAL + if (-not [string]::IsNullOrWhiteSpace("$($Template.mailTip)")) { $MailContactParams.MailTip = "$($Template.mailTip)" } + $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Set-MailContact' -cmdParams $MailContactParams -UseSystemMailbox $true + + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "Applied contact template to '$ContactName'." -Sev 'Info' +} diff --git a/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineDeviceEnrollmentConfiguration.ps1 b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineDeviceEnrollmentConfiguration.ps1 new file mode 100644 index 0000000000000..6e867a6166270 --- /dev/null +++ b/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)) +} diff --git a/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineDevicePrepProfile.ps1 b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineDevicePrepProfile.ps1 new file mode 100644 index 0000000000000..fef9cdf59181f --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineDevicePrepProfile.ps1 @@ -0,0 +1,167 @@ +function Invoke-CIPPBaselineDevicePrepProfile { + <# + .SYNOPSIS + DevicePrepProfile executor: deploys the named Device Preparation profile. + .DESCRIPTION + The classic's three-way write. Settings correct but assignment wrong: repair the + assignment IN PLACE through /assign - recreating would sever the enrollment-time + device group linkage over a delta the endpoint can fix. Otherwise: delete the drifted + profile and recreate it with the classic's exact settings body, resolving (or, when + CreateNewGroup allows, creating with the Intune Provisioning Client as owner) the + device security group first. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Remediate, + $TenantFilter, + $Current + ) + + $ProfileName = "$($Remediate.profileName.value ?? $Remediate.profileName)" + if ([string]::IsNullOrWhiteSpace($ProfileName)) { return } + + $DeploymentMode = '0' # Device Prep only supports self-deploying mode + $DeploymentType = [string]($Remediate.deploymentType.value ?? $Remediate.deploymentType ?? '0') + $JoinType = [string]($Remediate.joinType.value ?? $Remediate.joinType ?? '0') + $AccountType = [string]($Remediate.accountType.value ?? $Remediate.accountType ?? '0') + $Timeout = if ([string]::IsNullOrWhiteSpace("$($Remediate.timeout)")) { 60 } else { [int]"$($Remediate.timeout)" } + $CustomErrorMessage = if ([string]::IsNullOrWhiteSpace("$($Remediate.customErrorMessage)")) { "Contact your organization$([char]0x2019)s support person for help." } else { "$($Remediate.customErrorMessage)" } + $AllowSkip = $(if ($Remediate.allowSkip -eq $true) { '1' } else { '0' }) + $AllowDiagnostics = $(if ($Remediate.allowDiagnostics -eq $true) { '1' } else { '0' }) + $AssignTo = [string]($Remediate.assignTo.value ?? $Remediate.assignTo ?? 'none') + + $AssignmentTarget = Get-CIPPIntuneAssignmentTarget -AssignTo $AssignTo -PolicyType 'DevicePrepProfile' + $AssignmentBody = if (@($AssignmentTarget.Targets).Count -gt 0) { + @{ assignments = @($AssignmentTarget.Targets | ForEach-Object { @{ target = $_ } }) } | ConvertTo-Json -Compress -Depth 10 + } + if ($AssignmentTarget.Unsupported) { + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "DevicePrepProfile: $($AssignmentTarget.Unsupported)" -Sev 'Warning' + } + + # Settings already correct means only the assignment drifted: repair it in place. + if ($Current.settingsCorrect -eq $true -and -not [string]::IsNullOrWhiteSpace("$($Current.policyId)")) { + if ($AssignmentBody) { + $null = New-GraphPostRequest -uri "https://graph.microsoft.com/beta/deviceManagement/configurationPolicies('$($Current.policyId)')/assign" -tenantid $TenantFilter -body $AssignmentBody -type POST + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "Repaired the assignment for Device Prep profile '$ProfileName'." -Sev 'Info' + } + return + } + + # Resolve or create the device security group, the classic's flow. + $DeviceGroupId = '' + if (-not [string]::IsNullOrWhiteSpace("$($Remediate.deviceGroupName)")) { + $GroupName = "$($Remediate.deviceGroupName)" + $EscapedName = $GroupName -replace "'", "''" + $GroupFilter = [System.Uri]::EscapeDataString("startsWith(displayName,'$EscapedName') and mailEnabled eq false and securityEnabled eq true") + $MatchedGroups = @(New-GraphGetRequest -uri "https://graph.microsoft.com/beta/groups?`$select=id,displayName&`$filter=$GroupFilter" -tenantid $TenantFilter) + if ($MatchedGroups.Count -gt 0) { + $DeviceGroupId = "$($MatchedGroups[0].id)" + } elseif ($Remediate.createNewGroup -eq $true) { + $IntuneProvisioningAppId = 'f1346770-5b25-470b-88bd-d5744ab7952c' + $ServicePrincipal = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/servicePrincipals?`$filter=appId eq '$IntuneProvisioningAppId'&`$select=id" -tenantid $TenantFilter + $SpId = "$($ServicePrincipal.id)" + if ([string]::IsNullOrWhiteSpace($SpId)) { + $SpBody = @{ appId = $IntuneProvisioningAppId } | ConvertTo-Json -Compress + $CreatedSp = New-GraphPostRequest -uri 'https://graph.microsoft.com/beta/servicePrincipals' -tenantid $TenantFilter -body $SpBody -type POST + $SpId = "$($CreatedSp.id)" + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message 'Created the Intune Provisioning Client service principal.' -Sev 'Info' + } + $GroupBody = @{ + displayName = $GroupName + description = 'Device Preparation security group managed by CIPP' + securityEnabled = $true + mailEnabled = $false + mailNickname = ($GroupName -replace '[^a-zA-Z0-9]', '') + (Get-Random -Maximum 9999) + 'owners@odata.bind' = @("https://graph.microsoft.com/v1.0/servicePrincipals/$SpId") + } | ConvertTo-Json -Compress -Depth 10 + $NewGroup = New-GraphPostRequest -uri 'https://graph.microsoft.com/beta/groups' -tenantid $TenantFilter -body $GroupBody -type POST + $DeviceGroupId = "$($NewGroup.id)" + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "Created the security group '$GroupName' with the Intune Provisioning Client as owner." -Sev 'Info' + } else { + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "DevicePrepProfile: no security group found matching '$GroupName'." -Sev 'Warning' + } + } + + if (-not [string]::IsNullOrWhiteSpace("$($Current.policyId)")) { + $null = New-GraphPostRequest -uri "https://graph.microsoft.com/beta/deviceManagement/configurationPolicies('$($Current.policyId)')" -tenantid $TenantFilter -type DELETE + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "Deleted the drifted Device Prep profile '$ProfileName' for recreation." -Sev 'Info' + } + + $Choice = { param($DefId, $InstanceTemplate, $ValueTemplate, $Value) + @{ + '@odata.type' = '#microsoft.graph.deviceManagementConfigurationSetting' + settingInstance = @{ + '@odata.type' = '#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance' + settingDefinitionId = $DefId + settingInstanceTemplateReference = @{ settingInstanceTemplateId = $InstanceTemplate } + choiceSettingValue = @{ + '@odata.type' = '#microsoft.graph.deviceManagementConfigurationChoiceSettingValue' + children = @() + settingValueTemplateReference = @{ settingValueTemplateId = $ValueTemplate } + value = $Value + } + } + } + } + $PolicyBody = @{ + name = $ProfileName + description = "$($Remediate.profileDescription ?? '')" + roleScopeTagIds = @('0') + platforms = 'windows10' + technologies = 'enrollment' + templateReference = @{ templateId = '80d33118-b7b4-40d8-b15f-81be745e053f_1' } + settings = @( + (& $Choice 'enrollment_autopilot_dpp_deploymentmode' '5180aeab-886e-4589-97d4-40855c646315' '5874c2f6-bcf1-463b-a9eb-bee64e2f2d82' "enrollment_autopilot_dpp_deploymentmode_$DeploymentMode") + (& $Choice 'enrollment_autopilot_dpp_deploymenttype' 'f4184296-fa9f-4b67-8b12-1723b3f8456b' 'e0af022f-37f3-4a40-916d-1ab7281c88d9' "enrollment_autopilot_dpp_deploymenttype_$DeploymentType") + (& $Choice 'enrollment_autopilot_dpp_jointype' '6310e95d-6cfa-4d2f-aae0-1e7af12e2182' '1fa84eb3-fcfa-4ed6-9687-0f3d486402c4' "enrollment_autopilot_dpp_jointype_$JoinType") + (& $Choice 'enrollment_autopilot_dpp_accountype' 'd4f2a840-86d5-4162-9a08-fa8cc608b94e' 'bf13bb47-69ef-4e06-97c1-50c2859a49c2' "enrollment_autopilot_dpp_accountype_$AccountType") + @{ + settingInstance = @{ + '@odata.type' = '#microsoft.graph.deviceManagementConfigurationSimpleSettingInstance' + settingDefinitionId = 'enrollment_autopilot_dpp_timeout' + settingInstanceTemplateReference = @{ settingInstanceTemplateId = '6dec0657-dfb8-4906-a7ee-3ac6ee1edecb' } + simpleSettingValue = @{ + '@odata.type' = '#microsoft.graph.deviceManagementConfigurationIntegerSettingValue' + value = $Timeout + settingValueTemplateReference = @{ settingValueTemplateId = '0bbcce5b-a55a-4e05-821a-94bf576d6cc8' } + } + } + } + @{ + settingInstance = @{ + '@odata.type' = '#microsoft.graph.deviceManagementConfigurationSimpleSettingInstance' + settingDefinitionId = 'enrollment_autopilot_dpp_customerrormessage' + settingInstanceTemplateReference = @{ settingInstanceTemplateId = '2ddf0619-2b7a-46de-b29b-c6191e9dda6e' } + simpleSettingValue = @{ + '@odata.type' = '#microsoft.graph.deviceManagementConfigurationStringSettingValue' + value = $CustomErrorMessage + settingValueTemplateReference = @{ settingValueTemplateId = 'fe5002d5-fbe9-4920-9e2d-26bfc4b4cc97' } + } + } + } + (& $Choice 'enrollment_autopilot_dpp_allowskip' '2a71dc89-0f17-4ba9-bb27-af2521d34710' 'a2323e5e-ac56-4517-8847-b0a6fdb467e7' "enrollment_autopilot_dpp_allowskip_$AllowSkip") + (& $Choice 'enrollment_autopilot_dpp_allowdiagnostics' 'e2b7a81b-f243-4abd-bce3-c1856345f405' 'c59d26fd-3460-4b26-b47a-f7e202e7d5a3' "enrollment_autopilot_dpp_allowdiagnostics_$AllowDiagnostics") + @{ + settingInstance = @{ + '@odata.type' = '#microsoft.graph.deviceManagementConfigurationSimpleSettingInstance' + settingDefinitionId = 'enrollment_autopilot_dpp_devicesecuritygroupids' + settingInstanceTemplateReference = @{ settingInstanceTemplateId = 'a46a50ab-3076-4968-9366-75a40dde950e' } + simpleSettingValue = @{ + '@odata.type' = '#microsoft.graph.deviceManagementConfigurationStringSettingValue' + value = $DeviceGroupId + settingValueTemplateReference = @{ settingValueTemplateId = '5f7d09e1-1a90-44ad-9c9f-ad90ba509e60' } + } + } + } + ) + } + + $Body = $PolicyBody | ConvertTo-Json -Compress -Depth 20 + $NewPolicy = New-GraphPostRequest -uri 'https://graph.microsoft.com/beta/deviceManagement/configurationPolicies' -tenantid $TenantFilter -body $Body -type POST + if ("$($NewPolicy.id)" -and $AssignmentBody) { + $null = New-GraphPostRequest -uri "https://graph.microsoft.com/beta/deviceManagement/configurationPolicies('$($NewPolicy.id)')/assign" -tenantid $TenantFilter -body $AssignmentBody -type POST + } + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "Deployed the Device Prep profile '$ProfileName'." -Sev 'Info' +} diff --git a/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineDeviceRegistrationPolicy.ps1 b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineDeviceRegistrationPolicy.ps1 new file mode 100644 index 0000000000000..b53aff955e5ff --- /dev/null +++ b/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/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineDisableBasicAuthSMTP.ps1 b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineDisableBasicAuthSMTP.ps1 new file mode 100644 index 0000000000000..148500774e8d1 --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineDisableBasicAuthSMTP.ps1 @@ -0,0 +1,45 @@ +function Invoke-CIPPBaselineDisableBasicAuthSMTP { + <# + .SYNOPSIS + DisableBasicAuthSMTP executor: sets the tenant transport flag and clears per-user + SMTP AUTH overrides. + .DESCRIPTION + 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( + $Remediate, + $TenantFilter, + $Current + ) + + $Disabled = [bool]($Remediate.disabled -eq $true) + $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Set-TransportConfig' -cmdParams @{ SmtpClientAuthenticationDisabled = $Disabled } + if (-not $Disabled) { return } + + $EnabledUsers = @($Current.UsersWithSmtpAuthEnabled | Where-Object { $_ }) + foreach ($User in $EnabledUsers) { + $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Set-CASMailbox' -cmdParams @{ Identity = $User; SmtpClientAuthenticationDisabled = $null } + } + + 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)" + } + } + } +} diff --git a/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineDisableM365GroupUsers.ps1 b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineDisableM365GroupUsers.ps1 new file mode 100644 index 0000000000000..c291e902e9f33 --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineDisableM365GroupUsers.ps1 @@ -0,0 +1,70 @@ +function Invoke-CIPPBaselineDisableM365GroupUsers { + <# + .SYNOPSIS + DisableM365GroupUsers executor: turns off user-driven M365 group creation, with an + optional exempt group. + .DESCRIPTION + The classic's three-step write: create the exempt group when requested and missing + (through New-CIPPGroup), instantiate the Group.Unified directory setting from its + template when the tenant has none (live template fetch with the classic's offline + fallback), then PATCH EnableGroupCreation false plus - when an exempt group is + configured and resolved - GroupCreationAllowedGroupId. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Remediate, + $TenantFilter, + $Current + ) + + $AllowedGroupName = "$($Remediate.allowedGroupName)" + $GroupId = "$($Current.resolvedGroupId)" + + if (-not [string]::IsNullOrWhiteSpace($AllowedGroupName) -and [string]::IsNullOrWhiteSpace($GroupId) -and ($Remediate.createGroup -eq $true -or "$($Remediate.createGroup)" -eq 'True')) { + $Username = ($AllowedGroupName -replace '[^a-zA-Z0-9]', '') + if ($Username.Length -gt 64) { $Username = $Username.Substring(0, 64) } + $Result = New-CIPPGroup -GroupObject ([PSCustomObject]@{ + groupType = 'generic'; displayName = $AllowedGroupName; username = $Username; securityEnabled = $true + }) -TenantFilter $TenantFilter -APIName 'Baselines' + $GroupId = "$($Result.GroupId)" + } + if (-not [string]::IsNullOrWhiteSpace($AllowedGroupName) -and [string]::IsNullOrWhiteSpace($GroupId)) { + throw "The allowed group '$AllowedGroupName' does not exist and group creation was not enabled - refusing to disable group creation without the exemption." + } + + # Always read the object LIVE: a cached id can be mid-rewrite stale (concurrent + # one-offs share the Settings cache), and the update must resend EVERY template value - + # Graph rejects a partial values array - so the other thirteen Group.Unified values + # come from the live object. + $Live = @(New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/settings' -tenantid $TenantFilter) | Where-Object { "$($_.displayName)" -eq 'Group.Unified' } | Select-Object -First 1 + $SettingId = "$($Live.id)" + if ([string]::IsNullOrWhiteSpace($SettingId)) { + # No Group.Unified setting object yet: instantiate it from the template with defaults. + $Template = try { + (New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/directorySettingTemplates/62375ab9-6b52-47ed-826b-58e47e0e304b' -tenantid $TenantFilter).values + } catch { + '[{"name":"NewUnifiedGroupWritebackDefault","defaultValue":"true"},{"name":"EnableMIPLabels","defaultValue":"false"},{"name":"CustomBlockedWordsList","defaultValue":""},{"name":"EnableMSStandardBlockedWords","defaultValue":"false"},{"name":"ClassificationDescriptions","defaultValue":""},{"name":"DefaultClassification","defaultValue":""},{"name":"PrefixSuffixNamingRequirement","defaultValue":""},{"name":"AllowGuestsToBeGroupOwner","defaultValue":"false"},{"name":"AllowGuestsToAccessGroups","defaultValue":"true"},{"name":"GuestUsageGuidelinesUrl","defaultValue":""},{"name":"GroupCreationAllowedGroupId","defaultValue":""},{"name":"AllowToAddGuests","defaultValue":"true"},{"name":"UsageGuidelinesUrl","defaultValue":""},{"name":"ClassificationList","defaultValue":""},{"name":"EnableGroupCreation","defaultValue":"true"}]' | ConvertFrom-Json + } + $Values = @($Template | ForEach-Object { @{ name = "$($_.name)"; value = "$($_.defaultValue)" } }) + $Body = @{ templateId = '62375ab9-6b52-47ed-826b-58e47e0e304b'; values = $Values } | ConvertTo-Json -Depth 10 -Compress + # App-only, like the classic: the delegated identity's GDAP roles rarely include the + # Groups Administrator right this object demands, and app Directory.ReadWrite.All does. + $Created = New-GraphPostRequest -tenantid $TenantFilter -uri 'https://graph.microsoft.com/beta/settings' -type POST -body $Body -AsApp $true + $SettingId = "$($Created.id)" + $Live = $Created + } + + # Full values array: every live value resent, ours overridden. + $PatchValues = @(@($Live.values) | ForEach-Object { + $Name = "$($_.name)" + $Value = "$($_.value)" + if ($Name -eq 'EnableGroupCreation') { $Value = 'false' } + if ($Name -eq 'GroupCreationAllowedGroupId' -and -not [string]::IsNullOrWhiteSpace($GroupId)) { $Value = $GroupId } + @{ name = $Name; value = $Value } + }) + $Body = @{ values = $PatchValues } | ConvertTo-Json -Depth 10 -Compress + $null = New-GraphPostRequest -tenantid $TenantFilter -uri "https://graph.microsoft.com/beta/settings/$SettingId" -type PATCH -body $Body -AsApp $true + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "Disabled user M365 group creation$(if ($GroupId) { " (exempt group $AllowedGroupName)" })." -Sev 'Info' +} diff --git a/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineDisableOutlookAddins.ps1 b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineDisableOutlookAddins.ps1 new file mode 100644 index 0000000000000..4a0730ec04b8b --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineDisableOutlookAddins.ps1 @@ -0,0 +1,39 @@ +function Invoke-CIPPBaselineDisableOutlookAddins { + <# + .SYNOPSIS + DisableOutlookAddins executor: removes the user add-in install roles. + .DESCRIPTION + The classic's write: for each app-install role still on the default policy, resolve + its management role assignments and remove them by GUID. Per-role failures log and + continue; only every role failing is an error, so partial progress is kept. NOTE + (as in the classic): removal is one-way through CIPP - re-enabling requires + recreating the assignments in Exchange. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Remediate, + $TenantFilter, + $Current + ) + + $RolesToRemove = @($Current.rolesToRemove) + if ($RolesToRemove.Count -eq 0) { return } + $PolicyIdentity = "$($Current.policyIdentity)" + + $Failures = 0 + foreach ($Role in $RolesToRemove) { + try { + $RoleAssignments = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-ManagementRoleAssignment' -cmdParams @{ RoleAssignee = $PolicyIdentity; Role = $Role } + foreach ($Assignment in @($RoleAssignments)) { + New-ExoRequest -tenantid $TenantFilter -cmdlet 'Remove-ManagementRoleAssignment' -cmdParams @{ Identity = "$($Assignment.Guid)"; Confirm = $false } -UseSystemMailbox $true + } + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "Removed the Outlook add-in install role '$Role' from the default policy." -Sev 'Info' + } catch { + $Failures++ + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "Failed to remove the Outlook add-in install role '$Role': $($_.Exception.Message)" -Sev 'Error' + } + } + if ($Failures -ge $RolesToRemove.Count) { throw "Every Outlook add-in role removal failed for $TenantFilter - see the log for the first error." } +} diff --git a/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineDisableSelfServiceLicenses.ps1 b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineDisableSelfServiceLicenses.ps1 new file mode 100644 index 0000000000000..514d3709ba4f0 --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineDisableSelfServiceLicenses.ps1 @@ -0,0 +1,44 @@ +function Invoke-CIPPBaselineDisableSelfServiceLicenses { + <# + .SYNOPSIS + DisableSelfServiceLicenses executor: writes the self-service purchase posture. + .DESCRIPTION + One write per offender the hook found, each on its own endpoint - the classic's + exact routing: trial autoclaim to the admin.microsoft.com licensing API, email + subscriptions as an authorization policy PATCH on Graph, and every product to the + licensing.m365.microsoft.com policy API under its dedicated token scope. Partial + failures log and continue; a round where everything failed throws. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Remediate, + $TenantFilter, + $Current + ) + + $Offenders = @($Current.offenders | Where-Object { $_ }) + if ($Offenders.Count -eq 0) { return } + + $Failures = 0 + foreach ($Item in $Offenders) { + try { + $Id = "$($Item.productId)" + if ($Id -eq 'autoclaim') { + $Body = @{ policyValue = "$($Item.policyValue)" } | ConvertTo-Json -Compress + $null = New-GraphPostRequest -scope 'https://admin.microsoft.com/.default' -tenantid $TenantFilter -uri 'https://admin.microsoft.com/fd/m365licensing/v1/policies/autoclaim' -body $Body + } elseif ($Id -eq 'allowedToSignUpEmailBasedSubscriptions') { + $null = New-GraphPostRequest -tenantid $TenantFilter -uri 'https://graph.microsoft.com/v1.0/policies/authorizationPolicy' -type PATCH -body '{"allowedToSignUpEmailBasedSubscriptions":false}' + } else { + $Body = @{ policyValue = "$($Item.policyValue)" } | ConvertTo-Json -Compress + $null = New-GraphPostRequest -scope 'aeb86249-8ea3-49e2-900b-54cc8e308f85/.default' -tenantid $TenantFilter -uri "https://licensing.m365.microsoft.com/v1.0/policies/AllowSelfServicePurchase/products/$Id" -type PUT -body $Body + } + } catch { + $Failures++ + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "Self-service license write failed for '$($Item.productName)': $($_.Exception.Message)" -Sev 'Error' + } + } + if ($Failures -ge $Offenders.Count) { throw "Every self-service license write failed for $TenantFilter - see the log for the first error." } + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "Self-service licenses: aligned $($Offenders.Count - $Failures) of $($Offenders.Count) product(s)." -Sev 'Info' +} diff --git a/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineDlpCompliancePolicyTemplate.ps1 b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineDlpCompliancePolicyTemplate.ps1 new file mode 100644 index 0000000000000..962775fcb7931 --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineDlpCompliancePolicyTemplate.ps1 @@ -0,0 +1,33 @@ +function Invoke-CIPPBaselineDlpCompliancePolicyTemplate { + <# + .SYNOPSIS + DlpCompliancePolicyTemplate executor: deploys the instance's DLP policy template. + .DESCRIPTION + The classic's write: Set-CIPPDlpCompliancePolicy creates the policy and its rules, + or updates them in place by name. Only remediable states reach here - the hook + withholds a PendingDeletion policy's template because the deploy would just fail. + The helper reports failures as strings ('Could not deploy...'/'Failed...'), which + become an honest thrown error. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Remediate, + $TenantFilter, + $Current + ) + + $Templates = @($Current.remediableTemplates) + if ($Templates.Count -eq 0) { + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message 'No remediable DLP policy template on this instance (a PendingDeletion policy cannot be modified) - nothing written.' -Sev 'Info' + return + } + foreach ($Template in $Templates) { + $DeployResult = Set-CIPPDlpCompliancePolicy -TenantFilter $TenantFilter -Template $Template -APIName 'Baselines' + if ("$DeployResult" -match '^(Could not deploy|Failed)') { + throw "$DeployResult" + } + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "Deployed the DLP policy '$($Template.Name ?? $Template.name)': $DeployResult" -Sev 'Info' + } +} diff --git a/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineEmailAsAlternateLoginId.ps1 b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineEmailAsAlternateLoginId.ps1 new file mode 100644 index 0000000000000..b8771dfa310f1 --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineEmailAsAlternateLoginId.ps1 @@ -0,0 +1,30 @@ +function Invoke-CIPPBaselineEmailAsAlternateLoginId { + <# + .SYNOPSIS + EmailAsAlternateLoginId executor: writes the org-default HRD policy's + AlternateIdLogin flag. + .DESCRIPTION + PATCHes the existing org-default policy or POSTs a new one - the classic's write, + with the definition rebuilt whole because HRD definitions are JSON strings that only + round-trip as a unit. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Remediate, + $TenantFilter, + $Current + ) + + $Enabled = [bool]($Remediate.enabled -eq $true -or "$($Remediate.enabled)" -eq 'True') + $Definition = @{ HomeRealmDiscoveryPolicy = @{ AlternateIdLogin = @{ Enabled = $Enabled } } } | ConvertTo-Json -Depth 10 -Compress + $Body = @{ definition = @($Definition); isOrganizationDefault = $true; displayName = 'HomeRealmDiscoveryPolicy' } | ConvertTo-Json -Depth 10 -Compress + + if (-not [string]::IsNullOrWhiteSpace("$($Current.policyId)")) { + $null = New-GraphPostRequest -tenantid $TenantFilter -uri "https://graph.microsoft.com/v1.0/policies/homeRealmDiscoveryPolicies/$($Current.policyId)" -type PATCH -body $Body + } else { + $null = New-GraphPostRequest -tenantid $TenantFilter -uri 'https://graph.microsoft.com/v1.0/policies/homeRealmDiscoveryPolicies' -type POST -body $Body + } + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "Set email as alternate login ID to $Enabled." -Sev 'Info' +} diff --git a/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineEnableAppConsentRequests.ps1 b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineEnableAppConsentRequests.ps1 new file mode 100644 index 0000000000000..df3dc880fb8e3 --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineEnableAppConsentRequests.ps1 @@ -0,0 +1,80 @@ +function Invoke-CIPPBaselineEnableAppConsentRequests { + <# + .SYNOPSIS + EnableAppConsentRequests executor: enables the admin consent workflow with the + 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 + role-assignment reviewer queries MERGED into the existing reviewer list - reviewers + 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 + #> + [CmdletBinding()] + param( + $Remediate, + $TenantFilter, + $Current + ) + + $Policy = New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/policies/adminConsentRequestPolicy' -tenantid $TenantFilter + if (-not $Policy) { throw 'Could not read the admin consent request policy - refusing a blind write.' } + + $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)) { + $Found = $false + foreach ($Id in $ManagedIds) { + if ("$($Reviewer.query)" -match $Id) { $Found = $true } + } + if (-not $Found) { $Reviewers.Add($Reviewer) } + } + foreach ($Role in $Roles) { + $Reviewers.Add(@{ + query = "/beta/roleManagement/directory/roleAssignments?`$filter=roleDefinitionId eq '$Role'" + queryType = 'MicrosoftGraph' + 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) and $($Users.Count) reviewer user(s)." -Sev 'Info' +} diff --git a/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineEnableFIDO2.ps1 b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineEnableFIDO2.ps1 new file mode 100644 index 0000000000000..8200db458f5dd --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineEnableFIDO2.ps1 @@ -0,0 +1,53 @@ +function Invoke-CIPPBaselineEnableFIDO2 { + <# + .SYNOPSIS + EnableFIDO2 executor: enables the FIDO2/passkey authentication method. + .DESCRIPTION + 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 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 + #> + [CmdletBinding()] + param( + $Remediate, + $TenantFilter, + $Current + ) + + $Uri = 'https://graph.microsoft.com/beta/policies/authenticationMethodsPolicy/authenticationMethodConfigurations/Fido2' + $Config = New-GraphGetRequest -uri $Uri -tenantid $TenantFilter -AsApp $true + $Config.state = 'enabled' + $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) { + # Neutral: restrictions not enforced, nothing blocked. + $PasskeyProfile | Add-Member -NotePropertyName 'keyRestrictions' -NotePropertyValue ([PSCustomObject]@{ + isEnforced = $false; enforcementType = 'block'; aaGuids = @() + }) -Force + } + } + $Body = ConvertTo-Json -Depth 20 -Compress -InputObject ($Config | Select-Object -Property * -ExcludeProperty '@odata.context') + $null = New-GraphPostRequest -tenantid $TenantFilter -uri $Uri -type PATCH -body $Body -AsApp $true + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message 'Enabled the FIDO2 authentication method (passkey profiles normalized).' -Sev 'Info' +} diff --git a/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineExchangeConnectorTemplate.ps1 b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineExchangeConnectorTemplate.ps1 new file mode 100644 index 0000000000000..0f612e5c0e710 --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineExchangeConnectorTemplate.ps1 @@ -0,0 +1,43 @@ +function Invoke-CIPPBaselineExchangeConnectorTemplate { + <# + .SYNOPSIS + ExchangeConnectorTemplate executor: creates or rewrites the connector. + .DESCRIPTION + New- when absent, Set- when present, with the cmdlet noun picked by the template's + direction (InboundConnector/OutboundConnector) - the classic's exact write. The full + template body is applied on every remediation run, which pairs with + checkBeforeRun:false: the compare only grades presence, and the rewrite is what + repairs setting drift it cannot see. + + The comment field runs through Get-CIPPTextReplacement first (tenant tokens like + %tenantname%), and defaults to 'no comment' when the template has none - both + carried from the classic. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Remediate, + $TenantFilter, + $Current + ) + + $Body = $Current.connectorBody + $Direction = "$($Current.direction)" + if (-not $Body -or $Direction -notin @('inbound', 'outbound')) { return } + + if ($Body.comment) { + $Body.comment = Get-CIPPTextReplacement -Text $Body.comment -TenantFilter $TenantFilter + } else { + $Body | Add-Member -NotePropertyName 'comment' -NotePropertyValue 'no comment' -Force + } + + if ($Current.deployed -eq $true) { + $Body | Add-Member -NotePropertyName 'Identity' -NotePropertyValue "$($Current.existingIdentity)" -Force + $null = New-ExoRequest -tenantid $TenantFilter -cmdlet "Set-$($Direction)connector" -cmdParams $Body -useSystemMailbox $true + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "Updated $Direction connector '$($Body.name)'." -Sev 'Info' + } else { + $null = New-ExoRequest -tenantid $TenantFilter -cmdlet "New-$($Direction)connector" -cmdParams $Body -useSystemMailbox $true + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "Created $Direction connector '$($Body.name)'." -Sev 'Info' + } +} diff --git a/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineExcludedfileExt.ps1 b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineExcludedfileExt.ps1 new file mode 100644 index 0000000000000..d9876a19c6138 --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineExcludedfileExt.ps1 @@ -0,0 +1,26 @@ +function Invoke-CIPPBaselineExcludedfileExt { + <# + .SYNOPSIS + ExcludedfileExt executor: writes the OneDrive sync exclusion list. + .DESCRIPTION + One PATCH with the full normalized list, app-only, replacing the tenant list - the + admin center's own semantics and the classic's write. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Remediate, + $TenantFilter, + $Current + ) + + $Exts = @(("$($Remediate.ext)" -replace ' ', '') -split ',' | Where-Object { $_ } | ForEach-Object { + if ($_ -notlike '*.*') { "*.$_" } else { $_ } + } | Sort-Object -Unique) + if ($Exts.Count -eq 0) { return } + + $Body = ConvertTo-Json -InputObject @{ excludedFileExtensionsForSyncApp = @($Exts) } + $null = New-GraphPostRequest -tenantid $TenantFilter -uri 'https://graph.microsoft.com/beta/admin/sharepoint/settings' -type PATCH -body $Body -AsApp $true -ContentType 'application/json' + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "Set $($Exts.Count) excluded sync extensions." -Sev 'Info' +} diff --git a/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineExoBulkSweep.ps1 b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineExoBulkSweep.ps1 new file mode 100644 index 0000000000000..7d2e2a086cd20 --- /dev/null +++ b/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/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineExoPolicyRule.ps1 b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineExoPolicyRule.ps1 new file mode 100644 index 0000000000000..98dcee27d4294 --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineExoPolicyRule.ps1 @@ -0,0 +1,83 @@ +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 + # The prepare hook may carry DERIVED policy parameters a static spec cannot express - + # On/Off strings computed from switches, defaults for unset variables. Hook values are + # authoritative (they overwrite same-named spec params): grade and write then share + # one derivation and can never disagree, which is what left graded-but-never-written + # properties drifting forever. + foreach ($Extra in ($Current.extraPolicyParams ?? [PSCustomObject]@{}).PSObject.Properties) { + $PolicyParams[$Extra.Name] = $Extra.Value + } + 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/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineExoRequest.ps1 b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineExoRequest.ps1 index fc8fabe453dce..e0440757d662d 100644 --- a/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineExoRequest.ps1 +++ b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineExoRequest.ps1 @@ -5,16 +5,21 @@ 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 #> [CmdletBinding()] param( $Remediate, - $TenantFilter + $TenantFilter, + # The read result. Unused here; every executor takes the same arguments. + $Current ) foreach ($Step in @($Remediate.cmdlets)) { @@ -24,7 +29,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/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineExternalMFATrusted.ps1 b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineExternalMFATrusted.ps1 new file mode 100644 index 0000000000000..3d6884265a8c7 --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineExternalMFATrusted.ps1 @@ -0,0 +1,28 @@ +function Invoke-CIPPBaselineExternalMFATrusted { + <# + .SYNOPSIS + ExternalMFATrusted executor: sets inbound MFA trust on the default cross-tenant + access policy. + .DESCRIPTION + Reads the policy LIVE and patches the merged inboundTrust object, never the single + flag: Graph replaces the whole complex value on PATCH, so a bare isMfaAccepted body + would silently reset the compliant-device and hybrid-join trust flags alongside it. + The classic did the same read-merge-write for the same reason. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Remediate, + $TenantFilter, + $Current + ) + + $Policy = New-GraphGetRequest -uri 'https://graph.microsoft.com/v1.0/policies/crossTenantAccessPolicy/default?$select=inboundTrust' -tenantid $TenantFilter + if (-not $Policy.inboundTrust) { throw 'Could not read the default cross-tenant access policy - refusing a blind write.' } + + $Policy.inboundTrust.isMfaAccepted = [bool]($Remediate.trusted -eq $true -or "$($Remediate.trusted)" -eq 'True') + $Body = ConvertTo-Json -Compress -Depth 10 -InputObject ([PSCustomObject]@{ inboundTrust = $Policy.inboundTrust }) + $null = New-GraphPostRequest -tenantid $TenantFilter -uri 'https://graph.microsoft.com/v1.0/policies/crossTenantAccessPolicy/default' -type PATCH -body $Body + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "Set external MFA trust to $($Policy.inboundTrust.isMfaAccepted)." -Sev 'Info' +} diff --git a/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineFIDO2PasskeyProfiles.ps1 b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineFIDO2PasskeyProfiles.ps1 new file mode 100644 index 0000000000000..5cc412d74194c --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineFIDO2PasskeyProfiles.ps1 @@ -0,0 +1,52 @@ +function Invoke-CIPPBaselineFIDO2PasskeyProfiles { + <# + .SYNOPSIS + FIDO2PasskeyProfiles executor: rewrites the default passkey profile. + .DESCRIPTION + Rebuilds the profile ARRAY with only the default profile changed - every other + profile resends untouched, because the PATCH replaces the whole collection and + dropping one would delete it. The classic's exact write, app-only as the classic + wrote it. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Remediate, + $TenantFilter, + $Current + ) + + $DefaultId = "$($Current.defaultProfileId)" + if ([string]::IsNullOrWhiteSpace($DefaultId)) { throw 'The tenant reports no default passkey profile - refusing a blind profile rewrite.' } + + $PasskeyTypes = "$($Remediate.passkeyTypes)" + $Attestation = "$($Remediate.attestationEnforcement)" + $Enforce = [bool]($Remediate.enforceKeyRestrictions -eq $true -or "$($Remediate.enforceKeyRestrictions)" -eq 'True') + $EnforcementType = "$($Remediate.enforcementType)" + if ([string]::IsNullOrWhiteSpace($EnforcementType)) { $EnforcementType = 'allow' } + $AAGUIDs = @("$($Remediate.aaGuids)" -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ }) + + $UpdatedProfiles = @(@($Current.allProfiles) | ForEach-Object { + if ("$($_.id)" -eq $DefaultId) { + @{ + id = "$($_.id)" + name = "$($_.name)" + passkeyTypes = $PasskeyTypes + attestationEnforcement = $Attestation + keyRestrictions = @{ + isEnforced = $Enforce + enforcementType = $EnforcementType + aaGuids = @($AAGUIDs) + } + } + } else { $_ } + }) + + $Body = @{ + '@odata.type' = '#microsoft.graph.fido2AuthenticationMethodConfiguration' + passkeyProfiles = @($UpdatedProfiles) + } | ConvertTo-Json -Compress -Depth 10 + $null = New-GraphPostRequest -tenantid $TenantFilter -uri 'https://graph.microsoft.com/beta/policies/authenticationmethodspolicy/authenticationMethodConfigurations/Fido2' -type PATCH -body $Body -AsApp $true + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "Updated the default passkey profile ($PasskeyTypes, attestation $Attestation, restrictions $Enforce)." -Sev 'Info' +} diff --git a/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineGlobalQuarantineNotifications.ps1 b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineGlobalQuarantineNotifications.ps1 new file mode 100644 index 0000000000000..5e3440b21efdd --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineGlobalQuarantineNotifications.ps1 @@ -0,0 +1,33 @@ +function Invoke-CIPPBaselineGlobalQuarantineNotifications { + <# + .SYNOPSIS + GlobalQuarantineNotifications executor: sets the end-user notification interval. + .DESCRIPTION + The Microsoft default policy (DefaultGlobalPolicy) cannot be modified - meeting it + means CREATING the custom DefaultGlobalTag with the interval; anything else is Set- + in place. The classic's exact branch. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Remediate, + $TenantFilter, + $Current + ) + + $Interval = "$($Remediate.notificationInterval)" + if ([string]::IsNullOrWhiteSpace($Interval)) { return } + + if ("$($Current.policyName)" -eq 'DefaultGlobalPolicy') { + $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'New-QuarantinePolicy' -cmdParams @{ + Name = 'DefaultGlobalTag'; QuarantinePolicyType = 'GlobalQuarantinePolicy'; EndUserSpamNotificationFrequency = $Interval + } + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "Created the custom global quarantine policy with a $Interval notification interval." -Sev 'Info' + } else { + $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Set-QuarantinePolicy' -cmdParams @{ + Identity = "$($Current.policyIdentity)"; EndUserSpamNotificationFrequency = $Interval + } + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "Set the global quarantine notification interval to $Interval." -Sev 'Info' + } +} diff --git a/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineGlobalQuarantineSettings.ps1 b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineGlobalQuarantineSettings.ps1 new file mode 100644 index 0000000000000..e8027d6e1e577 --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineGlobalQuarantineSettings.ps1 @@ -0,0 +1,58 @@ +function Invoke-CIPPBaselineGlobalQuarantineSettings { + <# + .SYNOPSIS + GlobalQuarantineSettings executor: writes the global quarantine notification + branding. + .DESCRIPTION + Exchange requires ALL THREE per-language arrays on every write, with counts equal + to the language count - so a configured text fans across every tenant language, and + an UNCONFIGURED field resends the tenant's current values (padded with the first + value when the language list grew). Omitting an array fails the whole write with + 'counts must be equal'; nulling it - the classic's behaviour for unset fields - + would clear real branding. + + The Microsoft default policy (DefaultGlobalPolicy) cannot be modified, so meeting + it means CREATING the custom DefaultGlobalTag; anything else is Set- in place. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Remediate, + $TenantFilter, + $Current + ) + + $Languages = @($Current.languages) + if ($Languages.Count -eq 0) { $Languages = @('Default') } + + $BuildArray = { + param($Configured, $Existing) + if (-not [string]::IsNullOrWhiteSpace("$Configured")) { return @($Languages | ForEach-Object { "$Configured" }) } + $Values = @($Existing) + if ($Values.Count -eq $Languages.Count) { return $Values } + # Language list changed since the values were written - pad with the first value + # (or blanks) so the counts Exchange insists on line up. + $Fill = if ($Values.Count -gt 0) { "$($Values[0])" } else { '' } + return @(1..$Languages.Count | ForEach-Object { $Fill }) + } + $Params = @{ + MultiLanguageSetting = $Languages + MultiLanguageSenderName = & $BuildArray $Remediate.senderName $Current.currentSenderNames + ESNCustomSubject = & $BuildArray $Remediate.customSubject $Current.currentSubjects + MultiLanguageCustomDisclaimer = & $BuildArray $Remediate.customDisclaimer $Current.currentDisclaimers + } + if (-not [string]::IsNullOrWhiteSpace("$($Remediate.fromAddress)")) { $Params['EndUserSpamNotificationCustomFromAddress'] = "$($Remediate.fromAddress)" } + $Params['OrganizationBrandingEnabled'] = [bool]($Remediate.organizationBrandingEnabled -eq $true) + + if ("$($Current.policyName)" -eq 'DefaultGlobalPolicy') { + $Params['Name'] = 'DefaultGlobalTag' + $Params['QuarantinePolicyType'] = 'GlobalQuarantinePolicy' + $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'New-QuarantinePolicy' -cmdParams $Params + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message 'Created the custom global quarantine policy with the configured branding.' -Sev 'Info' + } else { + $Params['Identity'] = "$($Current.policyIdentity)" + $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Set-QuarantinePolicy' -cmdParams $Params + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message 'Updated the global quarantine policy branding.' -Sev 'Info' + } +} diff --git a/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineGraphBulkSweep.ps1 b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineGraphBulkSweep.ps1 new file mode 100644 index 0000000000000..6924b5a9cf152 --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineGraphBulkSweep.ps1 @@ -0,0 +1,122 @@ +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 = @{ + # NOT "$($Index++)": an increment inside a subexpression emits NOTHING in + # PowerShell, which shipped every batch request with an empty id - Graph + # rejects the whole batch with 'Id property cannot be empty'. + id = "$Index" + method = ($Write.method ?? 'PATCH') + url = "/$((& $Expand $Write.uri $Object) -replace '^/')" + } + $Index++ + 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/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineGraphRequest.ps1 b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineGraphRequest.ps1 index 2142740546fae..f96bf8a5150f3 100644 --- a/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineGraphRequest.ps1 +++ b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineGraphRequest.ps1 @@ -5,24 +5,34 @@ 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 #> [CmdletBinding()] param( $Remediate, - $TenantFilter + $TenantFilter, + # The read result. Unused here; every executor takes the same arguments. + $Current ) 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 $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/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineGroupTemplate.ps1 b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineGroupTemplate.ps1 new file mode 100644 index 0000000000000..f415b4d5307e1 --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineGroupTemplate.ps1 @@ -0,0 +1,111 @@ +function Invoke-CIPPBaselineGroupTemplate { + <# + .SYNOPSIS + GroupTemplate executor: creates the group or updates the existing one in place. + .DESCRIPTION + Create goes through New-CIPPGroup, the shared path that knows every group type. The + update branches are the classic's, ported per type: + + Graph groups (Generic/Security/AzureRole/Dynamic/M365) PATCH description, and for + Dynamic groups the membership rule (turning rule processing on with it). Exchange + groups go through Set-DistributionGroup / Set-DynamicDistributionGroup for name, + description, recipient filter and the RequireSenderAuthenticationEnabled flag, which + is the INVERSE of the template's allowExternal and only enforced when the template + expresses an opinion. + + Distribution types need an Exchange license; the check runs here exactly as the + classic ran it, because the standard itself must stay available to tenants without + Exchange deploying pure Graph groups. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Remediate, + $TenantFilter, + $Current + ) + + $Template = $Current.templateBody + if (-not $Template) { return } + $Existing = $Current.existingGroup + + $NormalizedGroupType = switch -Wildcard ("$($Template.groupType)".ToLower()) { + '*dynamicdistribution*' { 'DynamicDistribution'; break } + '*dynamic*' { 'Dynamic'; break } + '*generic*' { 'Generic'; break } + '*security*' { 'Security'; break } + '*azurerole*' { 'AzureRole'; break } + '*m365*' { 'M365'; break } + '*unified*' { 'M365'; break } + '*microsoft*' { 'M365'; break } + '*distribution*' { 'Distribution'; break } + '*mail*' { 'Distribution'; break } + default { "$($Template.groupType)" } + } + + if (-not $Existing) { + if ($NormalizedGroupType -in @('Distribution', 'DynamicDistribution')) { + $LicenseCheck = Test-CIPPStandardLicense -StandardName 'GroupTemplate' -TenantFilter $TenantFilter -Preset Exchange -SkipLog + if ($LicenseCheck -eq $false) { + throw "Cannot create group '$($Template.displayName)': the tenant is not licensed for Exchange." + } + } + $Result = New-CIPPGroup -GroupObject $Template -TenantFilter $TenantFilter -APIName 'Baselines' + if ($Result.Success -ne $true) { + throw "Failed to create group '$($Template.displayName)': $($Result.Message)" + } + return + } + + if ($NormalizedGroupType -in @('Generic', 'Security', 'AzureRole', 'Dynamic', 'M365')) { + $PatchBody = [PSCustomObject]@{} + if ("$($Existing.description)" -ne "$($Template.description)") { + $PatchBody | Add-Member -NotePropertyName 'description' -NotePropertyValue $Template.description + } + if ($NormalizedGroupType -eq 'Dynamic' -and $Template.membershipRules -and "$($Existing.membershipRule)" -ne "$($Template.membershipRules)") { + $PatchBody | Add-Member -NotePropertyName 'membershipRule' -NotePropertyValue $Template.membershipRules + $PatchBody | Add-Member -NotePropertyName 'membershipRuleProcessingState' -NotePropertyValue 'On' + } + if (@($PatchBody.PSObject.Properties).Count -gt 0) { + $null = New-GraphPostRequest -uri "https://graph.microsoft.com/beta/groups/$($Existing.id)" -tenantid $TenantFilter -type PATCH -body (ConvertTo-Json -InputObject $PatchBody -Depth 10) + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "Updated group '$($Template.displayName)'." -Sev 'Info' + } + return + } + + $LicenseCheck = Test-CIPPStandardLicense -StandardName 'GroupTemplate' -TenantFilter $TenantFilter -Preset Exchange -SkipLog + if ($LicenseCheck -eq $false) { + throw "Cannot update group '$($Template.displayName)': the tenant is not licensed for Exchange." + } + + if ($NormalizedGroupType -eq 'DynamicDistribution') { + $SetParams = @{ Identity = $Existing.Identity } + if ("$($Existing.RecipientFilter)" -notmatch [regex]::Escape("$($Template.membershipRules)")) { + $SetParams.RecipientFilter = $Template.membershipRules + } + if ($SetParams.Count -gt 1) { + $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Set-DynamicDistributionGroup' -cmdParams $SetParams + } + if ($null -ne $Template.allowExternal) { + $RequireAuth = [bool](-not $Template.allowExternal) + if ([bool]$Existing.RequireSenderAuthenticationEnabled -ne $RequireAuth) { + $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Set-DynamicDistributionGroup' -cmdParams @{ + Identity = "$($Existing.Name)" + RequireSenderAuthenticationEnabled = $RequireAuth + } + } + } + } else { + $SetParams = @{ Identity = "$($Template.displayName)" } + if ("$($Existing.description)" -ne "$($Template.description)") { $SetParams.Description = $Template.description } + if ($null -ne $Template.allowExternal) { + $RequireAuth = [bool](-not $Template.allowExternal) + if ([bool]$Existing.RequireSenderAuthenticationEnabled -ne $RequireAuth) { $SetParams.RequireSenderAuthenticationEnabled = $RequireAuth } + } + if ($SetParams.Count -gt 1) { + $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Set-DistributionGroup' -cmdParams $SetParams + } + } + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "Updated Exchange group '$($Template.displayName)'." -Sev 'Info' +} diff --git a/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineIntuneAppTemplateDeploy.ps1 b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineIntuneAppTemplateDeploy.ps1 new file mode 100644 index 0000000000000..d9010d1dfc136 --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineIntuneAppTemplateDeploy.ps1 @@ -0,0 +1,49 @@ +function Invoke-CIPPBaselineIntuneAppTemplateDeploy { + <# + .SYNOPSIS + IntuneAppTemplateDeploy executor: queues deployment of the missing template apps. + .DESCRIPTION + The classic's write: each missing app maps its template type to the deployment + queue's type and hands off to New-CIPPIntuneAppDeployment. Deployment is ASYNC - + the queue uploads apps after this run returns, so the first compare afterwards may + briefly still report the apps missing until the uploads land. Per-app failures log + and continue; only every app failing is an error. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Remediate, + $TenantFilter, + $Current + ) + + $MissingApps = @($Current.missingAppObjects) + if ($MissingApps.Count -eq 0) { return } + + $Failures = 0 + foreach ($App in $MissingApps) { + try { + $QueueType = switch ("$($App.AppType)") { + 'StoreApp' { 'WinGet' } + 'chocolateyApp' { 'Choco' } + 'win32ScriptApp' { 'Win32ScriptApp' } + 'mspApp' { 'MSPApp' } + 'officeApp' { 'OfficeApp' } + default { "$($App.AppType)" } + } + $DeployConfig = $App.Config | ConvertTo-Json -Depth 100 | ConvertFrom-Json -Depth 100 + $DeployConfig | Add-Member -NotePropertyName 'type' -NotePropertyValue $QueueType -Force + $DeployConfig | Add-Member -NotePropertyName 'Applicationname' -NotePropertyValue "$($App.AppName)" -Force + $AppAssignTo = if ("$($DeployConfig.AssignTo)" -eq 'customGroup') { $DeployConfig.CustomGroup } else { $DeployConfig.AssignTo } + $DeployConfig | Add-Member -NotePropertyName 'assignTo' -NotePropertyValue $AppAssignTo -Force + + $null = New-CIPPIntuneAppDeployment -AppConfig $DeployConfig -TenantFilter $TenantFilter -APIName 'Baselines' + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "Queued the Intune app '$($App.AppName)' ($($App.AppType)) from template '$($App.TemplateName)'." -Sev 'Info' + } catch { + $Failures++ + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "Failed to queue the Intune app '$($App.AppName)' from template '$($App.TemplateName)': $($_.Exception.Message)" -Sev 'Error' + } + } + if ($Failures -ge $MissingApps.Count) { throw "Every Intune app deployment failed to queue for $TenantFilter - see the log for the first error." } +} diff --git a/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineIntuneDeviceRetirementDays.ps1 b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineIntuneDeviceRetirementDays.ps1 new file mode 100644 index 0000000000000..56b2b4abc0a2d --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineIntuneDeviceRetirementDays.ps1 @@ -0,0 +1,36 @@ +function Invoke-CIPPBaselineIntuneDeviceRetirementDays { + <# + .SYNOPSIS + intuneDeviceRetirementDays executor: creates or patches the default device cleanup + rule. + .DESCRIPTION + PATCH when the hook found a rule, POST when the tenant has none - the classic's + exact branch, including its fixed 'Default Policy' rule body. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Remediate, + $TenantFilter, + $Current + ) + + $Body = ConvertTo-Json -Compress -InputObject ([PSCustomObject]@{ + displayName = 'Default Policy' + description = 'Default Policy' + deviceCleanupRulePlatformType = 'all' + deviceInactivityBeforeRetirementInDays = [int]"$($Remediate.days)" + }) + # Tenants with Intune multi-admin approval on device clean-up rules reject the write + # outright unless a justification header rides along; with it, the write either applies + # or lands as an approval request for another admin to action. + $Headers = @{ 'x-msft-approval-justification' = 'CIPP baseline remediation: enforce the configured device retirement window.' } + if ($Current.ruleId) { + $null = New-GraphPostRequest -tenantid $TenantFilter -uri "https://graph.microsoft.com/beta/deviceManagement/managedDeviceCleanupRules('$($Current.ruleId)')" -type PATCH -body $Body -AddedHeaders $Headers + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "Set device retirement to $($Remediate.days) days on the existing cleanup rule." -Sev 'Info' + } else { + $null = New-GraphPostRequest -tenantid $TenantFilter -uri 'https://graph.microsoft.com/beta/deviceManagement/managedDeviceCleanupRules' -type POST -body $Body -AddedHeaders $Headers + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "Created the default device cleanup rule at $($Remediate.days) days." -Sev 'Info' + } +} diff --git a/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineIntuneTemplate.ps1 b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineIntuneTemplate.ps1 index 55778be3ad722..dda0b8a06e45c 100644 --- a/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineIntuneTemplate.ps1 +++ b/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/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineMDMScope.ps1 b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineMDMScope.ps1 new file mode 100644 index 0000000000000..e9e147fe374f2 --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineMDMScope.ps1 @@ -0,0 +1,44 @@ +function Invoke-CIPPBaselineMDMScope { + <# + .SYNOPSIS + MDMScope executor: writes the Intune MDM enrollment URLs and user scope. + .DESCRIPTION + The classic's write, quirks included: everything goes DELEGATED with the + Accept-Language 0 header this endpoint demands; the URLs and appliesTo must PATCH + in SEPARATE requests (Graph rejects simultaneous patches of both); and 'selected' + scope assigns the custom group via an includedGroups $ref rather than an appliesTo + write. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Remediate, + $TenantFilter, + $Current + ) + + $AppliesTo = "$($Remediate.appliesTo)" + if ($AppliesTo -notin @('all', 'none', 'selected')) { return } + $Uri = 'https://graph.microsoft.com/beta/policies/mobileDeviceManagementPolicies/0000000a-0000-0000-c000-000000000000' + $Headers = @{ 'Accept-Language' = 0 } + + $UrlBody = @{ + termsOfUseUrl = 'https://portal.manage.microsoft.com/TermsofUse.aspx' + discoveryUrl = 'https://enrollment.manage.microsoft.com/enrollmentserver/discovery.svc' + complianceUrl = 'https://portal.manage.microsoft.com/?portalAction=Compliance' + } | ConvertTo-Json + $null = New-GraphPostRequest -tenantid $TenantFilter -uri $Uri -type PATCH -body $UrlBody -asApp $false -AddedHeaders $Headers -ContentType 'application/json; charset=utf-8' + + if ($AppliesTo -ne 'selected') { + $null = New-GraphPostRequest -tenantid $TenantFilter -uri $Uri -type PATCH -body (@{ appliesTo = $AppliesTo } | ConvertTo-Json) -asApp $false -AddedHeaders $Headers -ContentType 'application/json; charset=utf-8' + } else { + $CustomGroup = "$($Remediate.customGroup)" + $EscapedGroup = $CustomGroup -replace "'", "''" + $GroupId = "$((New-GraphGetRequest -uri "https://graph.microsoft.com/beta/groups?`$top=999&`$select=id,displayName&`$filter=displayName eq '$EscapedGroup'" -tenantid $TenantFilter -asApp $true).id | Select-Object -First 1)" + if ([string]::IsNullOrWhiteSpace($GroupId)) { throw "Could not resolve the custom MDM scope group '$CustomGroup'." } + $RefBody = @{ '@odata.id' = "https://graph.microsoft.com/odata/groups('$GroupId')" } | ConvertTo-Json + $null = New-GraphPostRequest -tenantid $TenantFilter -uri "$Uri/includedGroups/`$ref" -type POST -body $RefBody -asApp $false -AddedHeaders $Headers -ContentType 'application/json; charset=utf-8' + } + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "Set the MDM enrollment scope to $AppliesTo." -Sev 'Info' +} diff --git a/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineMailContacts.ps1 b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineMailContacts.ps1 new file mode 100644 index 0000000000000..537148b0a41ee --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineMailContacts.ps1 @@ -0,0 +1,37 @@ +function Invoke-CIPPBaselineMailContacts { + <# + .SYNOPSIS + MailContacts executor: writes the configured organization contacts. + .DESCRIPTION + One app-only PATCH carrying only the members the baseline configures - the + classic's write: marketing and technical addresses replace their lists, and the + general contact lands on the privacy profile. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Remediate, + $TenantFilter, + $Current + ) + + $OrgId = "$($Current.organizationId)" + if ([string]::IsNullOrWhiteSpace($OrgId)) { return } + + $Body = [PSCustomObject]@{} + if (-not [string]::IsNullOrWhiteSpace("$($Remediate.marketingContact)")) { + $Body | Add-Member -NotePropertyName 'marketingNotificationEmails' -NotePropertyValue @("$($Remediate.marketingContact)") + } + $TechSet = @(@("$($Remediate.securityContact)", "$($Remediate.techContact)") | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -Unique) + if ($TechSet.Count -gt 0) { + $Body | Add-Member -NotePropertyName 'technicalNotificationMails' -NotePropertyValue @($TechSet) + } + if (-not [string]::IsNullOrWhiteSpace("$($Remediate.generalContact)")) { + $Body | Add-Member -NotePropertyName 'privacyProfile' -NotePropertyValue @{ contactEmail = "$($Remediate.generalContact)" } + } + if (@($Body.PSObject.Properties).Count -eq 0) { return } + + $null = New-GraphPostRequest -tenantid $TenantFilter -uri "https://graph.microsoft.com/v1.0/organization/$OrgId" -type PATCH -body (ConvertTo-Json -InputObject $Body -Depth 5) -AsApp $true -ContentType 'application/json' + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message 'Updated the organization notification contacts.' -Sev 'Info' +} diff --git a/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineNudgeMFA.ps1 b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineNudgeMFA.ps1 new file mode 100644 index 0000000000000..5f855a119beaa --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineNudgeMFA.ps1 @@ -0,0 +1,35 @@ +function Invoke-CIPPBaselineNudgeMFA { + <# + .SYNOPSIS + NudgeMFA executor: writes the authenticator registration campaign. + .DESCRIPTION + One call to Set-CIPPRegistrationCampaign - the shared helper the classic and the + identity UI use - with the hook's carried inputs. Null include/exclude targets mean + 'keep the tenant's current targeting', the deliberate semantics that stop older + deployments having portal-configured targeting overwritten. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Remediate, + $TenantFilter, + $Current + ) + + $P = $Current.campaignParams + if (-not $P) { return } + + $Params = @{ + Tenant = $TenantFilter + State = "$($P.State)" + TargetedAuthenticationMethod = "$($P.TargetedAuthenticationMethod)" + SnoozeDurationInDays = [int]$P.SnoozeDurationInDays + EnforceRegistrationAfterAllowedSnoozes = [bool]$P.EnforceRegistrationAfterAllowedSnoozes + IncludeTargets = $P.IncludeTargets + ExcludeTargets = $P.ExcludeTargets + APIName = 'Baselines' + } + $null = Set-CIPPRegistrationCampaign @Params + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "Set the MFA registration campaign to $($P.State) ($($P.TargetedAuthenticationMethod), snooze $($P.SnoozeDurationInDays)d)." -Sev 'Info' +} diff --git a/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineOMEBranding.ps1 b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineOMEBranding.ps1 new file mode 100644 index 0000000000000..f63f497438568 --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineOMEBranding.ps1 @@ -0,0 +1,41 @@ +function Invoke-CIPPBaselineOMEBranding { + <# + .SYNOPSIS + OMEBranding executor: applies the configured encrypted-message branding. + .DESCRIPTION + Writes only what the baseline configures - Set-OMEConfiguration treats absent + parameters as leave-alone, so unspecified branding fields survive, mirroring the + compare. The logo downloads fresh from the configured URL on every run, exactly as + the classic did: the tenant cannot echo the image back, so re-upload is the only way + to enforce it. A failed download skips the image but still writes the rest. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Remediate, + $TenantFilter, + $Current + ) + + $SetParams = @{ Identity = "$($Current.omeIdentity ?? 'OME Configuration')" } + foreach ($Field in @('BackgroundColor', 'EmailText', 'IntroductionText', 'ReadButtonText', 'PortalText', 'DisclaimerText', 'PrivacyStatementUrl')) { + $Value = $Remediate.$($Field.Substring(0, 1).ToLower() + $Field.Substring(1)) + if (-not [string]::IsNullOrWhiteSpace("$Value")) { $SetParams[$Field] = "$Value" } + } + foreach ($Switch in @('OTPEnabled', 'SocialIdSignIn')) { + $Value = $Remediate.$($Switch.Substring(0, 1).ToLower() + $Switch.Substring(1)) + if ($null -ne $Value -and "$Value" -ne '') { $SetParams[$Switch] = [bool]($Value -eq $true -or "$Value" -eq 'True') } + } + if (-not [string]::IsNullOrWhiteSpace("$($Remediate.logoUrl)") -and "$($Remediate.logoUrl)" -match '^https?://') { + try { + $SetParams['Image'] = (Invoke-WebRequest -Uri "$($Remediate.logoUrl)" -UseBasicParsing).Content + } catch { + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "Could not download the OME logo from $($Remediate.logoUrl): $($_.Exception.Message) - applying the rest of the branding without it." -Sev 'Error' + } + } + + if ($SetParams.Count -le 1) { return } + $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Set-OMEConfiguration' -cmdParams $SetParams -useSystemMailbox $true + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message 'Applied OME branding configuration.' -Sev 'Info' +} diff --git a/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineOauthConsent.ps1 b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineOauthConsent.ps1 new file mode 100644 index 0000000000000..83939389b96c7 --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineOauthConsent.ps1 @@ -0,0 +1,48 @@ +function Invoke-CIPPBaselineOauthConsent { + <# + .SYNOPSIS + OauthConsent executor: builds the CIPP consent policy and routes user consent + through it. + .DESCRIPTION + The classic's write, whole and in order: create the cipp-consent-policy when the + tenant has none, ensure the fixed Office 365 Management delegated include, ensure a + delegated AND an application include per allowed app (only the missing ones - the + POSTs are add-only), then PATCH the authorization policy to hand the default user + role ManagePermissionGrantsForSelf.cipp-consent-policy. Everything DELEGATED - the + authorization policy write returns 403 app-only, which is why the classic never + used -AsApp here. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Remediate, + $TenantFilter, + $Current + ) + + $PolicyUri = 'https://graph.microsoft.com/beta/policies/permissionGrantPolicies' + if ($Current.policyExists -ne $true) { + $Existing = @(New-GraphGetRequest -uri "$PolicyUri/" -tenantid $TenantFilter) | Where-Object { $_.id -eq 'cipp-consent-policy' } + if (-not $Existing) { + $null = New-GraphPostRequest -tenantid $TenantFilter -uri $PolicyUri -type POST -body '{ "id":"cipp-consent-policy", "displayName":"Application Consent Policy", "description":"This policy controls the current application consent policies."}' -ContentType 'application/json' + } + } + + foreach ($Key in @($Current.missingIncludes)) { + $Type, $AppId = "$Key" -split '\|' + $Body = @{ permissionClassification = 'all'; permissionType = $Type; clientApplicationIds = @($AppId) } | ConvertTo-Json -Compress + try { + $null = New-GraphPostRequest -tenantid $TenantFilter -uri "$PolicyUri/cipp-consent-policy/includes" -type POST -body $Body -ContentType 'application/json' + } catch { + # An include that already exists fails the POST harmlessly - the classic + # continued past exactly this. + Write-Information "Baselines: consent include $Key on $TenantFilter continued past: $($_.Exception.Message)" + } + } + + if ($Current.consentPolicyAssigned -ne $true) { + $null = New-GraphPostRequest -tenantid $TenantFilter -uri 'https://graph.microsoft.com/beta/policies/authorizationPolicy/authorizationPolicy' -type PATCH -body '{"permissionGrantPolicyIdsAssignedToDefaultUserRole":["ManagePermissionGrantsForSelf.cipp-consent-policy"]}' -ContentType 'application/json' + } + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message 'Routed user app consent through the CIPP consent policy.' -Sev 'Info' +} diff --git a/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselinePhishProtection.ps1 b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselinePhishProtection.ps1 new file mode 100644 index 0000000000000..10954f3561383 --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselinePhishProtection.ps1 @@ -0,0 +1,66 @@ +function Invoke-CIPPBaselinePhishProtection { + <# + .SYNOPSIS + PhishProtection executor: appends the phishing-check CSS to the sign-in branding. + .DESCRIPTION + The classic's write, verbatim: strip a known malformed variant of the canary CSS + (empty URL parameter) first, create the default branding localization when the + tenant has none (Accept-Language 0 header, tolerating the already-exists conflict), + then PUT the current CSS with the canary appended - never a blind overwrite of an + operator's existing custom CSS. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Remediate, + $TenantFilter, + $Current + ) + + $CurrentBody = "$($Current.currentBody)" + $CSS = "$($Current.expectedCss)" + $CustomerId = "$($Current.customerId)" + if ([string]::IsNullOrWhiteSpace($CustomerId)) { throw 'No tenant customer id was carried from the prepare hook - refusing a blind write.' } + + $MalformedCSSPattern = '\.ext-sign-in-box\s*\{\s*background-image:\s*url\(https://clone\.cipp\.app/api/PublicPhishingCheck\?Tenantid=[^&]*&URL=\);\s*\}' + if ($CurrentBody -match $MalformedCSSPattern) { + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message 'Removing a malformed PhishProtection CSS block before rewriting.' -Sev 'Info' + $CurrentBody = $CurrentBody -replace $MalformedCSSPattern, '' + } + + if ([string]::IsNullOrWhiteSpace($CurrentBody)) { + # No CSS usually means no default localization either - create it, tolerating the + # object-conflict answer when it already exists. + $DefaultLocalizationExists = $false + try { + $Localizations = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/organization/$CustomerId/branding/localizations" -tenantid $TenantFilter -AsApp $true + $DefaultLocalizationExists = [bool]($Localizations | Where-Object { "$($_.id)" -eq '0' }) + } catch { + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "Could not check for the default branding localization - creation will be attempted: $($_.Exception.Message)" -Sev 'Warning' + } + if (-not $DefaultLocalizationExists) { + $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 { + $null = New-GraphPostRequest -tenantid $TenantFilter -uri "https://graph.microsoft.com/beta/organization/$CustomerId/branding/localizations/" -ContentType 'application/json' -AsApp $true -type POST -body $DefaultBrandingBody -AddedHeaders @{ 'Accept-Language' = 0 } + } catch { + $GraphError = $null + try { $GraphError = (Get-CippException -Exception $_).RawError | ConvertFrom-Json -ErrorAction Stop } catch {} + $IsConflict = ($GraphError.error.code -eq 'Request_BadRequest') -and [bool]($GraphError.error.details | Where-Object { $_.code -eq 'ObjectConflict' -and $_.target -eq 'id' }) + if ($IsConflict) { + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message 'The default branding localization already exists - continuing with it.' -Sev 'Info' + } else { + throw + } + } + } + } + + if ($CurrentBody -like "*$CSS*") { + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message 'The logon screen phishing protection CSS is already active.' -Sev 'Info' + return + } + $CurrentBody = $CurrentBody + $CSS + New-GraphPostRequest -tenantid $TenantFilter -uri "https://graph.microsoft.com/beta/organization/$CustomerId/branding/localizations/0/customCSS" -ContentType 'text/css' -AsApp $true -type PUT -body $CurrentBody + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message 'Enabled the logon screen phishing protection CSS.' -Sev 'Info' +} diff --git a/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselinePhishSimSpoofIntelligence.ps1 b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselinePhishSimSpoofIntelligence.ps1 new file mode 100644 index 0000000000000..cd5fbf245ae29 --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselinePhishSimSpoofIntelligence.ps1 @@ -0,0 +1,43 @@ +function Invoke-CIPPBaselinePhishSimSpoofIntelligence { + <# + .SYNOPSIS + PhishSimSpoofIntelligence executor: aligns the spoof intelligence allow list. + .DESCRIPTION + One bulk batch, the classic's: removals first (only when the operator opted into + strict ownership), then each missing domain added TWICE - once as Internal and once + as External spoof type, wildcarded to every spoofed user - because simulation + senders spoof both directions and the classic allowed both. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Remediate, + $TenantFilter, + $Current + ) + + $Missing = @($Current.missingDomains | Where-Object { $_ }) + $RemoveIds = @($Current.extraItemIds | Where-Object { $_ }) + if ($Missing.Count -eq 0 -and $RemoveIds.Count -eq 0) { return } + + $Requests = [System.Collections.Generic.List[hashtable]]::new() + if ($RemoveIds.Count -gt 0) { + $Requests.Add(@{ CmdletInput = @{ CmdletName = 'Remove-TenantAllowBlockListSpoofItems'; Parameters = @{ Identity = 'default'; Ids = @($RemoveIds) } } }) + } + foreach ($Domain in $Missing) { + foreach ($SpoofType in @('Internal', 'External')) { + $Requests.Add(@{ CmdletInput = @{ CmdletName = 'New-TenantAllowBlockListSpoofItems'; Parameters = @{ + Identity = 'default'; Action = 'Allow'; SendingInfrastructure = "$Domain"; SpoofedUser = '*'; SpoofType = $SpoofType + } } }) + } + } + + $Results = New-ExoBulkRequest -tenantid $TenantFilter -cmdletArray @($Requests) -useSystemMailbox $true + $Errors = @($Results | Where-Object { $_.error }) + foreach ($ErrorResult in $Errors) { + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "Spoof intelligence write failed: $(Get-NormalizedError -Message $ErrorResult.error)" -Sev 'Error' + } + if ($Errors.Count -ge $Requests.Count) { throw "Every spoof intelligence write failed for $TenantFilter - see the log for the first error." } + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "Spoof intelligence: allowed $($Missing.Count) domain(s), removed $($RemoveIds.Count) item(s)." -Sev 'Info' +} diff --git a/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselinePhishingSimulations.ps1 b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselinePhishingSimulations.ps1 new file mode 100644 index 0000000000000..e8d7de109ca99 --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselinePhishingSimulations.ps1 @@ -0,0 +1,63 @@ +function Invoke-CIPPBaselinePhishingSimulations { + <# + .SYNOPSIS + PhishingSimulations executor: aligns the phish sim override policy, rule and URL + allowances. + .DESCRIPTION + Three legs, each only when its part drifted, all the classic's writes: the override + policy is enabled in place or created; the rule takes Add/Remove deltas when it + exists (Set-ExoPhishSimOverrideRule speaks in deltas, not replacement lists) and is + created with the full configured lists otherwise; simulation URLs add to and - in + strict mode - remove from the Url advanced-delivery allow list. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Remediate, + $TenantFilter, + $Current + ) + + $Unwrap = { param($Value) @(@($Value) | ForEach-Object { "$($_.value ?? $_)" } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) } + $WantedDomains = & $Unwrap $Remediate.domains + $WantedRanges = & $Unwrap $Remediate.senderIpRanges + + if ($Current.policyEnabled -ne $true) { + if ($Current.policyExists -eq $true) { + $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Set-PhishSimOverridePolicy' -cmdParams @{ Identity = 'CIPPPhishSim'; Enabled = $true } + } else { + $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'New-PhishSimOverridePolicy' -cmdParams @{ Name = 'CIPPPhishSim'; Enabled = $true } + } + } + + $RuleNeedsWork = @($Current.missingDomains).Count -gt 0 -or @($Current.missingSenderIpRanges).Count -gt 0 -or + @($Current.extraDomains).Count -gt 0 -or @($Current.extraSenderIpRanges).Count -gt 0 + if ($RuleNeedsWork) { + if ($Current.ruleExists -eq $true) { + $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Set-ExoPhishSimOverrideRule' -cmdParams @{ + Identity = "$($Current.ruleIdentity)" + AddSenderIpRanges = @($Current.missingSenderIpRanges) + AddDomains = @($Current.missingDomains) + RemoveSenderIpRanges = @($Current.extraSenderIpRanges) + RemoveDomains = @($Current.extraDomains) + } + } else { + $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'New-ExoPhishSimOverrideRule' -cmdParams @{ + Name = 'CIPPPhishSim'; Policy = 'PhishSimOverridePolicy'; SenderIpRanges = @($WantedRanges); Domains = @($WantedDomains) + } + } + } + + if (@($Current.extraUrls).Count -gt 0) { + $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Remove-TenantAllowBlockListItems' -cmdParams @{ + ListType = 'Url'; ListSubType = 'AdvancedDelivery'; Entries = @($Current.extraUrls) + } + } + if (@($Current.missingUrls).Count -gt 0) { + $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'New-TenantAllowBlockListItems' -cmdParams @{ + ListType = 'Url'; ListSubType = 'AdvancedDelivery'; Allow = $true; Entries = @($Current.missingUrls) + } + } + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message 'Aligned the phishing simulation overrides.' -Sev 'Info' +} diff --git a/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineProfilePhotos.ps1 b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineProfilePhotos.ps1 new file mode 100644 index 0000000000000..6757da6de6f8a --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineProfilePhotos.ps1 @@ -0,0 +1,36 @@ +function Invoke-CIPPBaselineProfilePhotos { + <# + .SYNOPSIS + ProfilePhotos executor: enables or disables user photo changes on both surfaces. + .DESCRIPTION + The classic's paired write: the default OWA mailbox policy's SetPhotoEnabled flag, + and the Graph photo update settings - DELETEd back to default when enabling (an + absent policy means everyone may change photos), or PATCHed with the admin role ids + when disabling. Both must move or users keep a side door. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Remediate, + $TenantFilter, + $Current + ) + + $Enabled = "$($Remediate.state)" -eq 'enabled' + $Uri = 'https://graph.microsoft.com/beta/admin/people/photoUpdateSettings' + $Identity = "$($Current.owaPolicyIdentity)" + if ([string]::IsNullOrWhiteSpace($Identity)) { $Identity = 'OwaMailboxPolicy-Default' } + + $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Set-OwaMailboxPolicy' -cmdParams @{ Identity = $Identity; SetPhotoEnabled = $Enabled } -useSystemMailbox $true + if ($Enabled) { + $null = New-GraphPostRequest -tenantid $TenantFilter -uri $Uri -type DELETE -AsApp $true + } else { + $Body = @{ + source = 'cloud' + allowedRoles = @('fe930be7-5e62-47db-91af-98c3a49a38b1', '62e90394-69f5-4237-9190-012177145e10') + } | ConvertTo-Json -Depth 5 -Compress + $null = New-GraphPostRequest -tenantid $TenantFilter -uri $Uri -type PATCH -body $Body -AsApp $true + } + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "Set user profile photo changes to $($Remediate.state)." -Sev 'Info' +} diff --git a/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineQuarantineRequestAlert.ps1 b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineQuarantineRequestAlert.ps1 new file mode 100644 index 0000000000000..fe793461d914d --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineQuarantineRequestAlert.ps1 @@ -0,0 +1,84 @@ +function Invoke-CIPPBaselineQuarantineRequestAlert { + <# + .SYNOPSIS + QuarantineRequestAlert executor: creates or updates the quarantine release-request + 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. + + 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. + + 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. 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. All three 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' + $RemoveAlert = "$($Remediate.state)" -eq 'removed' + $Configured = "$($Remediate.notifyUser)" + if (-not $RemoveAlert -and [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: $($_.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() + $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/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineQuarantineTemplate.ps1 b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineQuarantineTemplate.ps1 new file mode 100644 index 0000000000000..1b74a2e323e4a --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineQuarantineTemplate.ps1 @@ -0,0 +1,47 @@ +function Invoke-CIPPBaselineQuarantineTemplate { + <# + .SYNOPSIS + QuarantineTemplate executor: creates or updates the quarantine policy. + .DESCRIPTION + One call to Set-CIPPQuarantinePolicy, the same upsert the classic and the quarantine + UI use, with the action picked from what the hook found. The permissions hashtable + is rebuilt from the GRADED Expected-side values on the Current row - the hook and + this executor must agree on the wire shape, and PermissionToViewHeader/Download stay + false because Exchange ignores them. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Remediate, + $TenantFilter, + $Current + ) + + $PolicyName = "$($Current.policyName)" + if ([string]::IsNullOrWhiteSpace($PolicyName)) { return } + + $ReleaseAction = "$($Remediate.releaseAction)" + $Permissions = @{ + PermissionToViewHeader = $false + PermissionToDownload = $false + PermissionToBlockSender = [bool]($Remediate.permissionToBlockSender -eq $true) + PermissionToDelete = [bool]($Remediate.permissionToDelete -eq $true) + PermissionToPreview = [bool]($Remediate.permissionToPreview -eq $true) + PermissionToRelease = [bool]($ReleaseAction -eq 'PermissionToRelease') + PermissionToRequestRelease = [bool]($ReleaseAction -eq 'PermissionToRequestRelease') + PermissionToAllowSender = [bool]($Remediate.permissionToAllowSender -eq $true) + } + + $Params = @{ + identity = $PolicyName + action = $(if ($Current.deployed -eq $true) { 'Update' } else { 'Create' }) + EndUserQuarantinePermissions = $Permissions + ESNEnabled = [bool]($Remediate.esnEnabled -eq $true) + IncludeMessagesFromBlockedSenderAddress = [bool]($Remediate.includeMessagesFromBlockedSenderAddress -eq $true) + tenantFilter = $TenantFilter + APIName = 'Baselines' + } + Set-CIPPQuarantinePolicy @Params + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "$($Params.action)d quarantine policy '$PolicyName'." -Sev 'Info' +} diff --git a/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineRestrictThirdPartyStorageServices.ps1 b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineRestrictThirdPartyStorageServices.ps1 new file mode 100644 index 0000000000000..786afa72e0fae --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineRestrictThirdPartyStorageServices.ps1 @@ -0,0 +1,24 @@ +function Invoke-CIPPBaselineRestrictThirdPartyStorageServices { + <# + .SYNOPSIS + RestrictThirdPartyStorageServices executor: disables the Microsoft 365 on the web + service principal. + .DESCRIPTION + The classic's write: one PATCH against the appId-addressed upsert endpoint with the + 'Prefer: create-if-missing' header - the plain /servicePrincipals/{appId} path does + not resolve this principal, and the upsert also creates-then-disables it on tenants + where it never existed. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Remediate, + $TenantFilter, + $Current + ) + + $Body = @{ accountEnabled = $false } | ConvertTo-Json -Compress + $null = New-GraphPostRequest -uri "https://graph.microsoft.com/beta/servicePrincipals(appId='c1f33bc0-bdb4-4248-ba9b-096807ddb43e')" -body $Body -tenantid $TenantFilter -type PATCH -AddedHeaders @{ 'Prefer' = 'create-if-missing' } + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message 'Restricted third-party storage services in Microsoft 365 on the web.' -Sev 'Info' +} diff --git a/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineRetentionCompliancePolicyTemplate.ps1 b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineRetentionCompliancePolicyTemplate.ps1 new file mode 100644 index 0000000000000..e5678c234dd1f --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineRetentionCompliancePolicyTemplate.ps1 @@ -0,0 +1,30 @@ +function Invoke-CIPPBaselineRetentionCompliancePolicyTemplate { + <# + .SYNOPSIS + RetentionCompliancePolicyTemplate executor: deploys the selected retention policies. + .DESCRIPTION + Pushes every selected template unconditionally, which is what the classic standard did - + it called Set-CIPPRetentionCompliancePolicy for each template on every remediation run + without consulting the comparison. That pairs with checkBeforeRun:false on the definition: + the compare only grades presence, so a policy whose RULES or LOCATIONS drifted still reads + compliant, and the unconditional rewrite is what repairs it. + + Set-CIPPRetentionCompliancePolicy is upsert over both the policy and its rules, so there + is no create/update branch to make here. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Remediate, + $TenantFilter, + $Current + ) + + $Templates = @($Current.templateBodies | Where-Object { $_ }) + if ($Templates.Count -eq 0) { return } + + foreach ($Template in $Templates) { + $null = Set-CIPPRetentionCompliancePolicy -TenantFilter $TenantFilter -Template $Template -APIName 'Baselines' + } +} diff --git a/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineRetentionPolicyTag.ps1 b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineRetentionPolicyTag.ps1 new file mode 100644 index 0000000000000..3240b6b85f05f --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineRetentionPolicyTag.ps1 @@ -0,0 +1,43 @@ +function Invoke-CIPPBaselineRetentionPolicyTag { + <# + .SYNOPSIS + RetentionPolicyTag executor: upserts the CIPP Deleted Items tag and links it into + the Default MRM Policy. + .DESCRIPTION + Two writes, both the classic's: Set- when the tag exists, New- (with the fixed + DeletedItems type) otherwise; then the MRM policy link, which must resend the FULL + existing link list with ours appended - Set-RetentionPolicy replaces the whole list, + and sending only the new tag would unlink every other retention tag in the tenant. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Remediate, + $TenantFilter, + $Current + ) + + $TagName = 'CIPP Deleted Items' + $Params = @{ + RetentionEnabled = $true + AgeLimitForRetention = [int]"$($Remediate.ageLimitForRetention)" + RetentionAction = 'PermanentlyDelete' + } + if ($Current.tagExists -eq $true) { + $Params['Identity'] = $TagName + $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Set-RetentionPolicyTag' -cmdParams $Params -useSystemMailbox $true + } else { + $Params['Name'] = $TagName + $Params['Type'] = 'DeletedItems' + $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'New-RetentionPolicyTag' -cmdParams $Params -useSystemMailbox $true + } + + if ($Current.linkedToPolicy -ne $true) { + $Links = @(@($Current.existingLinks | Where-Object { $_ }) + $TagName) + $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Set-RetentionPolicy' -cmdParams @{ + Identity = 'Default MRM Policy'; RetentionPolicyTagLinks = $Links + } -useSystemMailbox $true + } + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "Applied the $TagName retention tag at $($Params.AgeLimitForRetention) days." -Sev 'Info' +} diff --git a/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineReusableSettingsTemplate.ps1 b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineReusableSettingsTemplate.ps1 new file mode 100644 index 0000000000000..e15b8f18f207d --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineReusableSettingsTemplate.ps1 @@ -0,0 +1,28 @@ +function Invoke-CIPPBaselineReusableSettingsTemplate { + <# + .SYNOPSIS + ReusableSettingsTemplate executor: creates or overwrites the reusable setting. + .DESCRIPTION + PUT over the existing object when the hook found one, POST otherwise - the classic's + exact write. The body is the template's raw Graph JSON, untouched: a reusable + setting is a settingInstance tree that only round-trips whole. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Remediate, + $TenantFilter, + $Current + ) + + if ([string]::IsNullOrWhiteSpace("$($Current.rawJSON)")) { return } + + if ($Current.existingId) { + $null = New-GraphPostRequest -uri "https://graph.microsoft.com/beta/deviceManagement/reusablePolicySettings/$($Current.existingId)" -tenantid $TenantFilter -type PUT -body $Current.rawJSON + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message 'Updated reusable setting from template.' -Sev 'Info' + } else { + $null = New-GraphPostRequest -uri 'https://graph.microsoft.com/beta/deviceManagement/reusablePolicySettings' -tenantid $TenantFilter -type POST -body $Current.rawJSON + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message 'Created reusable setting from template.' -Sev 'Info' + } +} diff --git a/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineRotateDKIM.ps1 b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineRotateDKIM.ps1 new file mode 100644 index 0000000000000..61f965cb707f1 --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineRotateDKIM.ps1 @@ -0,0 +1,27 @@ +function Invoke-CIPPBaselineRotateDKIM { + <# + .SYNOPSIS + RotateDKIM executor: rotates weak DKIM keys to 2048-bit. + .DESCRIPTION + One Rotate-DkimSigningConfig per domain the hook found on a 1024-bit selector. + Rotation is asynchronous in Exchange - the new key publishes on the next selector + flip - so the row stays at drift until Exchange reports the new key size, which is + the honest state. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Remediate, + $TenantFilter, + $Current + ) + + $Domains = @($Current.domainsWith1024BitDkim | Where-Object { $_ }) + if ($Domains.Count -eq 0) { return } + + foreach ($Domain in $Domains) { + $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Rotate-DkimSigningConfig' -cmdParams @{ KeySize = 2048; Identity = "$Domain" } -useSystemMailbox $true + } + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "Rotated DKIM to 2048-bit for: $($Domains -join ', ')." -Sev 'Info' +} diff --git a/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineSPDirectSharing.ps1 b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineSPDirectSharing.ps1 new file mode 100644 index 0000000000000..8322e2e9e90f1 --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineSPDirectSharing.ps1 @@ -0,0 +1,23 @@ +function Invoke-CIPPBaselineSPDirectSharing { + <# + .SYNOPSIS + SPDirectSharing executor: sets the default sharing link type to Direct. + .DESCRIPTION + Reads the SPO tenant LIVE for a fresh CSOM object identity - the cached one goes + stale and the SPO SOAP endpoint rejects stale identities - then writes + DefaultSharingLinkType 1 through the shared SPO helper, the classic's write. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Remediate, + $TenantFilter, + $Current + ) + + $State = Get-CIPPSPOTenant -TenantFilter $TenantFilter | Select-Object -Property _ObjectIdentity_, TenantFilter, DefaultSharingLinkType + if (-not $State) { throw 'Could not read the SPO tenant configuration - refusing a blind write.' } + $State | Set-CIPPSPOTenant -Properties @{ DefaultSharingLinkType = 1 } + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message 'Set the default sharing link type to Direct.' -Sev 'Info' +} diff --git a/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineSPOTenant.ps1 b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineSPOTenant.ps1 index 569008a1fb4d2..27256f44c2a2d 100644 --- a/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineSPOTenant.ps1 +++ b/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/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineSPOVersionControl.ps1 b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineSPOVersionControl.ps1 new file mode 100644 index 0000000000000..ebdc7dbd658c2 --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineSPOVersionControl.ps1 @@ -0,0 +1,61 @@ +function Invoke-CIPPBaselineSPOVersionControl { + <# + .SYNOPSIS + SPOVersionControl executor: writes the tenant file version policy, optionally + pushing it to existing sites. + .DESCRIPTION + The tenant write goes through the SPO SetFileVersionPolicy method with the + classic's exact parameter shape (-1 sentinels for the limits when auto-trim is on), + against a LIVE-read CSOM identity. When the baseline opts into existing sites, each + site inherits the tenant policy across new and existing document libraries - a + per-site fan-out that continues past individual site failures, as the classic did. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Remediate, + $TenantFilter, + $Current + ) + + $AutoTrim = [bool]($Remediate.enableAutoTrim -eq $true -or "$($Remediate.enableAutoTrim)" -eq 'True') + $MajorLimit = [int]"$($Remediate.majorVersionLimit ?? 50)" + $ExpireDays = [int]"$($Remediate.expireVersionsAfterDays ?? 0)" + if (-not $AutoTrim -and $ExpireDays -ne 0 -and ($ExpireDays -lt 30 -or $ExpireDays -gt 36500)) { return } + + $State = Get-CIPPSPOTenant -TenantFilter $TenantFilter | Select-Object -Property _ObjectIdentity_, TenantFilter + if (-not $State) { throw 'Could not read the SPO tenant configuration - refusing a blind write.' } + + $MethodParams = if ($AutoTrim) { + @(@{ Type = 'Boolean'; Value = $true }, @{ Type = 'Int32'; Value = -1 }, @{ Type = 'Int32'; Value = -1 }) + } else { + @(@{ Type = 'Boolean'; Value = $false }, @{ Type = 'Int32'; Value = $MajorLimit }, @{ Type = 'Int32'; Value = $ExpireDays }) + } + $State | Set-CIPPSPOTenant -MethodName 'SetFileVersionPolicy' -MethodParameters $MethodParams + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "Set the file version policy (autoTrim=$AutoTrim$(if (-not $AutoTrim) { ", limit=$MajorLimit, expire=${ExpireDays}d" }))." -Sev 'Info' + + if ($Remediate.applyToExistingSites -eq $true -or "$($Remediate.applyToExistingSites)" -eq 'True') { + $Sites = @(New-GraphGetRequest -uri "https://graph.microsoft.com/beta/sites/getAllSites?`$select=webUrl&`$top=999" -tenantid $TenantFilter -AsApp $true) + $SiteProperties = @{ + InheritVersionPolicyFromTenant = $true + EnableAutoExpirationVersionTrim = $AutoTrim + ApplyToNewDocumentLibraries = $true + ApplyToExistingDocumentLibraries = $true + } + if (-not $AutoTrim) { + $SiteProperties.MajorVersionLimit = $MajorLimit + $SiteProperties.ExpireVersionsAfterDays = $ExpireDays + } + $Failures = 0 + foreach ($Site in $Sites) { + try { + Set-CIPPSPOSite -TenantFilter $TenantFilter -SiteUrl $Site.webUrl -Properties $SiteProperties + } catch { + $Failures++ + Write-Information "Baselines: version policy on $($Site.webUrl) continued past: $($_.Exception.Message)" + } + } + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "Applied the version policy to $(@($Sites).Count - $Failures) of $(@($Sites).Count) existing site(s)." -Sev 'Info' + } +} diff --git a/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineSafeLinksTemplatePolicy.ps1 b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineSafeLinksTemplatePolicy.ps1 new file mode 100644 index 0000000000000..7e8449af8e811 --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineSafeLinksTemplatePolicy.ps1 @@ -0,0 +1,98 @@ +function Invoke-CIPPBaselineSafeLinksTemplatePolicy { + <# + .SYNOPSIS + SafeLinksTemplatePolicy executor: upserts the policy, its rule, and the rule state. + .DESCRIPTION + The classic's three-step write, ported whole: New-/Set-SafeLinksPolicy with the + mapped policy fields, New-/Set-SafeLinksRule with the recipient scoping (the rule + binds to the policy only on create - the binding cannot change on Set-), then + Enable-/Disable-SafeLinksRule when the template expresses a state. + + Applied in full on every remediation run (checkBeforeRun:false): the compare only + grades presence, and the rewrite is what repairs setting drift it cannot see. + + Array-ish template fields (SentTo, DoNotRewriteUrls, ...) normalize through the + classic's unwrap rules - autoComplete pickers store {label,value} objects, user + pickers store userPrincipalName, and a bare string is itself. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Remediate, + $TenantFilter, + $Current + ) + + function ConvertTo-CIPPBaselineSafeLinksArray { + param($Field) + if ($null -eq $Field) { return @() } + $Result = [System.Collections.Generic.List[string]]::new() + foreach ($FieldItem in @($Field)) { + if ($null -eq $FieldItem) { continue } + if ($FieldItem -is [string]) { $Result.Add($FieldItem); continue } + if ($FieldItem.value) { $Result.Add("$($FieldItem.value)"); continue } + if ($FieldItem.userPrincipalName) { $Result.Add("$($FieldItem.userPrincipalName)"); continue } + if ($FieldItem.id) { $Result.Add("$($FieldItem.id)"); continue } + $Result.Add("$FieldItem") + } + return $Result.ToArray() + } + + $Template = $Current.templateBody + $PolicyName = "$($Current.policyName)" + $RuleName = "$($Current.ruleName)" + if (-not $Template -or [string]::IsNullOrWhiteSpace($PolicyName)) { return } + + $PolicyParams = @{} + foreach ($Field in @('EnableSafeLinksForEmail', 'EnableSafeLinksForTeams', 'EnableSafeLinksForOffice', 'TrackClicks', + 'AllowClickThrough', 'ScanUrls', 'EnableForInternalSenders', 'DeliverMessageAfterScan', 'DisableUrlRewrite', + 'AdminDisplayName', 'CustomNotificationText', 'EnableOrganizationBranding')) { + if ($null -ne $Template.$Field) { $PolicyParams[$Field] = $Template.$Field } + } + $DoNotRewriteUrls = ConvertTo-CIPPBaselineSafeLinksArray -Field $Template.DoNotRewriteUrls + if ($DoNotRewriteUrls.Count -gt 0) { $PolicyParams['DoNotRewriteUrls'] = $DoNotRewriteUrls } + + if ($Current.policyDeployed -eq $true) { + $PolicyParams['Identity'] = $PolicyName + $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Set-SafeLinksPolicy' -cmdParams $PolicyParams -useSystemMailbox $true + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "Updated SafeLinks policy '$PolicyName'." -Sev 'Info' + } else { + $PolicyParams['Name'] = $PolicyName + $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'New-SafeLinksPolicy' -cmdParams $PolicyParams -useSystemMailbox $true + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "Created SafeLinks policy '$PolicyName'." -Sev 'Info' + } + + $RuleParams = @{} + if ($null -ne $Template.Priority) { $RuleParams['Priority'] = $Template.Priority } + if ($null -ne $Template.Description) { $RuleParams['Comments'] = $Template.Description } + if ($null -ne $Template.TemplateDescription) { $RuleParams['Comments'] = $Template.TemplateDescription } + foreach ($Field in @('SentTo', 'SentToMemberOf', 'RecipientDomainIs', 'ExceptIfSentTo', 'ExceptIfSentToMemberOf', 'ExceptIfRecipientDomainIs')) { + $Values = ConvertTo-CIPPBaselineSafeLinksArray -Field $Template.$Field + if ($Values.Count -gt 0) { $RuleParams[$Field] = $Values } + } + + if ($Current.ruleDeployed -eq $true) { + $RuleParams['Identity'] = $RuleName + $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Set-SafeLinksRule' -cmdParams $RuleParams -useSystemMailbox $true + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "Updated SafeLinks rule '$RuleName'." -Sev 'Info' + } else { + $RuleParams['Name'] = $RuleName + $RuleParams['SafeLinksPolicy'] = $PolicyName + $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'New-SafeLinksRule' -cmdParams $RuleParams -useSystemMailbox $true + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "Created SafeLinks rule '$RuleName'." -Sev 'Info' + } + + # Rule state: only when the template expresses one, in any of the classic's spellings. + $IsEnabled = switch ("$($Template.State)") { + 'Enabled' { $true } + 'Disabled' { $false } + 'True' { $true } + 'False' { $false } + default { $null } + } + if ($null -ne $IsEnabled) { + $Cmdlet = if ($IsEnabled) { 'Enable-SafeLinksRule' } else { 'Disable-SafeLinksRule' } + $null = New-ExoRequest -tenantid $TenantFilter -cmdlet $Cmdlet -cmdParams @{ Identity = $RuleName } -useSystemMailbox $true + } +} diff --git a/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineSecureScoreRemediation.ps1 b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineSecureScoreRemediation.ps1 new file mode 100644 index 0000000000000..6df860ab330a8 --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineSecureScoreRemediation.ps1 @@ -0,0 +1,53 @@ +function Invoke-CIPPBaselineSecureScoreRemediation { + <# + .SYNOPSIS + SecureScoreRemediation executor: writes the configured control states. + .DESCRIPTION + One bulk request PATCHing each drifted control's state, comment and the Microsoft + SecureScore vendor block - the classic's exact write. Defender controls (scid_*) + skip: their state cannot be set through this endpoint, and the classic skipped them + for the same reason. Per-control failures log and continue. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Remediate, + $TenantFilter, + $Current + ) + + $Controls = @($Current.driftedControls | Where-Object { $_ -and "$($_.Control)" -notmatch '^scid_' }) + $Skipped = @($Current.driftedControls | Where-Object { $_ -and "$($_.Control)" -match '^scid_' }) + foreach ($Skip in $Skipped) { + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "Secure score control '$($Skip.Control)' is a Defender control and cannot be set through this endpoint - skipped." -Sev 'Info' + } + if ($Controls.Count -eq 0) { return } + + $Id = 1 + $Requests = @(foreach ($Control in $Controls) { + @{ + id = $Id++ + method = 'PATCH' + url = "security/secureScoreControlProfiles/$($Control.Control)" + body = @{ + state = "$($Control.State)" + comment = "$($Control.Reason)" + vendorInformation = @{ vendor = 'Microsoft'; provider = 'SecureScore' } + } + headers = @{ 'Content-Type' = 'application/json' } + } + }) + $Results = New-GraphBulkRequest -tenantid $TenantFilter -Requests @($Requests) + $Failures = 0 + for ($i = 0; $i -lt @($Results).Count; $i++) { + $Result = @($Results)[$i] + if ($Result.status -notin @(200, 204)) { + $Failures++ + $ErrorText = $(if ($Result.body.error.message) { "$($Result.body.error.message)" } else { "status $($Result.status)" }) + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "Secure score control '$($Controls[$i].Control)' write failed: $ErrorText" -Sev 'Error' + } + } + if ($Failures -ge $Controls.Count) { throw "Every secure score control write failed for $TenantFilter - see the log for the first error." } + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "Secure score: set $($Controls.Count - $Failures) of $($Controls.Count) control state(s)." -Sev 'Info' +} diff --git a/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineSendReceiveLimitTenant.ps1 b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineSendReceiveLimitTenant.ps1 new file mode 100644 index 0000000000000..41c30f58dd7b0 --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineSendReceiveLimitTenant.ps1 @@ -0,0 +1,29 @@ +function Invoke-CIPPBaselineSendReceiveLimitTenant { + <# + .SYNOPSIS + SendReceiveLimitTenant executor: sets the limits on every off-limits mailbox plan. + .DESCRIPTION + One Set-MailboxPlan per offender the hook found, keyed on the plan GUID. Byte values + go over the wire; Exchange renders them back as its display strings. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Remediate, + $TenantFilter, + $Current + ) + + $Guids = @($Current.offenderGuids | Where-Object { -not [string]::IsNullOrWhiteSpace("$_") }) + if ($Guids.Count -eq 0) { return } + $MaxSend = [int64]"$($Remediate.sendLimit)" * 1MB + $MaxReceive = [int64]"$($Remediate.receiveLimit)" * 1MB + + foreach ($Guid in $Guids) { + $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Set-MailboxPlan' -cmdParams @{ + Identity = "$Guid"; MaxSendSize = $MaxSend; MaxReceiveSize = $MaxReceive + } -useSystemMailbox $true + } + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "Set send/receive limits to $($Remediate.sendLimit)MB/$($Remediate.receiveLimit)MB on $($Guids.Count) mailbox plan(s)." -Sev 'Info' +} diff --git a/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineSensitiveInfoTypeTemplate.ps1 b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineSensitiveInfoTypeTemplate.ps1 new file mode 100644 index 0000000000000..9ded586c928bf --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineSensitiveInfoTypeTemplate.ps1 @@ -0,0 +1,36 @@ +function Invoke-CIPPBaselineSensitiveInfoTypeTemplate { + <# + .SYNOPSIS + SensitiveInfoTypeTemplate executor: deploys the SITs that are missing or drifted. + .DESCRIPTION + Unlike the other template families this one is selective, and deliberately so. It writes + only the templates the prepare hook found Missing or Drift - the same subset the classic + standard remediated. Templates that came back Invalid are broken TEMPLATES, not broken + tenants, so deploying them would fail; templates that came back BuiltIn collide with a + Microsoft SIT that cannot be modified. Both are reported and left alone. + + Set-CIPPSensitiveInfoType reports its outcome in its return string rather than by + throwing, which is why the result is matched rather than trusted. Anything that is not + Created or Updated is a failure and is logged as one - the classic did the same. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Remediate, + $TenantFilter, + $Current + ) + + $Templates = @($Current.remediableTemplates | Where-Object { $_ }) + if ($Templates.Count -eq 0) { return } + + foreach ($Template in $Templates) { + $Result = Set-CIPPSensitiveInfoType -TenantFilter $TenantFilter -Template $Template -APIName 'Baselines' + if ("$Result" -match '^(Created|Updated)') { + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "Remediated Sensitive Information Type '$($Template.Name)': $Result" -Sev 'Info' + } else { + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "$Result" -Sev 'Error' + } + } +} diff --git a/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineSensitivityLabelTemplate.ps1 b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineSensitivityLabelTemplate.ps1 new file mode 100644 index 0000000000000..c58104a35a139 --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineSensitivityLabelTemplate.ps1 @@ -0,0 +1,30 @@ +function Invoke-CIPPBaselineSensitivityLabelTemplate { + <# + .SYNOPSIS + SensitivityLabelTemplate executor: deploys the selected sensitivity labels. + .DESCRIPTION + Pushes every selected template unconditionally, which is what the classic standard did - + it called Set-CIPPSensitivityLabel for each template on every remediation run without + consulting the comparison. That pairs with checkBeforeRun:false on the definition, and it + matters: the compare only grades presence, so a label whose ENCRYPTION or MARKING settings + drifted still reads compliant. The unconditional rewrite is what repairs it. + + Set-CIPPSensitivityLabel is upsert - it updates a label of the same name in place and + creates it otherwise - so there is no create/update branch to make here. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Remediate, + $TenantFilter, + $Current + ) + + $Templates = @($Current.templateBodies | Where-Object { $_ }) + if ($Templates.Count -eq 0) { return } + + foreach ($Template in $Templates) { + $null = Set-CIPPSensitivityLabel -TenantFilter $TenantFilter -Template $Template -APIName 'Baselines' + } +} diff --git a/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineSmartLockout.ps1 b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineSmartLockout.ps1 new file mode 100644 index 0000000000000..9d24f23a3c191 --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineSmartLockout.ps1 @@ -0,0 +1,56 @@ +function Invoke-CIPPBaselineSmartLockout { + <# + .SYNOPSIS + SmartLockout executor: writes the lockout values on the password-rule directory + setting. + .DESCRIPTION + Creates the setting object with the classic's defaults when the tenant has none; + otherwise PATCHes the FULL six-value array - Graph rejects a directory-settings + update that omits any template property - with the banned-password values taken + from the LIVE object so this standard never steps on CustomBannedPasswordList's + territory. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Remediate, + $TenantFilter, + $Current + ) + + $Mode = "$($Remediate.bannedPasswordCheckOnPremisesMode)" + if ($Mode -eq 'Enforced') { $Mode = 'Enforce' } + if ([string]::IsNullOrWhiteSpace($Mode)) { $Mode = 'Audit' } + $OnPrem = if ($Remediate.enableBannedPasswordCheckOnPremises -eq $true -or "$($Remediate.enableBannedPasswordCheckOnPremises)" -eq 'True') { 'True' } else { 'False' } + $LockoutValues = @( + @{ name = 'LockoutDurationInSeconds'; value = "$($Remediate.lockoutDurationInSeconds)" } + @{ name = 'LockoutThreshold'; value = "$($Remediate.lockoutThreshold)" } + @{ name = 'EnableBannedPasswordCheckOnPremises'; value = $OnPrem } + @{ name = 'BannedPasswordCheckOnPremisesMode'; value = $Mode } + ) + + # Always read the object LIVE: a cached id can be mid-rewrite stale (concurrent + # one-offs share this object), and the update must resend EVERY template value - Graph + # rejects a partial values array - so the banned-password values come from the live + # object rather than this standard guessing them. + $Live = @(New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/settings' -tenantid $TenantFilter) | Where-Object { "$($_.templateId)" -eq '5cf42378-d67d-4f36-ba46-e8b86229381d' } | Select-Object -First 1 + if (-not $Live) { + $Body = @{ + templateId = '5cf42378-d67d-4f36-ba46-e8b86229381d' + values = @(@( + @{ name = 'EnableBannedPasswordCheck'; value = 'False' } + @{ name = 'BannedPasswordList'; value = '' } + ) + $LockoutValues) + } | ConvertTo-Json -Depth 10 -Compress + $null = New-GraphPostRequest -tenantid $TenantFilter -uri 'https://graph.microsoft.com/beta/settings' -type POST -body $Body + } else { + $LiveValueOf = { param($Name) "$((@($Live.values) | Where-Object { $_.name -eq $Name }).value)" } + $Body = @{ values = @(@( + @{ name = 'EnableBannedPasswordCheck'; value = (& $LiveValueOf 'EnableBannedPasswordCheck') } + @{ name = 'BannedPasswordList'; value = (& $LiveValueOf 'BannedPasswordList') } + ) + $LockoutValues) } | ConvertTo-Json -Depth 10 -Compress + $null = New-GraphPostRequest -tenantid $TenantFilter -uri "https://graph.microsoft.com/beta/settings/$($Live.id)" -type PATCH -body $Body + } + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "Applied smart lockout: $($Remediate.lockoutThreshold) attempts, $($Remediate.lockoutDurationInSeconds)s, on-prem $OnPrem/$Mode." -Sev 'Info' +} diff --git a/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineStandard.ps1 b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineStandard.ps1 index e78626cb444e6..a9c695c8deb66 100644 --- a/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineStandard.ps1 +++ b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineStandard.ps1 @@ -4,31 +4,23 @@ 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: - 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. - 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. - 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). + 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 + + -GradeOnly is the post-remediation cache verification: read and compare only, + returning { Compliant, CurrentValue, Diff } while persisting NOTHING - no history + events, no alignment writes, no alerts, no remediation. Only meaningful right after + a remediation on the same item, where conflict/unconfigured/manual cannot occur. .FUNCTIONALITY Internal #> @@ -38,27 +30,73 @@ function Invoke-CIPPBaselineStandard { [ValidateSet('run', 'compare', 'oneoff')]$Mode = 'run', $TriggeredBy = 'schedule', [switch]$Force, + [switch]$GradeOnly, $RunId ) if (-not $RunId) { $RunId = [string](New-Guid).Guid } + # Work items arrive through the durable pipeline as Hashtables, and hooks unwrap + # option objects with PSCustomObject checks - an OrderedHashtable slips past every + # one and stringifies into 'System.Management.Automation.OrderedHashtable'. One JSON + # round-trip at the boundary makes every nested variable a PSCustomObject. + if ($null -ne $Item.Variables -and $Item.Variables -isnot [System.Management.Automation.PSCustomObject]) { + $Item.Variables = ConvertTo-Json -Depth 100 -InputObject $Item.Variables | ConvertFrom-Json + } + # And unwrap the pickers' option WRAPPERS ({label, value}) - single values and array + # elements - so hooks that interpolate a variable directly get the intended value, + # never '@{label=...; value=...}'. Plain values have no .value and pass through. + if ($Item.Variables -is [System.Management.Automation.PSCustomObject]) { + foreach ($VariableProperty in $Item.Variables.PSObject.Properties) { + $VariableProperty.Value = if ($VariableProperty.Value -is [array]) { + @($VariableProperty.Value | ForEach-Object { $_.value ?? $_ }) + } else { + $VariableProperty.Value.value ?? $VariableProperty.Value + } + } + } + $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 + 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 + $Value = $Variable.Value + # The UI's pickers save option WRAPPERS ({label, value}), single or as array + # elements. Splicing a wrapper into a write sends the object (or its + # stringified '@{label=...}') to the API instead of the intended value. + if ($Value -is [array]) { + $Value = @($Value | ForEach-Object { $_.value ?? $_ }) + } else { + $Value = $Value.value ?? $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 @@ -67,21 +105,30 @@ 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 (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 }) - 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 @@ -104,41 +151,31 @@ 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) + if (-not $GradeOnly) { + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "Started `"$Label`" ($Mode) - Run $RunId" -Sev 'Info' } - # 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 } @@ -154,26 +191,22 @@ 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 }) - if ($StaleRows.Count -gt 0) { + if ($StaleRows.Count -gt 0 -and -not $GradeOnly) { try { Remove-CIPPAzDataTableEntity -Force @ResolvedTable -Entity $StaleRows } catch { Write-Information "Baselines: stale resolved-row cleanup for $($Item.Standard) on $TenantFilter failed: $($_.Exception.Message)" } } $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 @@ -182,12 +215,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 @@ -208,26 +238,25 @@ 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 + # ALL declared read caches, not just the primary: a standard whose executor + # writes to a secondary cache's object (an outbound connector under an inbound + # read.cacheType, a dynamic distro under Groups) would otherwise leave that + # cache stale after remediation, and the stale cache re-detects the fixed + # drift FOREVER - collect-on-miss never fires on collected-and-empty. + CacheType = @(@($Definition.read.requiredCaches) + @($Definition.read.cacheType) | Where-Object { $_ } | Sort-Object -Unique) } - # 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) { + if ($GradeOnly) { return $null } $Result.ExpectedValue = $null $Result.Outcome = 'Conflict' $Result.Status = 'Conflict' @@ -238,8 +267,27 @@ function Invoke-CIPPBaselineStandard { return $Result } - # 1b. Manual tasks: state lives on the resolved row; the operator completes them. + # 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 + [string]::IsNullOrEmpty("$($ConfiguredVariables.$($_.Name))") + } | ForEach-Object { $_.Name }) + if ($Unresolved.Count -gt 0) { + if ($GradeOnly) { return $null } + $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 + } + + # Manual tasks: state lives on the resolved row; the operator completes them. if ($Definition.manual) { + if ($GradeOnly) { return $null } $Manual = & $Render $Definition.manual $Item.Variables $Result.Manual = $Manual $Completed = [bool]($(try { $Prior.CurrentValue | ConvertFrom-Json } catch { $null })?.completed) @@ -248,10 +296,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 @@ -268,15 +316,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 = $_ @@ -289,7 +332,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) { @@ -308,32 +350,31 @@ 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. - # 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 - if ($CacheCollector -and $Definition.prepare) { + $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 } 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 { - $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)" @@ -344,16 +385,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 @@ -373,52 +408,55 @@ 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)" } } } - - # 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. + $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) { - $Projected | Add-Member -NotePropertyName $Property -NotePropertyValue $Current.$Property + $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 @@ -439,8 +477,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 @@ -462,7 +499,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) { @@ -475,29 +512,36 @@ 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 + # GradeOnly stops here: the verdict is the product, nothing is persisted, nothing + # is remediated. An empty read grades non-compliant so the caller retries honestly. + if ($GradeOnly) { + return [PSCustomObject]@{ + Compliant = [bool]$Compliant + CurrentValue = $Result.CurrentValue + Diff = @($Differences) + } + } + 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 @@ -512,8 +556,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 @@ -527,38 +571,32 @@ 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' } } } - # 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("$_.") } @@ -570,31 +608,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 - 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 } - 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' @@ -602,7 +636,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 = @() @@ -616,20 +650,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' } } @@ -638,6 +668,8 @@ function Invoke-CIPPBaselineStandard { return $Result } catch { Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "Baseline run failed for $($Item.Standard) on ${TenantFilter}: $($_.Exception.Message)" -Sev 'Error' + # A failed verification must never overwrite the optimistic post-remediation row. + if ($GradeOnly) { return $null } if ($Result) { $Result.Outcome = 'Error' try { Set-CIPPBaselineResult -Result $Result -Prior $Prior -RunId $RunId } catch { Write-Information "Set-CIPPBaselineResult failed: $($_.Exception.Message)" } diff --git a/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineTeamsFederationConfiguration.ps1 b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineTeamsFederationConfiguration.ps1 new file mode 100644 index 0000000000000..3f5846443abc8 --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineTeamsFederationConfiguration.ps1 @@ -0,0 +1,34 @@ +function Invoke-CIPPBaselineTeamsFederationConfiguration { + <# + .SYNOPSIS + TeamsFederationConfiguration executor: writes the federation posture. + .DESCRIPTION + One Set against the ConfigAPI with the hook's carried payload - the PUT shape + (AllowList envelope, bare arrays) differs from both the GET shape and the graded + shape, which is why the hook builds it rather than this executor deriving it. + -NoRead sends the bare properties exactly like the admin center does; the classic's + comment records that the ConfigAPI rejects the enveloped form for this write. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Remediate, + $TenantFilter, + $Current + ) + + $Payload = $Current.writePayload + if (-not $Payload) { return } + + $Parameters = @{ + Identity = 'Global' + AllowTeamsConsumer = [bool]$Payload.AllowTeamsConsumer + AllowTeamsConsumerInbound = [bool]$Payload.AllowTeamsConsumerInbound + AllowFederatedUsers = [bool]$Payload.AllowFederatedUsers + AllowedDomains = $Payload.AllowedDomains + BlockedDomains = @($Payload.BlockedDomains) + } + $null = New-TeamsRequestV2 -TenantFilter $TenantFilter -Type 'TenantFederationConfiguration' -Action Set -Parameters $Parameters -NoRead + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message 'Updated the Teams federation configuration.' -Sev 'Info' +} diff --git a/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineTeamsRequest.ps1 b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineTeamsRequest.ps1 index 4eb6e8f5fff89..476a61c4175de 100644 --- a/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineTeamsRequest.ps1 +++ b/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)) { diff --git a/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineTenantAllowBlockListTemplate.ps1 b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineTenantAllowBlockListTemplate.ps1 new file mode 100644 index 0000000000000..9ed8e55cc87b5 --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineTenantAllowBlockListTemplate.ps1 @@ -0,0 +1,47 @@ +function Invoke-CIPPBaselineTenantAllowBlockListTemplate { + <# + .SYNOPSIS + TenantAllowBlockListTemplate executor: adds the missing entries. + .DESCRIPTION + Adds ONLY the entries the hook found missing - this family is strictly additive, and + submitting an entry that already exists fails the whole batch, which is exactly why + the classic pre-filtered too. + + The action parameter is DYNAMIC: the template's listMethod ('Allow' or 'Block') + becomes the switch name on New-TenantAllowBlockListItems. Expiration is carried from + the template: NoExpiration, or the classic's fixed 45-day RemoveAfter, or the cmdlet + default when neither is set. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Remediate, + $TenantFilter, + $Current + ) + + $Missing = @($Current.missingEntries | Where-Object { -not [string]::IsNullOrWhiteSpace("$_") }) + $Template = $Current.templateBody + if ($Missing.Count -eq 0 -or -not $Template) { return } + + $ListMethod = "$($Template.listMethod)" + if ($ListMethod -notin @('Allow', 'Block')) { + throw "Tenant Allow/Block List template '$($Template.templateName)' has an invalid list method '$ListMethod'." + } + + $CmdParams = @{ + Entries = @($Missing) + ListType = "$($Template.listType)" + Notes = "$($Template.notes)" + $ListMethod = $true + } + if ($Template.NoExpiration -eq $true) { + $CmdParams.NoExpiration = $true + } elseif ($Template.RemoveAfter -eq $true) { + $CmdParams.RemoveAfter = 45 + } + + $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'New-TenantAllowBlockListItems' -cmdParams $CmdParams + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "Added $($Missing.Count) $ListMethod entries to the $($Template.listType) list from template '$($Template.templateName)'." -Sev 'Info' +} diff --git a/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineTransportRuleTemplate.ps1 b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineTransportRuleTemplate.ps1 new file mode 100644 index 0000000000000..a727e0877cf46 --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineTransportRuleTemplate.ps1 @@ -0,0 +1,53 @@ +function Invoke-CIPPBaselineTransportRuleTemplate { + <# + .SYNOPSIS + TransportRuleTemplate executor: deploys the template's transport rule. + .DESCRIPTION + New- when the rule is absent, Set- when it exists AND the baseline opted into + overwriting. That opt-in is the classic behaviour and matters: a transport rule is + frequently tuned by hand after deployment, so overwriting without being asked would + silently discard those edits. Without it an existing rule is left exactly as it is. + + The property exclusion list is carried verbatim from the classic standard - those + fields are returned by Get-TransportRule but rejected by New-/Set-TransportRule, so + passing them through fails the whole write. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Remediate, + $TenantFilter, + $Current + ) + + $Bodies = @($Current.ruleBodies | Where-Object { $_ }) + if ($Bodies.Count -eq 0) { return } + + $Deployed = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($Name in @($Current.deployedNames)) { if ($Name) { [void]$Deployed.Add("$Name") } } + + $Excluded = @('GUID', 'Comments', 'HasSenderOverride', 'ExceptIfHasSenderOverride', + 'ExceptIfMessageContainsDataClassifications', 'MessageContainsDataClassifications', 'UseLegacyRegex') + + foreach ($Body in $Bodies) { + $RuleName = "$($Body.name)" + $Parameters = @{} + foreach ($Property in $Body.PSObject.Properties) { + if ($Excluded -contains $Property.Name) { continue } + $Parameters[$Property.Name] = $Property.Value + } + + if ($Deployed.Contains($RuleName)) { + if ($Remediate.overwrite -ne $true) { + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "Transport rule '$RuleName' already exists and overwrite is off - leaving it untouched." -Sev 'Info' + continue + } + $Parameters['Identity'] = $RuleName + $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Set-TransportRule' -cmdParams $Parameters -useSystemMailbox $true + } else { + $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'New-TransportRule' -cmdParams $Parameters -useSystemMailbox $true + [void]$Deployed.Add($RuleName) + } + } +} diff --git a/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineUserSubmissions.ps1 b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineUserSubmissions.ps1 new file mode 100644 index 0000000000000..f4eeb03e810bb --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineUserSubmissions.ps1 @@ -0,0 +1,71 @@ +function Invoke-CIPPBaselineUserSubmissions { + <# + .SYNOPSIS + UserSubmissions executor: sets the report submission policy and rule posture. + .DESCRIPTION + The classic's write, whole: the policy is New-ed or Set- with the full parameter set + for the chosen posture (reporting to Microsoft, to a custom address, or off), and + the rule follows - created or updated to route to the address when one is + configured, REMOVED when reporting is being turned off while an enabled rule + remains. The fixed names are Exchange's own defaults. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Remediate, + $TenantFilter, + $Current + ) + + $State = "$($Remediate.state)" + if ($State -notin @('enable', 'disable')) { return } + $Email = "$($Current.resolvedEmail)" + + if ($State -eq 'enable' -and -not [string]::IsNullOrWhiteSpace($Email)) { + $PolicyParams = @{ + EnableReportToMicrosoft = $true + ReportJunkToCustomizedAddress = $true; ReportJunkAddresses = $Email + ReportNotJunkToCustomizedAddress = $true; ReportNotJunkAddresses = $Email + ReportPhishToCustomizedAddress = $true; ReportPhishAddresses = $Email + } + $RuleParams = @{ SentTo = $Email } + } elseif ($State -eq 'enable') { + $PolicyParams = @{ + EnableReportToMicrosoft = $true + ReportJunkToCustomizedAddress = $false; ReportJunkAddresses = $null + ReportNotJunkToCustomizedAddress = $false; ReportNotJunkAddresses = $null + ReportPhishToCustomizedAddress = $false; ReportPhishAddresses = $null + } + $RuleParams = $null + } else { + $PolicyParams = @{ + EnableReportToMicrosoft = $false + ReportJunkToCustomizedAddress = $false; ReportJunkAddresses = $null + ReportNotJunkToCustomizedAddress = $false; ReportNotJunkAddresses = $null + ReportPhishToCustomizedAddress = $false; ReportPhishAddresses = $null + } + $RuleParams = $null + } + + if ($Current.policyExists -eq $true) { + $PolicyParams['Identity'] = 'DefaultReportSubmissionPolicy' + $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Set-ReportSubmissionPolicy' -cmdParams $PolicyParams -useSystemMailbox $true + } else { + $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'New-ReportSubmissionPolicy' -cmdParams $PolicyParams -useSystemMailbox $true + } + + if ($RuleParams) { + if ($Current.ruleExists -eq $true) { + $RuleParams['Identity'] = 'DefaultReportSubmissionRule' + $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Set-ReportSubmissionRule' -cmdParams $RuleParams -useSystemMailbox $true + } else { + $RuleParams['Name'] = 'DefaultReportSubmissionRule' + $RuleParams['ReportSubmissionPolicy'] = 'DefaultReportSubmissionPolicy' + $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'New-ReportSubmissionRule' -cmdParams $RuleParams -useSystemMailbox $true + } + } elseif ($Current.ruleEnabled -eq $true) { + $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Remove-ReportSubmissionRule' -cmdParams @{ Identity = 'DefaultReportSubmissionRule' } -useSystemMailbox $true + } + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "Applied the user submissions posture ($State$(if ($Email) { ", $Email" }))." -Sev 'Info' +} diff --git a/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselinesharingDomainRestriction.ps1 b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselinesharingDomainRestriction.ps1 new file mode 100644 index 0000000000000..726685aea1ac7 --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselinesharingDomainRestriction.ps1 @@ -0,0 +1,29 @@ +function Invoke-CIPPBaselinesharingDomainRestriction { + <# + .SYNOPSIS + sharingDomainRestriction executor: writes the sharing domain restriction mode and + list. + .DESCRIPTION + One app-only PATCH: the mode plus - for the list modes - the matching domain list, + the classic's write. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Remediate, + $TenantFilter, + $Current + ) + + $Mode = "$($Remediate.mode)" + if ($Mode -notin @('none', 'allowList', 'blockList')) { return } + $Body = @{ sharingDomainRestrictionMode = $Mode } + if ($Mode -ne 'none') { + $Domains = @("$($Remediate.domains)".Split(',').Trim() | Where-Object { $_ }) + if ($Domains.Count -eq 0) { return } + if ($Mode -eq 'allowList') { $Body['sharingAllowedDomainList'] = @($Domains) } else { $Body['sharingBlockedDomainList'] = @($Domains) } + } + $null = New-GraphPostRequest -tenantid $TenantFilter -uri 'https://graph.microsoft.com/beta/admin/sharepoint/settings' -type PATCH -body ($Body | ConvertTo-Json) -AsApp $true -ContentType 'application/json' + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "Set the sharing domain restriction mode to $Mode." -Sev 'Info' +} diff --git a/Modules/CIPPCore/Public/Baselines/New-CIPPBaseline.ps1 b/Modules/CIPPCore/Public/Baselines/New-CIPPBaseline.ps1 index 79116cc8e09d8..d76a91e4afce9 100644 --- a/Modules/CIPPCore/Public/Baselines/New-CIPPBaseline.ps1 +++ b/Modules/CIPPCore/Public/Baselines/New-CIPPBaseline.ps1 @@ -125,7 +125,8 @@ function New-CIPPBaseline { scopeName = "$($Scope.scopeName)" stage = $StageNumber expectedValue = (ConvertTo-Json -Compress -Depth 100 -InputObject $Variables) - remediateEnabled = [bool]$(if ($Config -is [string]) { $true } else { $Config.remediateEnabled ?? $true }) + # A missing flag must never fail open into auto-remediation. + remediateEnabled = [bool]$(if ($Config -is [string]) { $false } else { $Config.remediateEnabled ?? $false }) alertEnabled = [bool]$(if ($Config -is [string]) { $true } else { $Config.alertEnabled ?? $true }) alertOnRemediate = [bool]$(if ($Config -is [string]) { $false } else { $Config.alertOnRemediate ?? $false }) rolloutId = "$RolloutId" diff --git a/Modules/CIPPCore/Public/Baselines/Set-CIPPBaselineTrendPoint.ps1 b/Modules/CIPPCore/Public/Baselines/Set-CIPPBaselineTrendPoint.ps1 index 26018b92a0086..1eada9eb2811d 100644 --- a/Modules/CIPPCore/Public/Baselines/Set-CIPPBaselineTrendPoint.ps1 +++ b/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/Modules/CIPPCore/Public/Baselines/Test-CIPPBaselineCacheCollected.ps1 b/Modules/CIPPCore/Public/Baselines/Test-CIPPBaselineCacheCollected.ps1 new file mode 100644 index 0000000000000..ce5505ffce51a --- /dev/null +++ b/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/Modules/CIPPCore/Public/CippQueue/Get-CIPPQueueData.ps1 b/Modules/CIPPCore/Public/CippQueue/Get-CIPPQueueData.ps1 index 0e94d7ea8de49..dd3181ae611cd 100644 --- a/Modules/CIPPCore/Public/CippQueue/Get-CIPPQueueData.ps1 +++ b/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' diff --git a/Modules/CIPPCore/Public/Compare-CIPPIntuneObject.ps1 b/Modules/CIPPCore/Public/Compare-CIPPIntuneObject.ps1 index 33c61b457596d..13f38e3a46bc7 100644 --- a/Modules/CIPPCore/Public/Compare-CIPPIntuneObject.ps1 +++ b/Modules/CIPPCore/Public/Compare-CIPPIntuneObject.ps1 @@ -85,7 +85,8 @@ function Compare-CIPPIntuneObject { 'includeDevices', 'excludeDevices', 'includeGuestOrExternalUserTypes', - 'excludeGuestOrExternalUserTypes' + 'excludeGuestOrExternalUserTypes', + 'NotifyUser' ) foreach ($pattern in $unorderedSetPatterns) { @@ -132,7 +133,40 @@ function Compare-CIPPIntuneObject { return } - if ($Object1.GetType() -ne $Object2.GetType()) { + # A JSON round-trip collapses a single-element collection to its scalar and widens + # int to long: wrapper and width differences are not value differences, and the + # type gate below would report them with identical-looking values on both sides. + # A multi-element collection against a scalar stays a real difference. + $Object1IsList = $Object1 -isnot [string] -and ($Object1 -is [Array] -or $Object1 -is [System.Collections.IList]) + $Object2IsList = $Object2 -isnot [string] -and ($Object2 -is [Array] -or $Object2 -is [System.Collections.IList]) + if ($Object1IsList -xor $Object2IsList) { + $ListSide = if ($Object1IsList) { $Object1 } else { $Object2 } + if (@($ListSide).Count -eq 1) { + Compare-ObjectsRecursively -Object1 @($Object1)[0] -Object2 @($Object2)[0] -PropertyPath $PropertyPath -Depth ($Depth + 1) -MaxDepth $MaxDepth + } else { + $result.Add([PSCustomObject]@{ + Property = $PropertyPath + ExpectedValue = (@($Object1) -join ', ') + ReceivedValue = (@($Object2) -join ', ') + }) + } + return + } + $NumericTypes = @([int], [long], [double], [decimal], [int16], [byte], [single]) + $Object1IsNumber = @($NumericTypes | Where-Object { $Object1 -is $_ }).Count -gt 0 + $Object2IsNumber = @($NumericTypes | Where-Object { $Object2 -is $_ }).Count -gt 0 + if ($Object1IsNumber -and $Object2IsNumber) { + if ($Object1 -ne $Object2) { + $result.Add([PSCustomObject]@{ + Property = $PropertyPath + ExpectedValue = $Object1 + ReceivedValue = $Object2 + }) + } + return + } + + if (-not ($Object1IsList -and $Object2IsList) -and $Object1.GetType() -ne $Object2.GetType()) { $result.Add([PSCustomObject]@{ Property = $PropertyPath ExpectedValue = $Object1 diff --git a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/New-CippCoreRequest.ps1 b/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/New-CippCoreRequest.ps1 index 27d561b4b99eb..136bf42dcc320 100644 --- a/Modules/CIPPCore/Public/Entrypoints/HTTP Functions/New-CippCoreRequest.ps1 +++ b/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]@{} @@ -135,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 @@ -162,7 +172,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/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-AuditLogIngestionV2.ps1 b/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-AuditLogIngestionV2.ps1 index ca26d3e1ca6b4..0087045235e47 100644 --- a/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-AuditLogIngestionV2.ps1 +++ b/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/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-AuditLogPlannerV2.ps1 b/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-AuditLogPlannerV2.ps1 index 4e8ad995b155b..30b3d8624c4c4 100644 --- a/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-AuditLogPlannerV2.ps1 +++ b/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/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-AuditLogSearchCreation.ps1 b/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-AuditLogSearchCreation.ps1 index f368e083ff43a..656fd5282b9bb 100644 --- a/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-AuditLogSearchCreation.ps1 +++ b/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/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-AuditLogSearchCreationV2.ps1 b/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-AuditLogSearchCreationV2.ps1 index e31783940a6e6..034cde6f5868b 100644 --- a/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-AuditLogSearchCreationV2.ps1 +++ b/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 @@ -20,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 @@ -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/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-CIPPBaselineOrchestrator.ps1 b/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-CIPPBaselineOrchestrator.ps1 index dd7b8e280c0b5..c0ed50de010c4 100644 --- a/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-CIPPBaselineOrchestrator.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-CIPPBaselineOrchestrator.ps1 @@ -117,6 +117,10 @@ function Start-CIPPBaselineOrchestrator { Batch = @($Batch) OrchestratorName = "BaselineRun_$Mode" SkipLog = $false + # Right under the audit log pipeline (P2) and ahead of the default 4: a drift + # check racing a fleet-wide report sweep must not sit behind it for hours, and + # remediation doubly so. + Priority = 3 # After every check has run, refresh ONLY the caches remediations wrote to - # otherwise the next run re-reads stale data and re-detects fixed drift. PostExecution = @{ diff --git a/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-CIPPOrchestrator.ps1 b/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-CIPPOrchestrator.ps1 index 4e07855b361da..b03862179a2a7 100644 --- a/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-CIPPOrchestrator.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-CIPPOrchestrator.ps1 @@ -100,15 +100,62 @@ function Start-CIPPOrchestrator { throw } - Write-Information "Craft: Queuing orchestrator '$OrchestratorName' ($TaskCount tasks$(if ($PostExecFunctionName) { ", PostExec: $PostExecFunctionName" }))" - [Craft.Services.OrchestratorBridge]::QueueOrchestrationFromFile( - $OrchestratorName, - $BatchPath, - 4, - $PostExecFunctionName, - $PostExecParametersJson, - $InputObject.Reference - ) + # $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, 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 = 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 } + } + $Priority = [int]$Priority + } + + # 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" } diff --git a/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-UserTasksOrchestrator.ps1 b/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-UserTasksOrchestrator.ps1 index 14c29bc7e4a62..131cf714d7cbb 100644 --- a/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-UserTasksOrchestrator.ps1 +++ b/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() @@ -250,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')) { @@ -299,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/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-TableCleanup.ps1 b/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-TableCleanup.ps1 index 69879bc6bc841..ef2c70496461b 100644 --- a/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-TableCleanup.ps1 +++ b/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/Modules/CIPPCore/Public/Functions/Add-CIPPQuarantineMessageProperties.ps1 b/Modules/CIPPCore/Public/Functions/Add-CIPPQuarantineMessageProperties.ps1 new file mode 100644 index 0000000000000..f039777f000f7 --- /dev/null +++ b/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/Modules/CIPPCore/Public/Functions/Get-CIPPVersionHistory.ps1 b/Modules/CIPPCore/Public/Functions/Get-CIPPVersionHistory.ps1 new file mode 100644 index 0000000000000..564472aeed78d --- /dev/null +++ b/Modules/CIPPCore/Public/Functions/Get-CIPPVersionHistory.ps1 @@ -0,0 +1,37 @@ +function Get-CIPPVersionHistory { + <# + .SYNOPSIS + Returns recorded version-transition events, newest first. + .DESCRIPTION + Reads the VersionHistory table written by Update-CIPPVersionHistory at warmup and + returns the transitions (PreviousVersion, NewVersion, image tags, RecordedAt), + newest first. Returns an empty array when nothing has been recorded or the table + cannot be read - the endpoints that surface this treat history as optional + decoration, so a storage hiccup must not fail them. + .PARAMETER Last + Maximum number of events to return. Default 25. + .FUNCTIONALITY + Internal + .EXAMPLE + Get-CIPPVersionHistory -Last 10 + #> + [CmdletBinding()] + param([int]$Last = 25) + + try { + $Table = Get-CIPPTable -TableName 'VersionHistory' + $Events = Get-CIPPAzDataTableEntity @Table -Filter "PartitionKey eq 'History'" + return @($Events | Sort-Object -Property RecordedAt -Descending | Select-Object -First $Last | ForEach-Object { + [PSCustomObject]@{ + PreviousVersion = $_.PreviousVersion + NewVersion = $_.NewVersion + PreviousImageTag = $_.PreviousImageTag + ImageTag = $_.ImageTag + RecordedAt = $_.RecordedAt + } + }) + } catch { + Write-Information "Could not read version history: $($_.Exception.Message)" + return @() + } +} diff --git a/Modules/CIPPCore/Public/Functions/Update-CIPPVersionHistory.ps1 b/Modules/CIPPCore/Public/Functions/Update-CIPPVersionHistory.ps1 new file mode 100644 index 0000000000000..f18e07b812c18 --- /dev/null +++ b/Modules/CIPPCore/Public/Functions/Update-CIPPVersionHistory.ps1 @@ -0,0 +1,93 @@ +function Update-CIPPVersionHistory { + <# + .SYNOPSIS + Records a version-transition event when the instance starts on a different build. + .DESCRIPTION + Sync-CippContainerUpdateState notices a restart onto a different build, but only uses + that to reconcile the update-available flag - the old version and the moment of the + change are discarded. This warmup step persists them: the last-known version lives in + a pointer row, and on mismatch with the running APP_VERSION a transition event + (previous version, new version, image tags, UTC timestamp) is appended to the + VersionHistory table as durable, queryable history. + + Warmup runs on every node, so concurrent runs must collapse to one event: the event + RowKey is derived from the previous and new version, making the append idempotent per + transition - nodes racing through warmup upsert the same row rather than each adding + a duplicate. A repeat of the same transition (downgrade and back) updates the existing + event instead of duplicating it. + + The first run on a fresh instance records the pointer only; with no known previous + version an event would be a guess. + + Never throws; warmup steps are soft-fail by design. + .FUNCTIONALITY + Internal + .EXAMPLE + Update-CIPPVersionHistory + #> + [CmdletBinding()] + param() + + $State = [PSCustomObject]@{ + RunningVersion = $env:APP_VERSION + PreviousVersion = $null + Recorded = $false + Reason = $null + } + + try { + if ([string]::IsNullOrWhiteSpace($env:APP_VERSION)) { + $State.Reason = 'APP_VERSION is not set - nothing to record' + Write-Information "[Version-History] $($State.Reason)" + return $State + } + + $RunningVersion = [string]$env:APP_VERSION + $ImageTag = [string]($env:IMAGE_TAG ?? '') + + $Table = Get-CIPPTable -TableName 'VersionHistory' + $Pointer = Get-CIPPAzDataTableEntity @Table -Filter "PartitionKey eq 'Version' and RowKey eq 'Current'" | Select-Object -First 1 + $State.PreviousVersion = $Pointer.Version + + if ($Pointer -and $Pointer.Version -eq $RunningVersion) { + $State.Reason = "Still on $RunningVersion - no transition to record" + Write-Information "[Version-History] $($State.Reason)" + return $State + } + + $Now = [DateTime]::UtcNow.ToString('yyyy-MM-ddTHH:mm:ssZ') + if (-not [string]::IsNullOrWhiteSpace($Pointer.Version)) { + # Table keys cannot contain / \ # ? or control characters; versions are semver so + # this is belt-and-braces against a malformed APP_VERSION. + $RowKey = ('{0}_to_{1}' -f $Pointer.Version, $RunningVersion) -replace '[/\\#?\u0000-\u001f\u007f-\u009f]', '-' + Add-CIPPAzDataTableEntity @Table -Entity @{ + PartitionKey = 'History' + RowKey = $RowKey + PreviousVersion = [string]$Pointer.Version + NewVersion = $RunningVersion + PreviousImageTag = [string]($Pointer.ImageTag ?? '') + ImageTag = $ImageTag + RecordedAt = $Now + } -Force | Out-Null + $State.Recorded = $true + $State.Reason = "Recorded version transition '$($Pointer.Version)' -> '$RunningVersion' (tag: $ImageTag)" + } else { + $State.Reason = "No previous version on record - storing '$RunningVersion' as the baseline" + } + + Add-CIPPAzDataTableEntity @Table -Entity @{ + PartitionKey = 'Version' + RowKey = 'Current' + Version = $RunningVersion + ImageTag = $ImageTag + RecordedAt = $Now + } -Force | Out-Null + + Write-Information "[Version-History] $($State.Reason)" + } catch { + $State.Reason = "Version history recording failed (non-fatal): $($_.Exception.Message)" + Write-Information "[Version-History] $($State.Reason)" + } + + return $State +} diff --git a/Modules/CIPPCore/Public/Get-CIPPDomainAnalyser.ps1 b/Modules/CIPPCore/Public/Get-CIPPDomainAnalyser.ps1 index f2494efa09dbf..87c6883e87472 100644 --- a/Modules/CIPPCore/Public/Get-CIPPDomainAnalyser.ps1 +++ b/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/Modules/CIPPCore/Public/Get-CIPPGeoIPLocationBatch.ps1 b/Modules/CIPPCore/Public/Get-CIPPGeoIPLocationBatch.ps1 index 6d60d71fe1aa8..60285d005b394 100644 --- a/Modules/CIPPCore/Public/Get-CIPPGeoIPLocationBatch.ps1 +++ b/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/Modules/CIPPCore/Public/Get-CIPPGroupType.ps1 b/Modules/CIPPCore/Public/Get-CIPPGroupType.ps1 new file mode 100644 index 0000000000000..509a70585152f --- /dev/null +++ b/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/Modules/CIPPCore/Public/Get-CIPPGuestUsersReport.ps1 b/Modules/CIPPCore/Public/Get-CIPPGuestUsersReport.ps1 new file mode 100644 index 0000000000000..bab411a0b6a6b --- /dev/null +++ b/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/Modules/CIPPCore/Public/Get-CIPPIntuneAssignmentTarget.ps1 b/Modules/CIPPCore/Public/Get-CIPPIntuneAssignmentTarget.ps1 index 155867ffa82a9..0d92f92cafbf0 100644 --- a/Modules/CIPPCore/Public/Get-CIPPIntuneAssignmentTarget.ps1 +++ b/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/Modules/CIPPCore/Public/Get-CIPPSPOAdminListData.ps1 b/Modules/CIPPCore/Public/Get-CIPPSPOAdminListData.ps1 new file mode 100644 index 0000000000000..97e35811bb89f --- /dev/null +++ b/Modules/CIPPCore/Public/Get-CIPPSPOAdminListData.ps1 @@ -0,0 +1,197 @@ +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 ListName + Optional RenderAdminListData listName (sibling of parameters). Omit for the Active sites default. + Unlicensed OneDrive management uses DO_NOT_DELETE_SPLIST_TENANTADMIN_ALL_SITES_AGGREGATED_SITECOLLECTIONS. + + .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]$ListName, + + [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 } + if (-not [string]::IsNullOrWhiteSpace($ListName)) { + $BodyObj['listName'] = $ListName + } + + $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/Modules/CIPPCore/Public/Get-CIPPTestResultsTenants.ps1 b/Modules/CIPPCore/Public/Get-CIPPTestResultsTenants.ps1 index 9c1c267925ff7..9ad2092151408 100644 --- a/Modules/CIPPCore/Public/Get-CIPPTestResultsTenants.ps1 +++ b/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/Modules/CIPPCore/Public/Get-CippTestDataFieldManifest.ps1 b/Modules/CIPPCore/Public/Get-CippTestDataFieldManifest.ps1 index a2e97e2f614cf..b0598ac101d4d 100644 --- a/Modules/CIPPCore/Public/Get-CippTestDataFieldManifest.ps1 +++ b/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/Modules/CIPPCore/Public/GraphHelper/Add-CippQueueMessage.ps1 b/Modules/CIPPCore/Public/GraphHelper/Add-CippQueueMessage.ps1 index 1df44a16cb0ee..abb87f07468f0 100644 --- a/Modules/CIPPCore/Public/GraphHelper/Add-CippQueueMessage.ps1 +++ b/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,20 @@ function Add-CippQueueMessage { try { if ($env:CIPPNG -eq 'true') { + if ($null -eq $Priority) { + # 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 - [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 } diff --git a/Modules/CIPPCore/Public/GraphHelper/Get-CippSamPermissions.ps1 b/Modules/CIPPCore/Public/GraphHelper/Get-CippSamPermissions.ps1 index 4b01cda92a402..8391eaa00b980 100644 --- a/Modules/CIPPCore/Public/GraphHelper/Get-CippSamPermissions.ps1 +++ b/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/Modules/CIPPCore/Public/GraphHelper/Get-Tenants.ps1 b/Modules/CIPPCore/Public/GraphHelper/Get-Tenants.ps1 index 3468f3d7df952..4c15c67838398 100644 --- a/Modules/CIPPCore/Public/GraphHelper/Get-Tenants.ps1 +++ b/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/Modules/CIPPCore/Public/Invoke-CIPPCATemplateBatch.ps1 b/Modules/CIPPCore/Public/Invoke-CIPPCATemplateBatch.ps1 index 3d5ae1633f994..b5f031869ddcb 100644 --- a/Modules/CIPPCore/Public/Invoke-CIPPCATemplateBatch.ps1 +++ b/Modules/CIPPCore/Public/Invoke-CIPPCATemplateBatch.ps1 @@ -94,6 +94,11 @@ function Invoke-CIPPCATemplateBatch { $TemplateValue = $t.Settings.TemplateList.value $Filter = "PartitionKey eq 'CATemplate' and RowKey eq '$TemplateValue'" $JSON = (Get-CippAzDataTableEntity @Table -Filter $Filter).JSON + # Resolve custom variables once at load: the compare helper, the dependency + # reconciliation objects and the deploy RawJSON must all see the same resolved + # values, or the DependencyMap ends up keyed by raw %tokens% that the (resolved) + # policies can never look up. + if ($JSON) { $JSON = Get-CIPPTextReplacement -TenantFilter $Tenant -Text $JSON -EscapeForJson } if (-not $JSON) { Write-LogMessage -API 'Standards' -tenant $Tenant -message "Conditional Access template '$($t.Settings.TemplateList.label)' ($TemplateValue) could not be loaded from the template store - skipping." -Sev 'Error' Set-CIPPStandardsCompareField -FieldName "standards.ConditionalAccessTemplate.$TemplateValue" -FieldValue "Template '$($t.Settings.TemplateList.label)' could not be loaded from the template store." -Tenant $Tenant diff --git a/Modules/CIPPCore/Public/Invoke-CIPPDBCacheCollection.ps1 b/Modules/CIPPCore/Public/Invoke-CIPPDBCacheCollection.ps1 index bc3eeaf918458..3c761f84c784d 100644 --- a/Modules/CIPPCore/Public/Invoke-CIPPDBCacheCollection.ps1 +++ b/Modules/CIPPCore/Public/Invoke-CIPPDBCacheCollection.ps1 @@ -55,6 +55,7 @@ function Invoke-CIPPDBCacheCollection { 'AdminConsentRequestPolicy' 'AuthorizationPolicy' 'AuthenticationMethodsPolicy' + 'SecurityDefaults' 'DeviceSettings' 'DirectoryRecommendations' 'CrossTenantAccessPolicy' @@ -69,6 +70,20 @@ function Invoke-CIPPDBCacheCollection { 'AppRoleAssignments' 'LicenseOverview' 'BitlockerKeys' + 'AdminReportSettings' + 'PeopleInsights' + 'Pronouns' + 'NamePronunciation' + 'PhotoUpdateSettings' + 'OrganizationBranding' + 'HomeRealmDiscoveryPolicy' + 'MobileDeviceManagementPolicies' + 'PermissionGrantPolicies' + 'CopilotAdminSettings' + 'CopilotPolicySettings' + 'SelfServicePurchaseProducts' + 'MoeraDmarc' + 'DomainAnalyser' ) ExchangeConfig = @( 'ExoAntiPhishPolicies' @@ -92,7 +107,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' @@ -127,10 +158,20 @@ function Invoke-CIPPDBCacheCollection { 'DetectedApps' 'IntuneAppInstallStatus' 'MDEOnboarding' + 'AutopilotDeploymentProfiles' + 'DeviceEnrollmentConfigurations' + 'IntuneDeviceManagementSettings' + 'IntuneDataProcessorOnboarding' + 'IntuneBrandingProfile' + 'ManagedDeviceCleanupRules' ) Compliance = @( 'SensitivityLabels' 'DlpCompliancePolicies' + 'ComplianceRetentionPolicies' + 'ComplianceRetentionRules' + 'ExoDlpSensitiveInfoTypes' + 'ExoLabels' ) CopilotUsage = @( 'CopilotUsageUserDetail' @@ -141,6 +182,7 @@ function Invoke-CIPPDBCacheCollection { SharePoint = @( 'SPOTenant' 'SPOTenantSyncClientRestriction' + 'SharePointAdminSettings' 'SharePointSiteUsage' 'SiteActivity' 'OneDriveUsage' @@ -156,6 +198,7 @@ function Invoke-CIPPDBCacheCollection { 'Teams' 'TeamsActivity' 'TeamsVoice' + 'TeamsResourceAccounts' ) Defender = @( 'DefenderCVEs' diff --git a/Modules/CIPPCore/Public/Invoke-CIPPMDEOffboard.ps1 b/Modules/CIPPCore/Public/Invoke-CIPPMDEOffboard.ps1 new file mode 100644 index 0000000000000..1e459717748ab --- /dev/null +++ b/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/Modules/CIPPCore/Public/Invoke-CIPPOffboardingJob.ps1 b/Modules/CIPPCore/Public/Invoke-CIPPOffboardingJob.ps1 index c4f40e526453a..3174219365396 100644 --- a/Modules/CIPPCore/Public/Invoke-CIPPOffboardingJob.ps1 +++ b/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 @@ -202,6 +208,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/Modules/CIPPCore/Public/New-CIPPCAPolicy.ps1 b/Modules/CIPPCore/Public/New-CIPPCAPolicy.ps1 index 3a151d084ee65..74e613aa5f633 100644 --- a/Modules/CIPPCore/Public/New-CIPPCAPolicy.ps1 +++ b/Modules/CIPPCore/Public/New-CIPPCAPolicy.ps1 @@ -99,6 +99,14 @@ function New-CIPPCAPolicy { return $UserIds } + # Custom variables must be resolved before the dependency lookups below. The only other + # substitution point is the Graph request layer (New-GraphPOSTRequest), which runs after the + # named-location / auth-strength / auth-context name matching - leaving those comparing raw + # %tokens% against real display names, so a location that exists is never matched: a duplicate + # is created on every deploy and the policy body keeps the token, which Graph then rejects + # (1040: NamedLocation with id does not exist in the directory). + $RawJSON = Get-CIPPTextReplacement -TenantFilter $TenantFilter -Text $RawJSON -EscapeForJson + $displayName = ($RawJSON | ConvertFrom-Json).displayName $JSONobj = $RawJSON | ConvertFrom-Json | Select-Object * -ExcludeProperty ID, GUID, *time* diff --git a/Modules/CIPPCore/Public/New-CIPPDeviceAction.ps1 b/Modules/CIPPCore/Public/New-CIPPDeviceAction.ps1 index 33b42b971a78d..e54f728212b03 100644 --- a/Modules/CIPPCore/Public/New-CIPPDeviceAction.ps1 +++ b/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/Modules/CIPPCore/Public/New-CIPPSPOAdminListViewXml.ps1 b/Modules/CIPPCore/Public/New-CIPPSPOAdminListViewXml.ps1 new file mode 100644 index 0000000000000..d74f7c882045c --- /dev/null +++ b/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" +} diff --git a/Modules/CIPPCore/Public/Read-CippMimeMessage.ps1 b/Modules/CIPPCore/Public/Read-CippMimeMessage.ps1 new file mode 100644 index 0000000000000..f9028a7f8449a --- /dev/null +++ b/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/Modules/CIPPCore/Public/Remove-CIPPGroupMember.ps1 b/Modules/CIPPCore/Public/Remove-CIPPGroupMember.ps1 index 4ebf2ff4f9753..e6d02dcc13fcb 100644 --- a/Modules/CIPPCore/Public/Remove-CIPPGroupMember.ps1 +++ b/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/Modules/CIPPCore/Public/Remove-CIPPGroupOwner.ps1 b/Modules/CIPPCore/Public/Remove-CIPPGroupOwner.ps1 new file mode 100644 index 0000000000000..97433e15ccc51 --- /dev/null +++ b/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/Modules/CIPPCore/Public/Resolve-CIPPDirectoryId.ps1 b/Modules/CIPPCore/Public/Resolve-CIPPDirectoryId.ps1 new file mode 100644 index 0000000000000..1a3f57656136f --- /dev/null +++ b/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/Modules/CIPPCore/Public/Set-CIPPAuthenticationPolicy.ps1 b/Modules/CIPPCore/Public/Set-CIPPAuthenticationPolicy.ps1 index 08f5cf2a942a0..7eb6edbd418db 100644 --- a/Modules/CIPPCore/Public/Set-CIPPAuthenticationPolicy.ps1 +++ b/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)" } } @@ -74,12 +88,15 @@ function Set-CIPPAuthenticationPolicy { $CurrentInfo.featureSettings.companionAppAllowedState.state = $MicrosoftAuthenticatorCompanionApp $AuthChanges.Add("companion app set to $MicrosoftAuthenticatorCompanionApp") } - # numberMatchingRequiredState is permanently enabled by Microsoft and can no longer be toggled - $CurrentInfo.featureSettings.PSObject.Properties.Remove('numberMatchingRequiredState') if ($AuthChanges.Count -gt 0) { $OptionalLogMessage = "with $($AuthChanges -join ', ')" } } + # numberMatchingRequiredState is permanently enabled by Microsoft and can no + # longer be toggled - Graph rejects ANY write that echoes it back. This must + # strip on BOTH paths: it previously only ran on enable, so every DISABLE + # echoed the deprecated field and failed. + $CurrentInfo.featureSettings.PSObject.Properties.Remove('numberMatchingRequiredState') } # SMS diff --git a/Modules/CIPPCore/Public/Set-CIPPDefaultAPDeploymentProfile.ps1 b/Modules/CIPPCore/Public/Set-CIPPDefaultAPDeploymentProfile.ps1 index 9bc288f30f01a..6c51b141b8808 100644 --- a/Modules/CIPPCore/Public/Set-CIPPDefaultAPDeploymentProfile.ps1 +++ b/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/Modules/CIPPCore/Public/Set-CIPPIntunePolicy.ps1 b/Modules/CIPPCore/Public/Set-CIPPIntunePolicy.ps1 index c4580f5b99a97..35a366b83c677 100644 --- a/Modules/CIPPCore/Public/Set-CIPPIntunePolicy.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPIntunePolicy.ps1 @@ -31,6 +31,11 @@ function Set-CIPPIntunePolicy { if ([string]::IsNullOrWhiteSpace($RawJSON)) { throw "The template contains no policy JSON (RAWJson is empty). The stored template row is corrupt, or a same-named duplicate row shadowed the one selected. Delete the broken copy of this template and recreate it." } + # Tenants with Intune multi-admin approval reject policy writes outright unless a + # justification header rides along; with it, the write either applies directly or + # lands as an approval request for another admin to action. Harmless on tenants + # without approval enabled. + $ApprovalHeaders = @{ 'x-msft-approval-justification' = 'CIPP policy deployment: apply the selected template.' } switch ($TemplateType) { 'AppProtection' { $PlatformType = 'deviceAppManagement' @@ -53,11 +58,11 @@ function Set-CIPPIntunePolicy { if ($FuzzyResult.MatchType -eq 'fuzzy') { Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message "Fuzzy matched policy '$($FuzzyResult.OriginalName)' for template '$DisplayName' (distance=$($FuzzyResult.Distance))" -Sev Info } - $CreateRequest = New-GraphPOSTRequest -uri "https://graph.microsoft.com/beta/$PlatformType/$TemplateTypeURL/$($ExistingID.Id)" -tenantid $TenantFilter -type PATCH -body $RawJSON + $CreateRequest = New-GraphPOSTRequest -uri "https://graph.microsoft.com/beta/$PlatformType/$TemplateTypeURL/$($ExistingID.Id)" -tenantid $TenantFilter -type PATCH -body $RawJSON -AddedHeaders $ApprovalHeaders $CreateRequest = $FuzzyResult.Policy } else { $PostType = 'added' - $CreateRequest = New-GraphPOSTRequest -uri "https://graph.microsoft.com/beta/$PlatformType/$TemplateTypeURL" -tenantid $TenantFilter -type POST -body $RawJSON + $CreateRequest = New-GraphPOSTRequest -uri "https://graph.microsoft.com/beta/$PlatformType/$TemplateTypeURL" -tenantid $TenantFilter -type POST -body $RawJSON -AddedHeaders $ApprovalHeaders } } 'AppConfiguration' { @@ -76,14 +81,14 @@ function Set-CIPPIntunePolicy { } $PolicyFile = $PolicyFile | Select-Object * -ExcludeProperty id, createdDateTime, lastModifiedDateTime, version, '@odata.context', targetedMobileApps $RawJSON = ConvertTo-Json -InputObject $PolicyFile -Depth 20 -Compress - $CreateRequest = New-GraphPOSTRequest -uri "https://graph.microsoft.com/beta/$PlatformType/$TemplateTypeURL/$($ExistingID.Id)" -tenantid $TenantFilter -type PATCH -body $RawJSON + $CreateRequest = New-GraphPOSTRequest -uri "https://graph.microsoft.com/beta/$PlatformType/$TemplateTypeURL/$($ExistingID.Id)" -tenantid $TenantFilter -type PATCH -body $RawJSON -AddedHeaders $ApprovalHeaders Write-LogMessage -headers $Headers -API $APIName -tenant $($TenantFilter) -message "Updated policy $($DisplayName) to template defaults" -Sev Info $CreateRequest = $FuzzyResult.Policy } else { $PostType = 'added' $PolicyFile = $PolicyFile | Select-Object * -ExcludeProperty id, createdDateTime, lastModifiedDateTime, version, '@odata.context' $RawJSON = ConvertTo-Json -InputObject $PolicyFile -Depth 20 -Compress - $CreateRequest = New-GraphPOSTRequest -uri "https://graph.microsoft.com/beta/$PlatformType/$TemplateTypeURL" -tenantid $TenantFilter -type POST -body $RawJSON + $CreateRequest = New-GraphPOSTRequest -uri "https://graph.microsoft.com/beta/$PlatformType/$TemplateTypeURL" -tenantid $TenantFilter -type POST -body $RawJSON -AddedHeaders $ApprovalHeaders Write-LogMessage -headers $Headers -API $APIName -tenant $($TenantFilter) -message "Added policy $($DisplayName) via template" -Sev Info } } @@ -104,13 +109,13 @@ function Set-CIPPIntunePolicy { if ($FuzzyResult.MatchType -eq 'fuzzy') { Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message "Fuzzy matched policy '$($FuzzyResult.OriginalName)' for template '$DisplayName' (distance=$($FuzzyResult.Distance))" -Sev Info } - $CreateRequest = New-GraphPOSTRequest -uri "https://graph.microsoft.com/beta/$PlatformType/$TemplateTypeURL/$($ExistingID.Id)" -tenantid $TenantFilter -type PATCH -body $RawJSON + $CreateRequest = New-GraphPOSTRequest -uri "https://graph.microsoft.com/beta/$PlatformType/$TemplateTypeURL/$($ExistingID.Id)" -tenantid $TenantFilter -type PATCH -body $RawJSON -AddedHeaders $ApprovalHeaders Write-LogMessage -headers $Headers -API $APIName -tenant $($TenantFilter) -message "Updated policy $($DisplayName) to template defaults" -Sev Info $CreateRequest = $FuzzyResult.Policy } else { $RawJSON = ConvertTo-Json -InputObject $PolicyFile -Depth 20 -Compress $PostType = 'added' - $CreateRequest = New-GraphPOSTRequest -uri "https://graph.microsoft.com/beta/$PlatformType/$TemplateTypeURL" -tenantid $TenantFilter -type POST -body $RawJSON + $CreateRequest = New-GraphPOSTRequest -uri "https://graph.microsoft.com/beta/$PlatformType/$TemplateTypeURL" -tenantid $TenantFilter -type POST -body $RawJSON -AddedHeaders $ApprovalHeaders Write-LogMessage -headers $Headers -API $APIName -tenant $($TenantFilter) -message "Added policy $($DisplayName) via template" -Sev Info } } @@ -130,15 +135,15 @@ function Set-CIPPIntunePolicy { $DeleteJson | Add-Member -MemberType NoteProperty -Name 'deletedIds' -Value @($ExistingData.id) -Force $DeleteJson | Add-Member -MemberType NoteProperty -Name 'added' -Value @() -Force $DeleteJson = ConvertTo-Json -Depth 10 -InputObject $DeleteJson - $DeleteRequest = New-GraphPOSTRequest -uri "https://graph.microsoft.com/beta/$PlatformType/$TemplateTypeURL('$($ExistingID.id)')/updateDefinitionValues" -tenantid $TenantFilter -type POST -body $DeleteJson - $CreateRequest = New-GraphPOSTRequest -uri "https://graph.microsoft.com/beta/$PlatformType/$TemplateTypeURL('$($ExistingID.id)')/updateDefinitionValues" -tenantid $TenantFilter -type POST -body $RawJSON + $DeleteRequest = New-GraphPOSTRequest -uri "https://graph.microsoft.com/beta/$PlatformType/$TemplateTypeURL('$($ExistingID.id)')/updateDefinitionValues" -tenantid $TenantFilter -type POST -body $DeleteJson -AddedHeaders $ApprovalHeaders + $CreateRequest = New-GraphPOSTRequest -uri "https://graph.microsoft.com/beta/$PlatformType/$TemplateTypeURL('$($ExistingID.id)')/updateDefinitionValues" -tenantid $TenantFilter -type POST -body $RawJSON -AddedHeaders $ApprovalHeaders $CreateRequest = $FuzzyResult.Policy Write-LogMessage -headers $Headers -API $APIName -tenant $($TenantFilter) -message "Updated policy $($DisplayName) to template defaults" -Sev Info $PostType = 'edited' } else { $PostType = 'added' - $CreateRequest = New-GraphPOSTRequest -uri "https://graph.microsoft.com/beta/$PlatformType/$TemplateTypeURL" -tenantid $TenantFilter -type POST -body $CreateBody - $UpdateRequest = New-GraphPOSTRequest -uri "https://graph.microsoft.com/beta/$PlatformType/$TemplateTypeURL('$($CreateRequest.id)')/updateDefinitionValues" -tenantid $TenantFilter -type POST -body $RawJSON + $CreateRequest = New-GraphPOSTRequest -uri "https://graph.microsoft.com/beta/$PlatformType/$TemplateTypeURL" -tenantid $TenantFilter -type POST -body $CreateBody -AddedHeaders $ApprovalHeaders + $UpdateRequest = New-GraphPOSTRequest -uri "https://graph.microsoft.com/beta/$PlatformType/$TemplateTypeURL('$($CreateRequest.id)')/updateDefinitionValues" -tenantid $TenantFilter -type POST -body $RawJSON -AddedHeaders $ApprovalHeaders Write-LogMessage -headers $Headers -API $APIName -tenant $($TenantFilter) -message "Added policy $($DisplayName) to template defaults" -Sev Info } @@ -165,12 +170,12 @@ function Set-CIPPIntunePolicy { Write-LogMessage -headers $Headers -API $APIName -tenant $tenantFilter -message "Fuzzy matched policy '$($FuzzyResult.OriginalName)' for template '$DisplayName' (distance=$($FuzzyResult.Distance))" -Sev Info } Write-Host "Raw JSON is $RawJSON" - $CreateRequest = New-GraphPOSTRequest -uri "https://graph.microsoft.com/beta/$PlatformType/$TemplateTypeURL/$($ExistingID.Id)" -tenantid $tenantFilter -type PATCH -body $RawJSON + $CreateRequest = New-GraphPOSTRequest -uri "https://graph.microsoft.com/beta/$PlatformType/$TemplateTypeURL/$($ExistingID.Id)" -tenantid $tenantFilter -type PATCH -body $RawJSON -AddedHeaders $ApprovalHeaders $CreateRequest = $FuzzyResult.Policy Write-LogMessage -headers $Headers -API $APIName -tenant $($tenantFilter) -message "Updated policy $($DisplayName) to template defaults" -Sev Info } else { $PostType = 'added' - $CreateRequest = New-GraphPOSTRequest -uri "https://graph.microsoft.com/beta/$PlatformType/$TemplateTypeURL" -tenantid $tenantFilter -type POST -body $RawJSON + $CreateRequest = New-GraphPOSTRequest -uri "https://graph.microsoft.com/beta/$PlatformType/$TemplateTypeURL" -tenantid $tenantFilter -type POST -body $RawJSON -AddedHeaders $ApprovalHeaders Write-LogMessage -headers $Headers -API $APIName -tenant $($tenantFilter) -message "Added policy $($DisplayName) via template" -Sev Info } @@ -209,12 +214,12 @@ function Set-CIPPIntunePolicy { if ($FuzzyResult.MatchType -eq 'fuzzy') { Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message "Fuzzy matched policy '$($FuzzyResult.OriginalName)' for template '$DisplayName' (distance=$($FuzzyResult.Distance))" -Sev Info } - $CreateRequest = New-GraphPOSTRequest -uri "https://graph.microsoft.com/beta/$PlatformType/$TemplateTypeURL/$($ExistingID.Id)" -tenantid $TenantFilter -type PUT -body $RawJSON + $CreateRequest = New-GraphPOSTRequest -uri "https://graph.microsoft.com/beta/$PlatformType/$TemplateTypeURL/$($ExistingID.Id)" -tenantid $TenantFilter -type PUT -body $RawJSON -AddedHeaders $ApprovalHeaders $CreateRequest = $FuzzyResult.Policy $PostType = 'edited' } else { $PostType = 'added' - $CreateRequest = New-GraphPOSTRequest -uri "https://graph.microsoft.com/beta/$PlatformType/$TemplateTypeURL" -tenantid $TenantFilter -type POST -body $RawJSON + $CreateRequest = New-GraphPOSTRequest -uri "https://graph.microsoft.com/beta/$PlatformType/$TemplateTypeURL" -tenantid $TenantFilter -type POST -body $RawJSON -AddedHeaders $ApprovalHeaders Write-LogMessage -headers $Headers -API $APIName -tenant $($TenantFilter) -message "Added policy $($DisplayName) via template" -Sev Info } } @@ -233,11 +238,11 @@ function Set-CIPPIntunePolicy { Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message "Fuzzy matched policy '$($FuzzyResult.OriginalName)' for template '$DisplayName' (distance=$($FuzzyResult.Distance))" -Sev Info } Write-Host 'We are editing' - $CreateRequest = New-GraphPOSTRequest -uri "https://graph.microsoft.com/beta/$PlatformType/$TemplateTypeURL/$($ExistingID.Id)" -tenantid $TenantFilter -type PATCH -body $RawJSON + $CreateRequest = New-GraphPOSTRequest -uri "https://graph.microsoft.com/beta/$PlatformType/$TemplateTypeURL/$($ExistingID.Id)" -tenantid $TenantFilter -type PATCH -body $RawJSON -AddedHeaders $ApprovalHeaders $CreateRequest = $FuzzyResult.Policy } else { $PostType = 'added' - $CreateRequest = New-GraphPOSTRequest -uri "https://graph.microsoft.com/beta/$PlatformType/$TemplateTypeURL" -tenantid $TenantFilter -type POST -body $RawJSON + $CreateRequest = New-GraphPOSTRequest -uri "https://graph.microsoft.com/beta/$PlatformType/$TemplateTypeURL" -tenantid $TenantFilter -type POST -body $RawJSON -AddedHeaders $ApprovalHeaders Write-LogMessage -headers $Headers -API $APIName -tenant $($TenantFilter) -message "Added policy $($DisplayName) via template" -Sev Info } } @@ -256,12 +261,12 @@ function Set-CIPPIntunePolicy { Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message "Fuzzy matched policy '$($FuzzyResult.OriginalName)' for template '$DisplayName' (distance=$($FuzzyResult.Distance))" -Sev Info } Write-Host 'We are editing' - $CreateRequest = New-GraphPOSTRequest -uri "https://graph.microsoft.com/beta/$PlatformType/$TemplateTypeURL/$($ExistingID.Id)" -tenantid $TenantFilter -type PATCH -body $RawJSON + $CreateRequest = New-GraphPOSTRequest -uri "https://graph.microsoft.com/beta/$PlatformType/$TemplateTypeURL/$($ExistingID.Id)" -tenantid $TenantFilter -type PATCH -body $RawJSON -AddedHeaders $ApprovalHeaders $CreateRequest = $FuzzyResult.Policy } else { $PostType = 'added' - $CreateRequest = New-GraphPOSTRequest -uri "https://graph.microsoft.com/beta/$PlatformType/$TemplateTypeURL" -tenantid $TenantFilter -type POST -body $RawJSON + $CreateRequest = New-GraphPOSTRequest -uri "https://graph.microsoft.com/beta/$PlatformType/$TemplateTypeURL" -tenantid $TenantFilter -type POST -body $RawJSON -AddedHeaders $ApprovalHeaders Write-LogMessage -headers $Headers -API $APIName -tenant $($TenantFilter) -message "Added policy $($DisplayName) via template" -Sev Info } } @@ -280,11 +285,11 @@ function Set-CIPPIntunePolicy { Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message "Fuzzy matched policy '$($FuzzyResult.OriginalName)' for template '$DisplayName' (distance=$($FuzzyResult.Distance))" -Sev Info } Write-Host 'We are editing' - $CreateRequest = New-GraphPOSTRequest -uri "https://graph.microsoft.com/beta/$PlatformType/$TemplateTypeURL/$($ExistingID.Id)" -tenantid $TenantFilter -type PATCH -body $RawJSON + $CreateRequest = New-GraphPOSTRequest -uri "https://graph.microsoft.com/beta/$PlatformType/$TemplateTypeURL/$($ExistingID.Id)" -tenantid $TenantFilter -type PATCH -body $RawJSON -AddedHeaders $ApprovalHeaders $CreateRequest = $FuzzyResult.Policy } else { $PostType = 'added' - $CreateRequest = New-GraphPOSTRequest -uri "https://graph.microsoft.com/beta/$PlatformType/$TemplateTypeURL" -tenantid $TenantFilter -type POST -body $RawJSON + $CreateRequest = New-GraphPOSTRequest -uri "https://graph.microsoft.com/beta/$PlatformType/$TemplateTypeURL" -tenantid $TenantFilter -type POST -body $RawJSON -AddedHeaders $ApprovalHeaders Write-LogMessage -headers $Headers -API $APIName -tenant $($TenantFilter) -message "Added policy $($DisplayName) via template" -Sev Info } } @@ -303,11 +308,11 @@ function Set-CIPPIntunePolicy { Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message "Fuzzy matched policy '$($FuzzyResult.OriginalName)' for template '$DisplayName' (distance=$($FuzzyResult.Distance))" -Sev Info } Write-Host 'We are editing' - $CreateRequest = New-GraphPOSTRequest -uri "https://graph.microsoft.com/beta/$PlatformType/$TemplateTypeURL/$($ExistingID.Id)" -tenantid $TenantFilter -type PATCH -body $RawJSON + $CreateRequest = New-GraphPOSTRequest -uri "https://graph.microsoft.com/beta/$PlatformType/$TemplateTypeURL/$($ExistingID.Id)" -tenantid $TenantFilter -type PATCH -body $RawJSON -AddedHeaders $ApprovalHeaders $CreateRequest = $FuzzyResult.Policy } else { $PostType = 'added' - $CreateRequest = New-GraphPOSTRequest -uri "https://graph.microsoft.com/beta/$PlatformType/$TemplateTypeURL" -tenantid $TenantFilter -type POST -body $RawJSON + $CreateRequest = New-GraphPOSTRequest -uri "https://graph.microsoft.com/beta/$PlatformType/$TemplateTypeURL" -tenantid $TenantFilter -type POST -body $RawJSON -AddedHeaders $ApprovalHeaders Write-LogMessage -headers $Headers -API $APIName -tenant $($TenantFilter) -message "Added policy $($DisplayName) via template" -Sev Info } } diff --git a/Modules/CIPPCore/Public/Set-CIPPMailboxAccess.ps1 b/Modules/CIPPCore/Public/Set-CIPPMailboxAccess.ps1 index 54a1a075c040f..08cfec45b58cf 100644 --- a/Modules/CIPPCore/Public/Set-CIPPMailboxAccess.ps1 +++ b/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/Modules/CIPPCore/Public/Set-CIPPRequirePasswordChange.ps1 b/Modules/CIPPCore/Public/Set-CIPPRequirePasswordChange.ps1 new file mode 100644 index 0000000000000..f15fe7ae717ce --- /dev/null +++ b/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/Modules/CIPPCore/Public/Test-CIPPAccessPermissions.ps1 b/Modules/CIPPCore/Public/Test-CIPPAccessPermissions.ps1 index 99845e0216632..b71b3360b019f 100644 --- a/Modules/CIPPCore/Public/Test-CIPPAccessPermissions.ps1 +++ b/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'" @@ -169,14 +218,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/Modules/CIPPCore/Public/Test-CIPPCloudPCDevice.ps1 b/Modules/CIPPCore/Public/Test-CIPPCloudPCDevice.ps1 new file mode 100644 index 0000000000000..3655e08e20144 --- /dev/null +++ b/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/Modules/CIPPCore/Public/Test-CIPPGDAPRelationships.ps1 b/Modules/CIPPCore/Public/Test-CIPPGDAPRelationships.ps1 index 24616b8be9842..afb53e296665a 100644 --- a/Modules/CIPPCore/Public/Test-CIPPGDAPRelationships.ps1 +++ b/Modules/CIPPCore/Public/Test-CIPPGDAPRelationships.ps1 @@ -100,6 +100,40 @@ function Test-CIPPGDAPRelationships { }) | Out-Null } + # GDAP invites/onboarding call the Partner Center API with delegated auth, so obtain a token for that + # audience and confirm the user_impersonation scope is in it, the same way the access permissions check + # inspects the Graph token. The token request itself fails with AADSTS65001 when the scope was never + # consented for the SAM app in the partner tenant. + try { + $PartnerCenterToken = Get-GraphToken -tenantid $env:TenantID -scope 'https://api.partnercenter.microsoft.com/.default' -returnRefresh $true -SkipCache $true + $PartnerCenterTokenDetails = Read-JwtAccessDetails -Token $PartnerCenterToken.access_token + if ($PartnerCenterTokenDetails.Scope -notcontains 'user_impersonation') { + $GDAPissues.add([PSCustomObject]@{ + Type = 'Error' + Issue = "The Partner Center API token for the SAM application does not contain the 'user_impersonation' scope. This permission is part of the CIPP defaults, so this usually indicates a wider problem with the SAM application's permissions. Run the Permissions check and repair permissions, then refresh this check. GDAP invites and tenant onboarding will fail until this is resolved." + Tenant = '*Partner Tenant' + Relationship = 'None' + Link = 'https://docs.cipp.app/setup/installation/permissions' + }) | Out-Null + } + } catch { + $ErrorMessage = Get-CippException -Exception $_ + # The Partner Center consent is part of the CIPP defaults, so a missing grant rarely happens in + # isolation - a failure here usually points at a wider SAM application or token problem. + if ($ErrorMessage.NormalizedError -match 'AADSTS65001|consent') { + $Issue = "The Partner Center API 'user_impersonation' delegated permission is not consented for the SAM application. This permission is part of the CIPP defaults, so this usually indicates a wider problem with the SAM application's permissions. Run the Permissions check and repair permissions, then refresh this check. GDAP invites and tenant onboarding will fail until this is resolved." + } else { + $Issue = "Could not obtain a Partner Center API token: $($ErrorMessage.NormalizedError). This usually indicates a problem with the SAM application or its tokens rather than the Partner Center permission itself - run the Permissions check for details. GDAP invites and tenant onboarding will fail until this is resolved." + } + $GDAPissues.add([PSCustomObject]@{ + Type = 'Error' + Issue = $Issue + Tenant = '*Partner Tenant' + Relationship = 'None' + Link = 'https://docs.cipp.app/setup/installation/permissions' + }) | Out-Null + } + # Validate that every stored GDAP role mapping still points at a group that exists in the partner tenant. # A drifted/deleted GroupId is what causes the "access container does not exist" error during onboarding. # Problems are added to GDAPIssues as errors (so they count toward the Errors total) and tagged with diff --git a/Modules/CIPPCore/Public/Test-CIPPHtmlIsEmpty.ps1 b/Modules/CIPPCore/Public/Test-CIPPHtmlIsEmpty.ps1 new file mode 100644 index 0000000000000..eb9402e78efc7 --- /dev/null +++ b/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/Modules/CIPPCore/Public/Test-CIPPOffboardingRequest.ps1 b/Modules/CIPPCore/Public/Test-CIPPOffboardingRequest.ps1 index 4938feef3dcac..0f9b81fa6b275 100644 --- a/Modules/CIPPCore/Public/Test-CIPPOffboardingRequest.ps1 +++ b/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) { @@ -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/Modules/CIPPCore/Public/Tools/Push-ExecGenerateReportBuilderReport.ps1 b/Modules/CIPPCore/Public/Tools/Push-ExecGenerateReportBuilderReport.ps1 index c63f2fe877429..ee55408feb75d 100644 --- a/Modules/CIPPCore/Public/Tools/Push-ExecGenerateReportBuilderReport.ps1 +++ b/Modules/CIPPCore/Public/Tools/Push-ExecGenerateReportBuilderReport.ps1 @@ -59,6 +59,42 @@ 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. 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 }) { + 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, $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 + } + $Names = foreach ($Assignment in $Items) { + $Name = $LicenseNamesBySkuId[([string]$Assignment.skuId).ToLowerInvariant()] + if ($Name) { $Name } + } + 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 +138,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 $Header $Row } else { '' } } [PSCustomObject]$Obj }) @@ -202,7 +238,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 $Header $Row } else { '' } } [PSCustomObject]$Obj }) diff --git a/Modules/CIPPCore/Public/Webhooks/Get-CippAuditLogLegacyCacheRow.ps1 b/Modules/CIPPCore/Public/Webhooks/Get-CippAuditLogLegacyCacheRow.ps1 new file mode 100644 index 0000000000000..e83bc4a22222e --- /dev/null +++ b/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/Modules/CIPPCore/Public/Webhooks/Invoke-CIPPWebhookProcessing.ps1 b/Modules/CIPPCore/Public/Webhooks/Invoke-CIPPWebhookProcessing.ps1 index ddc0e54de19e8..aaefa7f488a42 100644 --- a/Modules/CIPPCore/Public/Webhooks/Invoke-CIPPWebhookProcessing.ps1 +++ b/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/Modules/CIPPCore/Public/Webhooks/Push-AuditLogDownloadV2.ps1 b/Modules/CIPPCore/Public/Webhooks/Push-AuditLogDownloadV2.ps1 index ded32cbe209dd..cf8d95bfc5687 100644 --- a/Modules/CIPPCore/Public/Webhooks/Push-AuditLogDownloadV2.ps1 +++ b/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/Modules/CIPPCore/Public/Webhooks/Push-AuditLogProcessV2.ps1 b/Modules/CIPPCore/Public/Webhooks/Push-AuditLogProcessV2.ps1 index 7c52ccf09c766..c6d4a36dbaefa 100644 --- a/Modules/CIPPCore/Public/Webhooks/Push-AuditLogProcessV2.ps1 +++ b/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/Modules/CIPPCore/Public/Webhooks/Push-AuditLogProcessingBatchV2.ps1 b/Modules/CIPPCore/Public/Webhooks/Push-AuditLogProcessingBatchV2.ps1 index fad2047ad5c9d..2a1008f44cd74 100644 --- a/Modules/CIPPCore/Public/Webhooks/Push-AuditLogProcessingBatchV2.ps1 +++ b/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/Modules/CIPPCore/Public/Webhooks/Push-AuditLogTenantProcessV2.ps1 b/Modules/CIPPCore/Public/Webhooks/Push-AuditLogTenantProcessV2.ps1 index 4dae1f69ce11c..30419a81b5921 100644 --- a/Modules/CIPPCore/Public/Webhooks/Push-AuditLogTenantProcessV2.ps1 +++ b/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/Modules/CIPPCore/Public/Webhooks/Set-CippAuditLogWindowProcessed.ps1 b/Modules/CIPPCore/Public/Webhooks/Set-CippAuditLogWindowProcessed.ps1 new file mode 100644 index 0000000000000..8d813392e73f7 --- /dev/null +++ b/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/Modules/CIPPCore/Public/Webhooks/Test-CIPPAuditLogRules.ps1 b/Modules/CIPPCore/Public/Webhooks/Test-CIPPAuditLogRules.ps1 index 135297724eba8..935ff56ac883a 100644 --- a/Modules/CIPPCore/Public/Webhooks/Test-CIPPAuditLogRules.ps1 +++ b/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,128 @@ 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 ($ConfigEntry.Disabled -eq $true) { + continue + } + 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 +316,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 +513,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 +564,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 +583,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 +607,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 +639,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 +680,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 +754,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 +767,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 +927,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/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheActivityBasedTimeoutPolicy.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheActivityBasedTimeoutPolicy.ps1 new file mode 100644 index 0000000000000..dcc5e5797e034 --- /dev/null +++ b/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/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheAdminReportSettings.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheAdminReportSettings.ps1 new file mode 100644 index 0000000000000..051c944c22d34 --- /dev/null +++ b/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/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheAuthenticationMethodsPolicy.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheAuthenticationMethodsPolicy.ps1 index e1b63f15b8ca3..3f26788dbc30a 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheAuthenticationMethodsPolicy.ps1 +++ b/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/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheAutopilotDeploymentProfiles.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheAutopilotDeploymentProfiles.ps1 new file mode 100644 index 0000000000000..2dec2aee679a5 --- /dev/null +++ b/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/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheB2BManagementPolicy.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheB2BManagementPolicy.ps1 index 0a3b901af6f2e..36255c5e966de 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheB2BManagementPolicy.ps1 +++ b/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/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheComplianceRetentionPolicies.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheComplianceRetentionPolicies.ps1 new file mode 100644 index 0000000000000..a51e14ccc9bb2 --- /dev/null +++ b/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/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheComplianceRetentionRules.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheComplianceRetentionRules.ps1 new file mode 100644 index 0000000000000..712ad8a419b30 --- /dev/null +++ b/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/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheCopilotAdminSettings.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheCopilotAdminSettings.ps1 new file mode 100644 index 0000000000000..ae45e1b064372 --- /dev/null +++ b/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/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheCopilotPolicySettings.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheCopilotPolicySettings.ps1 new file mode 100644 index 0000000000000..0bdfae63d3772 --- /dev/null +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheCopilotPolicySettings.ps1 @@ -0,0 +1,66 @@ +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 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 + + .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 + + # 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. + $Values = [ordered]@{} + $PolicyIds = [ordered]@{} + foreach ($Key in $SettingMap.Keys) { + try { + $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 '$($SettingMap[$Key])': $($_.Exception.Message)" -sev Warning + $Values[$Key] = $null + $PolicyIds[$Key] = $null + } + } + $Values['policyIds'] = [PSCustomObject]$PolicyIds + + 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 + + } catch { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Failed to cache Copilot policy settings: $($_.Exception.Message)" -sev Error + } +} diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheDeviceEnrollmentConfigurations.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheDeviceEnrollmentConfigurations.ps1 new file mode 100644 index 0000000000000..fb23fea07a7c2 --- /dev/null +++ b/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/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheDevices.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheDevices.ps1 index a51fd7ce1b03b..62e7fbe041ad3 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheDevices.ps1 +++ b/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/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheDlpCompliancePolicies.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheDlpCompliancePolicies.ps1 index 13d9a99273773..2186b844e1953 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheDlpCompliancePolicies.ps1 +++ b/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/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheDomainAnalyser.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheDomainAnalyser.ps1 new file mode 100644 index 0000000000000..5cdb1a9be96c2 --- /dev/null +++ b/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/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoCASMailboxSmtpAuth.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoCASMailboxSmtpAuth.ps1 new file mode 100644 index 0000000000000..b2687103655f4 --- /dev/null +++ b/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/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoDlpSensitiveInfoTypes.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoDlpSensitiveInfoTypes.ps1 new file mode 100644 index 0000000000000..dd640f3440725 --- /dev/null +++ b/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/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoDynamicDistributionGroup.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoDynamicDistributionGroup.ps1 new file mode 100644 index 0000000000000..edc32d0044178 --- /dev/null +++ b/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/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoExternalInOutlook.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoExternalInOutlook.ps1 new file mode 100644 index 0000000000000..14236ee775a4f --- /dev/null +++ b/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/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoGlobalQuarantinePolicy.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoGlobalQuarantinePolicy.ps1 new file mode 100644 index 0000000000000..67617a57577c0 --- /dev/null +++ b/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/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoHostedConnectionFilterPolicy.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoHostedConnectionFilterPolicy.ps1 new file mode 100644 index 0000000000000..ec9531d11227e --- /dev/null +++ b/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/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoHostedContentFilterRule.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoHostedContentFilterRule.ps1 new file mode 100644 index 0000000000000..a0834481eeea7 --- /dev/null +++ b/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/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoLabels.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoLabels.ps1 new file mode 100644 index 0000000000000..93a551ae3fe96 --- /dev/null +++ b/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/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoMailContacts.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoMailContacts.ps1 new file mode 100644 index 0000000000000..424f41cf9d54a --- /dev/null +++ b/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/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoMailboxPlans.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoMailboxPlans.ps1 new file mode 100644 index 0000000000000..8e113ae39485c --- /dev/null +++ b/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/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoOMEConfiguration.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoOMEConfiguration.ps1 new file mode 100644 index 0000000000000..0d5524c56d747 --- /dev/null +++ b/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/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoOutboundConnector.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoOutboundConnector.ps1 new file mode 100644 index 0000000000000..0253c3d512a5f --- /dev/null +++ b/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/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoPhishSimConfig.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoPhishSimConfig.ps1 new file mode 100644 index 0000000000000..37f4711d40a19 --- /dev/null +++ b/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/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoRetentionPolicies.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoRetentionPolicies.ps1 new file mode 100644 index 0000000000000..d3cfbacb692a4 --- /dev/null +++ b/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/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoRetentionPolicyTags.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoRetentionPolicyTags.ps1 new file mode 100644 index 0000000000000..5f6ec11d94d73 --- /dev/null +++ b/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/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoRoleAssignmentPolicy.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoRoleAssignmentPolicy.ps1 new file mode 100644 index 0000000000000..93b57c264e616 --- /dev/null +++ b/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/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoTeamsProtectionPolicy.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoTeamsProtectionPolicy.ps1 new file mode 100644 index 0000000000000..1b32996aa916c --- /dev/null +++ b/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/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoTenantAllowBlockListSpoofItems.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheExoTenantAllowBlockListSpoofItems.ps1 new file mode 100644 index 0000000000000..3522d8a10a292 --- /dev/null +++ b/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/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheFormsSettings.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheFormsSettings.ps1 new file mode 100644 index 0000000000000..b0a2389da49b7 --- /dev/null +++ b/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/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheGuests.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheGuests.ps1 index 42fadf461acd9..a9345b5979267 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheGuests.ps1 +++ b/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/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheHomeRealmDiscoveryPolicy.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheHomeRealmDiscoveryPolicy.ps1 new file mode 100644 index 0000000000000..36d23a0db6299 --- /dev/null +++ b/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/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheIntuneAppleUserInitiatedEnrollmentProfiles.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheIntuneAppleUserInitiatedEnrollmentProfiles.ps1 new file mode 100644 index 0000000000000..5b7f149e15b3a --- /dev/null +++ b/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/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheIntuneApplications.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheIntuneApplications.ps1 index 8594bc640137d..4218dfdc137ea 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheIntuneApplications.ps1 +++ b/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/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheIntuneBrandingProfile.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheIntuneBrandingProfile.ps1 new file mode 100644 index 0000000000000..60c054954e2bd --- /dev/null +++ b/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/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheIntuneConfigurationPolicies.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheIntuneConfigurationPolicies.ps1 new file mode 100644 index 0000000000000..36ba1b935a8c8 --- /dev/null +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheIntuneConfigurationPolicies.ps1 @@ -0,0 +1,40 @@ +function Set-CIPPDBCacheIntuneConfigurationPolicies { + <# + .SYNOPSIS + Caches Intune settings catalog policies (with settings and assignments) for a tenant. + + .DESCRIPTION + Thin single-family collector for the IntuneConfigurationPolicies 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 - the umbrella cannot be named after every type it writes. + Same URI as the umbrella's family entry, so both writers produce the same row shape. + + .PARAMETER TenantFilter + The tenant to cache settings catalog policies for + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter, + [string]$QueueId + ) + + try { + $TestResult = Test-CIPPStandardLicense -StandardName 'IntuneConfigurationPoliciesCache' -TenantFilter $TenantFilter -Preset Intune -SkipLog + if ($TestResult -eq $false) { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Tenant does not have Intune license, skipping configuration policies cache' -sev Debug + return + } + + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching Intune configuration policies' -sev Debug + $Policies = New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/deviceManagement/configurationPolicies?$expand=assignments,settings&$top=1000' -tenantid $TenantFilter + if (-not $Policies) { $Policies = @() } + + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'IntuneConfigurationPolicies' -Data @($Policies) -AddCount -ClearOnEmpty + + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Cached $(($Policies | Measure-Object).Count) configuration policies" -sev Debug + } catch { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Failed to cache configuration policies: $($_.Exception.Message)" -sev Error -LogData (Get-CippException -Exception $_) + } +} diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheIntuneDataProcessorOnboarding.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheIntuneDataProcessorOnboarding.ps1 new file mode 100644 index 0000000000000..904e274db725d --- /dev/null +++ b/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/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheIntuneDeviceEnrollmentConfigurations.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheIntuneDeviceEnrollmentConfigurations.ps1 new file mode 100644 index 0000000000000..a7416e164a3dd --- /dev/null +++ b/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/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheIntuneDeviceManagementSettings.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheIntuneDeviceManagementSettings.ps1 new file mode 100644 index 0000000000000..e55bad9d2f381 --- /dev/null +++ b/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/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheIntuneMobileApps.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheIntuneMobileApps.ps1 new file mode 100644 index 0000000000000..70593f46d0327 --- /dev/null +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheIntuneMobileApps.ps1 @@ -0,0 +1,41 @@ +function Set-CIPPDBCacheIntuneMobileApps { + <# + .SYNOPSIS + Caches Intune mobile apps (summary fields) for a tenant. + + .DESCRIPTION + Thin single-family collector for the IntuneMobileApps 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. Same $select as the umbrella's family entry; the umbrella's + per-app assignment fan-out is not repeated here, so rows from this collector simply + lack the assignments property. + + .PARAMETER TenantFilter + The tenant to cache mobile apps for + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter, + [string]$QueueId + ) + + try { + $TestResult = Test-CIPPStandardLicense -StandardName 'IntuneMobileAppsCache' -TenantFilter $TenantFilter -Preset Intune -SkipLog + if ($TestResult -eq $false) { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Tenant does not have Intune license, skipping mobile apps cache' -sev Debug + return + } + + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching Intune mobile apps' -sev Debug + $Apps = New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/deviceAppManagement/mobileApps?$top=999&$select=id,displayName,description,publisher,isAssigned,createdDateTime,lastModifiedDateTime' -tenantid $TenantFilter + if (-not $Apps) { $Apps = @() } + + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'IntuneMobileApps' -Data @($Apps) -AddCount -ClearOnEmpty + + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Cached $(($Apps | Measure-Object).Count) mobile apps" -sev Debug + } catch { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Failed to cache mobile apps: $($_.Exception.Message)" -sev Error -LogData (Get-CippException -Exception $_) + } +} diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheIntunePolicies.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheIntunePolicies.ps1 index b413692907f84..3327feda26ceb 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheIntunePolicies.ps1 +++ b/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/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheIntuneWindowsAutopilotDeploymentProfiles.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheIntuneWindowsAutopilotDeploymentProfiles.ps1 new file mode 100644 index 0000000000000..03f966b3571ce --- /dev/null +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheIntuneWindowsAutopilotDeploymentProfiles.ps1 @@ -0,0 +1,39 @@ +function Set-CIPPDBCacheIntuneWindowsAutopilotDeploymentProfiles { + <# + .SYNOPSIS + Caches Windows Autopilot deployment profiles (with assignments) for a tenant. + + .DESCRIPTION + Thin single-family collector for the IntuneWindowsAutopilotDeploymentProfiles 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. Same URI as the umbrella's family entry. + + .PARAMETER TenantFilter + The tenant to cache Autopilot deployment profiles for + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter, + [string]$QueueId + ) + + try { + $TestResult = Test-CIPPStandardLicense -StandardName 'IntuneAutopilotProfilesCache' -TenantFilter $TenantFilter -Preset Intune -SkipLog + if ($TestResult -eq $false) { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Tenant does not have Intune license, skipping Autopilot 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 'IntuneWindowsAutopilotDeploymentProfiles' -Data @($Profiles) -AddCount -ClearOnEmpty + + 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 -LogData (Get-CippException -Exception $_) + } +} diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheMailboxes.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheMailboxes.ps1 index 0f845f5e75166..74f56ffc3e599 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheMailboxes.ps1 +++ b/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/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheManagedDeviceCleanupRules.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheManagedDeviceCleanupRules.ps1 new file mode 100644 index 0000000000000..225a36899f5bb --- /dev/null +++ b/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/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheManagedDeviceEncryptionStates.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheManagedDeviceEncryptionStates.ps1 index 413cc53c29e69..962fd98117c8b 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheManagedDeviceEncryptionStates.ps1 +++ b/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/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheManagedDevices.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheManagedDevices.ps1 index 27032d5d4a4e2..9c82673c4ba26 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheManagedDevices.ps1 +++ b/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/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheMobileDeviceManagementPolicies.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheMobileDeviceManagementPolicies.ps1 new file mode 100644 index 0000000000000..969f496cb5089 --- /dev/null +++ b/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/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheMoeraDmarc.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheMoeraDmarc.ps1 new file mode 100644 index 0000000000000..eb4efddfe1795 --- /dev/null +++ b/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/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheNamePronunciation.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheNamePronunciation.ps1 new file mode 100644 index 0000000000000..9c436d203b18b --- /dev/null +++ b/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/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheOrganizationBranding.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheOrganizationBranding.ps1 new file mode 100644 index 0000000000000..fdd7cccbce4f9 --- /dev/null +++ b/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/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCachePeopleInsights.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCachePeopleInsights.ps1 new file mode 100644 index 0000000000000..7eb773191e6c3 --- /dev/null +++ b/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/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCachePermissionGrantPolicies.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCachePermissionGrantPolicies.ps1 new file mode 100644 index 0000000000000..aa88aae3855d9 --- /dev/null +++ b/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/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCachePhotoUpdateSettings.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCachePhotoUpdateSettings.ps1 new file mode 100644 index 0000000000000..9c38b92a6542a --- /dev/null +++ b/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/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCachePronouns.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCachePronouns.ps1 new file mode 100644 index 0000000000000..87ea93e87f4d0 --- /dev/null +++ b/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/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheReportSubmissionRule.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheReportSubmissionRule.ps1 new file mode 100644 index 0000000000000..5a4ae9e018643 --- /dev/null +++ b/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/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheSecureScoreControlProfiles.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheSecureScoreControlProfiles.ps1 new file mode 100644 index 0000000000000..be01d912b08b4 --- /dev/null +++ b/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/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheSecurityDefaults.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheSecurityDefaults.ps1 new file mode 100644 index 0000000000000..460d92e958406 --- /dev/null +++ b/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/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheSelfServicePurchaseProducts.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheSelfServicePurchaseProducts.ps1 new file mode 100644 index 0000000000000..a96cef3be7938 --- /dev/null +++ b/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/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheSharePointAdminSettings.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheSharePointAdminSettings.ps1 new file mode 100644 index 0000000000000..72e16e325fe66 --- /dev/null +++ b/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/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheSharePointSharingLinks.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheSharePointSharingLinks.ps1 index 49e9bca51f94f..ee0a83797cb6f 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheSharePointSharingLinks.ps1 +++ b/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/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheTeamsResourceAccounts.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheTeamsResourceAccounts.ps1 new file mode 100644 index 0000000000000..6ae50e8ecfea3 --- /dev/null +++ b/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) + + # 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 $_ + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Failed to cache Teams resource accounts: $($ErrorMessage.NormalizedError)" -sev Warning -LogData $ErrorMessage + } +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecRemoveSnooze.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecRemoveSnooze.ps1 index ccfc01f630cf5..e20117935b64c 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecRemoveSnooze.ps1 +++ b/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/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecSnoozeAlert.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecSnoozeAlert.ps1 index fc57f7ab116f8..b378074eedcb3 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecSnoozeAlert.ps1 +++ b/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/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecUniversalSearchV2.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecUniversalSearchV2.ps1 index b52eadbac5de3..fb9b39de6cb02 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecUniversalSearchV2.ps1 +++ b/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/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-GetVersion.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-GetVersion.ps1 index 81e58f4fa61b0..f61d8776beb66 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-GetVersion.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-GetVersion.ps1 @@ -5,13 +5,54 @@ function Invoke-GetVersion { .ROLE CIPP.Core.Read .DESCRIPTION - Compares the caller's reported CIPP version against the latest published release and reports whether the frontend or the API is out of date. + Compares the caller's reported CIPP version against the latest published release and reports whether the frontend or the API is out of date, alongside the hosting shape (hosting type, App Service SKU, runtime stack) and the recorded version-update history. #> [CmdletBinding()] param($Request, $TriggerMetadata) $CIPPVersion = $request.query.LocalVersion - $Version = Assert-CippVersion -CIPPVersion $CIPPVersion + try { + $Version = Assert-CippVersion -CIPPVersion $CIPPVersion + } catch { + # GitHub unreachable or rate-limited - the release comparison is unavailable, but the + # hosting shape and update history below do not depend on it. Degrade instead of + # failing the request, so a support-ticket paste still carries what we could read. + $Version = [PSCustomObject]@{ + LocalCIPPVersion = $CIPPVersion + RemoteCIPPVersion = 'Unknown' + LocalCIPPAPIVersion = $env:APP_VERSION ?? 'Unknown' + RemoteCIPPAPIVersion = 'Unknown' + OutOfDateCIPP = $false + OutOfDateCIPPAPI = $false + } + } + + # 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 + 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 + # instance has moved between builds at least once. + $History = @(Get-CIPPVersionHistory) + + $Version | Add-Member -NotePropertyName 'Hosting' -NotePropertyValue $Hosting + $Version | Add-Member -NotePropertyName 'VersionHistory' -NotePropertyValue $History + $Version | Add-Member -NotePropertyName 'LastUpdate' -NotePropertyValue ($History.Count -gt 0 ? $History[0] : $null) return ([HttpResponseContext]@{ StatusCode = [HttpStatusCode]::OK diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ListAlertResults.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ListAlertResults.ps1 index 15246398a35b9..45447d1e1a58d 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ListAlertResults.ps1 +++ b/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/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ListCustomDataMappings.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ListCustomDataMappings.ps1 index 057072b1bb55c..e9aa67f33b45e 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ListCustomDataMappings.ps1 +++ b/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 } diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ListDirectoryObjects.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ListDirectoryObjects.ps1 index 360f777b7d069..30264a08f1bc1 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ListDirectoryObjects.ps1 +++ b/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/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ListSnoozedAlerts.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ListSnoozedAlerts.ps1 index 7829191267d9d..32e8876606438 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ListSnoozedAlerts.ps1 +++ b/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/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Extensions/Invoke-ExecExtensionMapping.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Extensions/Invoke-ExecExtensionMapping.ps1 index 3482998609146..5179bfdc3d0ee 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Extensions/Invoke-ExecExtensionMapping.ps1 +++ b/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 = @{ @@ -57,6 +66,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/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Extensions/Invoke-ExecExtensionTest.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Extensions/Invoke-ExecExtensionTest.ps1 index 24cc7bd2de1d6..5178d14771013 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Extensions/Invoke-ExecExtensionTest.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Extensions/Invoke-ExecExtensionTest.ps1 @@ -50,12 +50,23 @@ Function Invoke-ExecExtensionTest { } } 'PWPush' { + if ($Configuration.PWPush.Enabled -ne $true) { + $Results = [pscustomobject]@{'Results' = 'PWPush is not enabled. Enable the integration and save the configuration, then test again.' } + break + } $Payload = 'This is a test from CIPP' - $PasswordLink = New-PwPushLink -Payload $Payload + # ThrowOnError: the silent $false fallback exists for the password flows; the + # test's whole job is to show why a push fails, so let the real exception through. + try { + $PasswordLink = New-PwPushLink -Payload $Payload -ThrowOnError + } catch { + $Results = [pscustomobject]@{'Results' = "PWPush is enabled but creating a test push failed: $($_.Exception.Message)" } + break + } if ($PasswordLink) { $Results = [pscustomobject]@{Results = @(@{'resultText' = 'Successfully generated PWPush, hit the Copy to Clipboard button to retrieve the test.'; 'copyField' = $PasswordLink; 'state' = 'success' }) } } else { - $Results = [pscustomobject]@{'Results' = 'PWPush is not enabled' } + $Results = [pscustomobject]@{'Results' = 'PWPush did not return a link. Check the CIPP logbook (API: PwPush) for details.' } } } 'Hudu' { diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Extensions/Invoke-ExecHaloPSATestTicket.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Extensions/Invoke-ExecHaloPSATestTicket.ps1 index f01ef36490676..c20bedf47843a 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Extensions/Invoke-ExecHaloPSATestTicket.ps1 +++ b/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/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Scheduler/Invoke-ListScheduledItemDetails.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Scheduler/Invoke-ListScheduledItemDetails.ps1 index 432943270d45d..88ee45df5ba2b 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Scheduler/Invoke-ListScheduledItemDetails.ps1 +++ b/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/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Scheduler/Invoke-RemoveScheduledItem.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Scheduler/Invoke-RemoveScheduledItem.ps1 index a739ec68360c5..31f53f445e292 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Scheduler/Invoke-RemoveScheduledItem.ps1 +++ b/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/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecContainerManagement.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecContainerManagement.ps1 index 8e2cb27781f25..979dd2ddb6c26 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecContainerManagement.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecContainerManagement.ps1 @@ -206,6 +206,7 @@ function Invoke-ExecContainerManagement { ValidChannels = $ValidChannels BuildChannelPattern = $BuildChannelPattern UpdateSettings = $UpdateInfo + UpgradeHistory = @(Get-CIPPVersionHistory -Last 50) } } } catch { diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecCustomData.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecCustomData.ps1 index 09574746fa1f1..0a7b3a16623ac 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecCustomData.ps1 +++ b/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/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecCustomRole.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecCustomRole.ps1 index 37609dc2f7ba1..1594453d77e7d 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecCustomRole.ps1 +++ b/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 { @@ -46,10 +50,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)" @@ -80,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())" @@ -87,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' } @@ -127,6 +169,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 @@ -157,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) { @@ -185,12 +229,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) @@ -275,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/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecDnsConfig.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecDnsConfig.ps1 index bd4f4db30f65e..7d437c1e2d54f 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecDnsConfig.ps1 +++ b/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/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecRefreshMyAccess.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecRefreshMyAccess.ps1 new file mode 100644 index 0000000000000..e9105d3f72a83 --- /dev/null +++ b/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/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecRunTenantGroupRule.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecRunTenantGroupRule.ps1 index 31aaec5c4d316..4ce8658e9fc2a 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecRunTenantGroupRule.ps1 +++ b/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/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ListCustomRole.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ListCustomRole.ps1 index a57d615cada29..e231cdcd085db 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ListCustomRole.ps1 +++ b/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/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Setup/Invoke-ExecAddTenant.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Setup/Invoke-ExecAddTenant.ps1 index 15a58688061a7..7a07c581cf133 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Setup/Invoke-ExecAddTenant.ps1 +++ b/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/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Contacts/Invoke-ListContactTemplates.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Contacts/Invoke-ListContactTemplates.ps1 index b42fa55a75626..b218240885355 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Contacts/Invoke-ListContactTemplates.ps1 +++ b/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)" diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ListHVEAccounts.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ListHVEAccounts.ps1 index e2b3fa48eee8d..c4ce318772df7 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ListHVEAccounts.ps1 +++ b/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/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Spamfilter/Invoke-AddSpamFilter.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Spamfilter/Invoke-AddSpamFilter.ps1 index f998de70ff36e..e5896a1f50c57 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Spamfilter/Invoke-AddSpamFilter.ps1 +++ b/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/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Spamfilter/Invoke-ExecMailQuarantineSubmit.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Spamfilter/Invoke-ExecMailQuarantineSubmit.ps1 new file mode 100644 index 0000000000000..cc59181bbf451 --- /dev/null +++ b/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/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Spamfilter/Invoke-ExecQuarantineManagement.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Spamfilter/Invoke-ExecQuarantineManagement.ps1 index 08f15c3e275e0..c388fc5259f41 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Spamfilter/Invoke-ExecQuarantineManagement.ps1 +++ b/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/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Spamfilter/Invoke-ListMailQuarantine.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Spamfilter/Invoke-ListMailQuarantine.ps1 index 973b5913d9f77..01d79ee71a499 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Spamfilter/Invoke-ListMailQuarantine.ps1 +++ b/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/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Spamfilter/Invoke-ListMailQuarantineMessage.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Spamfilter/Invoke-ListMailQuarantineMessage.ps1 index 22a89fd7ff437..d10a3436cb778 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Spamfilter/Invoke-ListMailQuarantineMessage.ps1 +++ b/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/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Spamfilter/Invoke-ListMailQuarantineMessageDetails.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Spamfilter/Invoke-ListMailQuarantineMessageDetails.ps1 new file mode 100644 index 0000000000000..68673232378fe --- /dev/null +++ b/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/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Spamfilter/Invoke-ListMailQuarantineMessageHeader.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Spamfilter/Invoke-ListMailQuarantineMessageHeader.ps1 new file mode 100644 index 0000000000000..2734bfbf58946 --- /dev/null +++ b/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/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Tools/Invoke-ExecIRMConfiguration.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Tools/Invoke-ExecIRMConfiguration.ps1 new file mode 100644 index 0000000000000..bdc787731ad6f --- /dev/null +++ b/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/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Tools/Invoke-ListIRMConfiguration.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Tools/Invoke-ListIRMConfiguration.ps1 new file mode 100644 index 0000000000000..a1ba70761589c --- /dev/null +++ b/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/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Transport/Invoke-AddExConnector.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Transport/Invoke-AddExConnector.ps1 index 5e3ca2d26e8df..e18a1292a9f32 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Transport/Invoke-AddExConnector.ps1 +++ b/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 { diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/Autopilot/Invoke-AddAutopilotConfig.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/Autopilot/Invoke-AddAutopilotConfig.ps1 index 52591cc8b3aa2..32093660d2d52 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/Autopilot/Invoke-AddAutopilotConfig.ps1 +++ b/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/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/Autopilot/Invoke-ExecAssignAutopilotProfile.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/Autopilot/Invoke-ExecAssignAutopilotProfile.ps1 new file mode 100644 index 0000000000000..743cb752d3d23 --- /dev/null +++ b/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/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-AddIntuneTemplate.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-AddIntuneTemplate.ps1 index 8861c30455dd4..10380b5e2ad53 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-AddIntuneTemplate.ps1 +++ b/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/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ExecCompareIntunePolicy.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ExecCompareIntunePolicy.ps1 index c87ff50b1563a..1d8404c00b5f8 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ExecCompareIntunePolicy.ps1 +++ b/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/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ExecDeviceAction.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ExecDeviceAction.ps1 index 8b3123ea19d14..bad51eddf05c1 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ExecDeviceAction.ps1 +++ b/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/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ListCVEManagement.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ListCVEManagement.ps1 index 1eb2981b14337..97e92b83af2c0 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ListCVEManagement.ps1 +++ b/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/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Groups/Invoke-EditGroup.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Groups/Invoke-EditGroup.ps1 index 3988e05e9526c..e7042c65e82b2 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Groups/Invoke-EditGroup.ps1 +++ b/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/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Groups/Invoke-ExecGroupMembers.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Groups/Invoke-ExecGroupMembers.ps1 new file mode 100644 index 0000000000000..fc095dc6d5d48 --- /dev/null +++ b/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/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ExecRequirePasswordChange.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ExecRequirePasswordChange.ps1 new file mode 100644 index 0000000000000..1bab573c8a020 --- /dev/null +++ b/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/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListGuestUsers.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListGuestUsers.ps1 new file mode 100644 index 0000000000000..0255de67a6cdf --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListGuestUsers.ps1 @@ -0,0 +1,120 @@ +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. Supports UseReportDB=true to serve cached data from the reporting database; AllTenants always uses the cache. + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers + + # 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 { + 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 + } + + $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. + $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 $RowCapable) { + 'Unknown' + } elseif (-not $LastSignIn) { + 'Never Signed In' + } elseif ($DaysSinceSignIn -ge $StaleDays) { + 'Stale' + } else { + 'Active' + } + + $Row = [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 + 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 { + $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/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListUserPhoto.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListUserPhoto.ps1 index 8e262240fa312..afae7ded9c99d 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListUserPhoto.ps1 +++ b/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/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListUserSettings.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListUserSettings.ps1 index c1f1523d52a67..049d046612b13 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListUserSettings.ps1 +++ b/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/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListUsers.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListUsers.ps1 index 69396848aabba..a43c639147c95 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListUsers.ps1 +++ b/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/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Reports/Invoke-ListInactiveAccounts.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Reports/Invoke-ListInactiveAccounts.ps1 index a562ed80527e7..cb08149fc8efe 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Reports/Invoke-ListInactiveAccounts.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Reports/Invoke-ListInactiveAccounts.ps1 @@ -85,6 +85,18 @@ function Get-InactiveUsersFromDB { $TenantInfo = Get-Tenants -TenantFilter $TenantFilter | Select-Object -First 1 $TenantDisplayName = $TenantInfo.displayName ?? $TenantFilter + # The Users-Count row is rewritten when a cache run for this tenant completes, so its table + # Timestamp is when this data was actually refreshed — request time must not be reported here. + $LastRefreshed = $null + try { + $CountRow = Get-CIPPDbItem -TenantFilter $TenantFilter -Type 'Users' -CountsOnly | Select-Object -First 1 + if ($CountRow.Timestamp) { + $LastRefreshed = ([DateTimeOffset]$CountRow.Timestamp).UtcDateTime.ToString('yyyy-MM-ddTHH:mm:ss.fffZ') + } + } catch { + Write-Information "Could not determine Users cache refresh time for $($TenantFilter): $($_.Exception.Message)" + } + $InactiveUsers = foreach ($User in $Users) { # Skip disabled users by default if ($User.accountEnabled -eq $false) { continue } @@ -92,21 +104,18 @@ function Get-InactiveUsersFromDB { # Skip guest users if ($User.userType -eq 'Guest') { continue } - # Determine last sign-in + # Determine last sign-in — most recent of the three signInActivity fields. + # lastSuccessfulSignInDateTime can run ahead of the other two (it is what the Entra + # profile blade shows); leaving it out lists recently-active users as inactive. $lastInteractive = $User.signInActivity.lastSignInDateTime $lastNonInteractive = $User.signInActivity.lastNonInteractiveSignInDateTime + $lastSuccessful = $User.signInActivity.lastSuccessfulSignInDateTime $lastSignIn = $null - if ($lastInteractive -and $lastNonInteractive) { - $lastSignIn = if ([DateTime]$lastInteractive -gt [DateTime]$lastNonInteractive) { - $lastInteractive - } else { - $lastNonInteractive + foreach ($Candidate in @($lastInteractive, $lastNonInteractive, $lastSuccessful)) { + if ($Candidate -and (-not $lastSignIn -or [DateTime]$Candidate -gt [DateTime]$lastSignIn)) { + $lastSignIn = $Candidate } - } elseif ($lastInteractive) { - $lastSignIn = $lastInteractive - } elseif ($lastNonInteractive) { - $lastSignIn = $lastNonInteractive } # Check if user is inactive @@ -137,7 +146,8 @@ function Get-InactiveUsersFromDB { createdDateTime = $User.createdDateTime lastSignInDateTime = $lastInteractive lastNonInteractiveSignInDateTime = $lastNonInteractive - lastRefreshedDateTime = (Get-Date).ToString('yyyy-MM-ddTHH:mm:ss.fffZ') + lastSuccessfulSignInDateTime = $lastSuccessful + lastRefreshedDateTime = $LastRefreshed numberOfAssignedLicenses = $numberOfAssignedLicenses daysSinceLastSignIn = $daysSinceSignIn accountEnabled = $User.accountEnabled diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecSharePointTemplate.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecSharePointTemplate.ps1 index d437123d0c81b..fbb70900fda51 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecSharePointTemplate.ps1 +++ b/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/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecSiteBrowserActions.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecSiteBrowserActions.ps1 new file mode 100644 index 0000000000000..950ab99041715 --- /dev/null +++ b/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/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecSiteBrowserPermissions.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecSiteBrowserPermissions.ps1 new file mode 100644 index 0000000000000..b6834377d59a4 --- /dev/null +++ b/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/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSharepointQuota.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSharepointQuota.ps1 index 58fd78d7854d4..01ca13eb14bd0 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSharepointQuota.ps1 +++ b/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 diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSiteBrowser.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSiteBrowser.ps1 new file mode 100644 index 0000000000000..29538fe0213dc --- /dev/null +++ b/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/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSiteBrowserPermissions.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSiteBrowserPermissions.ps1 new file mode 100644 index 0000000000000..94eef3575ac98 --- /dev/null +++ b/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/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Alerts/Invoke-AddAlert.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Alerts/Invoke-AddAlert.ps1 index 31b38f41bc1e9..8afe3d021f1c0 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Alerts/Invoke-AddAlert.ps1 +++ b/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/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Alerts/Invoke-ExecScheduleAuditExclusionVacation.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Alerts/Invoke-ExecScheduleAuditExclusionVacation.ps1 new file mode 100644 index 0000000000000..702a61d5800e7 --- /dev/null +++ b/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/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Alerts/Invoke-ExecToggleAlert.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Alerts/Invoke-ExecToggleAlert.ps1 new file mode 100644 index 0000000000000..c6afe8b455b86 --- /dev/null +++ b/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/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Alerts/Invoke-ListAlertsQueue.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Alerts/Invoke-ListAlertsQueue.ps1 index 35ec64a84b964..81f7e6013b88f 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Alerts/Invoke-ListAlertsQueue.ps1 +++ b/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/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Alerts/Invoke-ListAuditLogTest.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Alerts/Invoke-ListAuditLogTest.ps1 index 7a779a245dfb0..44a42794c6529 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Alerts/Invoke-ListAuditLogTest.ps1 +++ b/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/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Tenant/Invoke-EditTenant.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Tenant/Invoke-EditTenant.ps1 index ac9a6c15e7591..1f034f561df2f 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Tenant/Invoke-EditTenant.ps1 +++ b/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/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Tenant/Invoke-EditTenantOffboardingDefaults.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Tenant/Invoke-EditTenantOffboardingDefaults.ps1 index aebd8f12364f4..3aedbd78ef983 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Tenant/Invoke-EditTenantOffboardingDefaults.ps1 +++ b/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/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Tenant/Invoke-ExecOffboardTenant.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Tenant/Invoke-ExecOffboardTenant.ps1 index 219140ee5c638..0c8432fef850d 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Tenant/Invoke-ExecOffboardTenant.ps1 +++ b/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/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Tenant/Invoke-RemoveTenantCapabilitiesCache.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Tenant/Invoke-RemoveTenantCapabilitiesCache.ps1 index 52db5565027ea..776386a3101f1 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Tenant/Invoke-RemoveTenantCapabilitiesCache.ps1 +++ b/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/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Conditional/Invoke-AddNamedLocation.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Conditional/Invoke-AddNamedLocation.ps1 index 941bcdef574f7..457a620610289 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Conditional/Invoke-AddNamedLocation.ps1 +++ b/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/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Conditional/Invoke-ExecNamedLocation.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Conditional/Invoke-ExecNamedLocation.ps1 index 038e00dc022b4..59b414ff469d6 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Conditional/Invoke-ExecNamedLocation.ps1 +++ b/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/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/GDAP/Invoke-ExecGDAPInvite.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/GDAP/Invoke-ExecGDAPInvite.ps1 index 865ca6e6921ff..feebb1ad053b3 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/GDAP/Invoke-ExecGDAPInvite.ps1 +++ b/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/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/GDAP/Invoke-ListGDAPInvite.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/GDAP/Invoke-ListGDAPInvite.ps1 index 662c6d68bcb71..f37e4da5be673 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/GDAP/Invoke-ListGDAPInvite.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/GDAP/Invoke-ListGDAPInvite.ps1 @@ -13,13 +13,31 @@ 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) { + $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 + } + 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]@{ diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-AddBaseline.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-AddBaseline.ps1 index 7aa6685d0ab3c..be5bc7ba8d642 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-AddBaseline.ps1 +++ b/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/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ExecBPA.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ExecBPA.ps1 index 7f3e7495f478c..aaf763e7eaa7c 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ExecBPA.ps1 +++ b/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/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ExecBaselineOverride.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ExecBaselineOverride.ps1 index 432d90b8f0d7f..3b00d8dcca5e7 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ExecBaselineOverride.ps1 +++ b/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/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ExecBaselineRun.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ExecBaselineRun.ps1 index d3d93a06218fa..b8c08b144e9d4 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ExecBaselineRun.ps1 +++ b/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/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ExecBaselineStage.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ExecBaselineStage.ps1 index 633e4512b9c4b..3a108a647df79 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ExecBaselineStage.ps1 +++ b/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/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ExecDomainAnalyser.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ExecDomainAnalyser.ps1 index b48d0ca647a7e..34fc4be7f5045 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ExecDomainAnalyser.ps1 +++ b/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/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ExecUpdateBaselineDeviation.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ExecUpdateBaselineDeviation.ps1 index 60b798984737c..7042137b20eb2 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ExecUpdateBaselineDeviation.ps1 +++ b/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), @@ -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/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ListBaselineAlignment.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ListBaselineAlignment.ps1 index e9551aa094511..0bf3359f26a97 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ListBaselineAlignment.ps1 +++ b/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/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ListBaselineStandards.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ListBaselineStandards.ps1 index 090922b54713e..a6388c4ea6174 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ListBaselineStandards.ps1 +++ b/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/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ListBaselines.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ListBaselines.ps1 index 63ea8ff1670c2..0a4c16d24060f 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ListBaselines.ps1 +++ b/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/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ListDomainHealth.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ListDomainHealth.ps1 index 1e0174b193216..cc89f2b8b4019 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ListDomainHealth.ps1 +++ b/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/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-RemoveBaseline.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-RemoveBaseline.ps1 index a42fa33f7c9c0..794d2f0c73290 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-RemoveBaseline.ps1 +++ b/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/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/invoke-DomainAnalyser_List.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/invoke-DomainAnalyser_List.ps1 index 1a80c149262ab..cf1643dd5eb87 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/invoke-DomainAnalyser_List.ps1 +++ b/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 diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tools/GitHub/Invoke-ExecCommunityRepo.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tools/GitHub/Invoke-ExecCommunityRepo.ps1 index fbc2278de8023..335a5d0b684e7 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tools/GitHub/Invoke-ExecCommunityRepo.ps1 +++ b/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" diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardAppManagementPolicy.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardAppManagementPolicy.ps1 index 75223e87b27bd..a6865d3f70db2 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardAppManagementPolicy.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardAppManagementPolicy.ps1 @@ -137,16 +137,21 @@ function Invoke-CIPPStandardAppManagementPolicy { } } + # Project current policy restrictions down to only the fields this standard manages. + # Graph adds new properties to restriction objects over time (e.g. excludeActors, returned as null) + # which would otherwise cause a permanent deviation in the JSON comparison below. + $ManagedRestrictionFields = 'restrictionType', 'state', 'maxLifetime', 'restrictForAppsCreatedAfterDateTime' + # Sort current policy arrays the same way for consistent comparison $CurrentValue = [PSCustomObject]@{ isEnabled = [bool]$CurrentPolicy.isEnabled applicationRestrictions = [PSCustomObject]@{ - passwordCredentials = @($CurrentPolicy.applicationRestrictions.passwordCredentials | Sort-Object -Property restrictionType) - keyCredentials = @($CurrentPolicy.applicationRestrictions.keyCredentials | Sort-Object -Property restrictionType) + passwordCredentials = @($CurrentPolicy.applicationRestrictions.passwordCredentials | Select-Object -Property $ManagedRestrictionFields | Sort-Object -Property restrictionType) + keyCredentials = @($CurrentPolicy.applicationRestrictions.keyCredentials | Select-Object -Property $ManagedRestrictionFields | Sort-Object -Property restrictionType) } servicePrincipalRestrictions = [PSCustomObject]@{ - passwordCredentials = @($CurrentPolicy.servicePrincipalRestrictions.passwordCredentials | Sort-Object -Property restrictionType) - keyCredentials = @($CurrentPolicy.servicePrincipalRestrictions.keyCredentials | Sort-Object -Property restrictionType) + passwordCredentials = @($CurrentPolicy.servicePrincipalRestrictions.passwordCredentials | Select-Object -Property $ManagedRestrictionFields | Sort-Object -Property restrictionType) + keyCredentials = @($CurrentPolicy.servicePrincipalRestrictions.keyCredentials | Select-Object -Property $ManagedRestrictionFields | Sort-Object -Property restrictionType) } } diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardAppleEnrollmentTypeProfile.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardAppleEnrollmentTypeProfile.ps1 new file mode 100644 index 0000000000000..f9652d773dd3f --- /dev/null +++ b/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/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardColleagueImpersonationAlert.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardColleagueImpersonationAlert.ps1 index 2deb77bff0de4..9e6ee7bf0ae41 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardColleagueImpersonationAlert.ps1 +++ b/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 diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardConditionalAccessTemplate.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardConditionalAccessTemplate.ps1 index 3281c2b1d61b1..9f40cdbce9b6e 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardConditionalAccessTemplate.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardConditionalAccessTemplate.ps1 @@ -62,6 +62,10 @@ function Invoke-CIPPStandardConditionalAccessTemplate { $TemplateKey = ConvertTo-CIPPODataFilterValue -Value $Settings.TemplateList.value -Type String $TemplateRow = Get-CippAzDataTableEntity @Table -Filter "PartitionKey eq 'CATemplate' and (RowKey eq '$TemplateKey' or GUID eq '$TemplateKey')" | Select-Object -First 1 $JSONObj = $TemplateRow.JSON + # Resolve custom variables for the report path too, not just the deploy inside + # New-CIPPCAPolicy - otherwise the compare diffs raw %tokens% against the deployed + # policy's resolved values and flags every templated field as permanent drift. + if ($JSONObj) { $JSONObj = Get-CIPPTextReplacement -TenantFilter $Tenant -Text $JSONObj -EscapeForJson } try { #Get from DB, as we just downloaded the latest before the standard runs. diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardEnableAppConsentRequests.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardEnableAppConsentRequests.ps1 index 6665ff6c9a78c..72cc357d310f8 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardEnableAppConsentRequests.ps1 +++ b/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/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardEnableCustomerLockbox.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardEnableCustomerLockbox.ps1 index c732564427989..e5455506feb56 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardEnableCustomerLockbox.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardEnableCustomerLockbox.ps1 @@ -35,7 +35,9 @@ function Invoke-CIPPStandardEnableCustomerLockbox { #> param($Tenant, $Settings) - $TestResult = Test-CIPPStandardLicense -StandardName 'EnableCustomerLockbox' -TenantFilter $Tenant -RequiredCapabilities @('CustomerLockbox') + # Capabilities are keyed by servicePlanName from subscribedSkus - 'CustomerLockbox' is + # not one, so the old gate could never pass. The real plan names: + $TestResult = Test-CIPPStandardLicense -StandardName 'EnableCustomerLockbox' -TenantFilter $Tenant -RequiredCapabilities @('LOCKBOX_ENTERPRISE', 'LOCKBOX_ENTERPRISE_GOV', 'CustomerLockboxA_Enterprise') if ($TestResult -eq $false) { return $true diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardGroupTemplate.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardGroupTemplate.ps1 index de45554c3c5b0..562bb79e29ee3 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardGroupTemplate.ps1 +++ b/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/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardMessageEncryption.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardMessageEncryption.ps1 new file mode 100644 index 0000000000000..78f894b7227b8 --- /dev/null +++ b/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/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardNudgeMFA.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardNudgeMFA.ps1 index afac49b65a297..4b15c56be6daa 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardNudgeMFA.ps1 +++ b/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/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardPhishProtection.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardPhishProtection.ps1 index 7e0fae4f45e78..6ef1dc2fccf95 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardPhishProtection.ps1 +++ b/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/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardQuarantineRequestAlert.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardQuarantineRequestAlert.ps1 index d1d016111129e..144141a671cce 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardQuarantineRequestAlert.ps1 +++ b/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/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardSensitivityLabelTemplate.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardSensitivityLabelTemplate.ps1 index 01603ce0cc28f..8cee20cffca5d 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardSensitivityLabelTemplate.ps1 +++ b/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/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardSpamFilterPolicy.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardSpamFilterPolicy.ps1 index 6be274bd9079c..08ca43604c1d8 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardSpamFilterPolicy.ps1 +++ b/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 diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTAP.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTAP.ps1 index 21d632dac5b14..80f06e8c2b469 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTAP.ps1 +++ b/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/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTeamsExternalAccessPolicy.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTeamsExternalAccessPolicy.ps1 index 8eb632ba19456..37bd93c22a237 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTeamsExternalAccessPolicy.ps1 +++ b/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/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTeamsFederationConfiguration.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTeamsFederationConfiguration.ps1 index 10639e02d829f..c4e62f781de11 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTeamsFederationConfiguration.ps1 +++ b/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/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardUserSubmissions.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardUserSubmissions.ps1 index 7c5c3d8c8e16e..13f53548e564d 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardUserSubmissions.ps1 +++ b/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/Modules/CIPPTests/Public/Tests/GenericTests/Identity/Invoke-CippTestGenericTest002.ps1 b/Modules/CIPPTests/Public/Tests/GenericTests/Identity/Invoke-CippTestGenericTest002.ps1 index 75d99ab942925..68164d7333d9a 100644 --- a/Modules/CIPPTests/Public/Tests/GenericTests/Identity/Invoke-CippTestGenericTest002.ps1 +++ b/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/Modules/CippExtensions/Public/Halo/Get-HaloRequestSource.ps1 b/Modules/CippExtensions/Public/Halo/Get-HaloRequestSource.ps1 new file mode 100644 index 0000000000000..1aa8179773618 --- /dev/null +++ b/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/Modules/CippExtensions/Public/Halo/New-HaloPSATicket.ps1 b/Modules/CippExtensions/Public/Halo/New-HaloPSATicket.ps1 index deea67be9cdfb..5cddec481ebe8 100644 --- a/Modules/CippExtensions/Public/Halo/New-HaloPSATicket.ps1 +++ b/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/Modules/CippExtensions/Public/Hudu/Invoke-HuduExtensionSync.ps1 b/Modules/CippExtensions/Public/Hudu/Invoke-HuduExtensionSync.ps1 index b2ff5e4133e91..0e854f88b6ede 100644 --- a/Modules/CippExtensions/Public/Hudu/Invoke-HuduExtensionSync.ps1 +++ b/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' @@ -223,7 +228,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' }) } diff --git a/Modules/CippExtensions/Public/PwPush/New-PwPushLink.ps1 b/Modules/CippExtensions/Public/PwPush/New-PwPushLink.ps1 index 60f1c7810a8a5..ba0c02975e551 100644 --- a/Modules/CippExtensions/Public/PwPush/New-PwPushLink.ps1 +++ b/Modules/CippExtensions/Public/PwPush/New-PwPushLink.ps1 @@ -1,7 +1,10 @@ function New-PwPushLink { [CmdletBinding(SupportsShouldProcess)] Param( - $Payload + $Payload, + # Rethrow creation failures instead of collapsing them into $false. The password flows + # rely on the silent fallback; the extension test uses this to show the real error. + [switch]$ThrowOnError ) try { @@ -34,21 +37,59 @@ 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 + } + # 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) { + $PasswordValues.expire_after_days = $ExpireAfterDays + } else { + Write-LogMessage -API PwPush -Message "Ignoring ExpireAfterDays '$($Configuration.ExpireAfterDays)': PWPush accepts 1 to 90 days" -Sev 'Warning' + } + } + $ExpireAfterViews = $Configuration.ExpireAfterViews -as [int] + if ($ExpireAfterViews) { + if ($ExpireAfterViews -ge 1 -and $ExpireAfterViews -le 100) { + $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) { $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)) { + $PushBody.account_id = $Configuration.AccountId.value } - if ($Configuration.ExpireAfterDays) { $PushParams.ExpireAfterDays = $Configuration.ExpireAfterDays } - if ($Configuration.ExpireAfterViews) { $PushParams.ExpireAfterViews = $Configuration.ExpireAfterViews } - if ($Configuration.DeletableByViewer) { $PushParams.DeletableByViewer = $Configuration.DeletableByViewer } - if ($Configuration.AccountId) { $PushParams.AccountId = $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]@{ @@ -56,11 +97,13 @@ function New-PwPushLink { 'Exception' = Get-CippException -Exception $_ } Write-LogMessage -API PwPush -Message "Failed to create a new PwPush link: $($_.Exception.Message)" -Sev 'Error' -LogData $LogData + if ($ThrowOnError) { throw } Write-LogMessage -API PwPush -Message "Continuing without PwPush link due to error" -sev 'Warning' return $false } } catch { Write-LogMessage -API PwPush -Message "Unexpected error in PwPush configuration handling: $($_.Exception.Message)" -Sev 'Error' + if ($ThrowOnError) { throw } return $false } } diff --git a/Modules/CippExtensions/Public/PwPush/Set-PwPushConfig.ps1 b/Modules/CippExtensions/Public/PwPush/Set-PwPushConfig.ps1 index 4d403f0d9a950..b42e35ad26e26 100644 --- a/Modules/CippExtensions/Public/PwPush/Set-PwPushConfig.ps1 +++ b/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) { diff --git a/Tests/ActivityTriggers/Push-ListGraphRequestQueue.Tests.ps1 b/Tests/ActivityTriggers/Push-ListGraphRequestQueue.Tests.ps1 new file mode 100644 index 0000000000000..9281c16a7c806 --- /dev/null +++ b/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 + } + } +} diff --git a/Tests/ActivityTriggers/Push-UpdatePermissionsQueue.Tests.ps1 b/Tests/ActivityTriggers/Push-UpdatePermissionsQueue.Tests.ps1 new file mode 100644 index 0000000000000..dc43563e445b6 --- /dev/null +++ b/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' + } + } +} diff --git a/Tests/AuditLogs/Get-CippAuditLogPlannedWindows.Tests.ps1 b/Tests/AuditLogs/Get-CippAuditLogPlannedWindows.Tests.ps1 new file mode 100644 index 0000000000000..2de8a1c3366ca --- /dev/null +++ b/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/Tests/Baselines/BaselineDefinitions.Catalog.Tests.ps1 b/Tests/Baselines/BaselineDefinitions.Catalog.Tests.ps1 new file mode 100644 index 0000000000000..a66c888bb802c --- /dev/null +++ b/Tests/Baselines/BaselineDefinitions.Catalog.Tests.ps1 @@ -0,0 +1,212 @@ +# 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 '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 + -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 + } + + It 'gives every multi-instance standard an identity, and every identity a real variable' { + # Without instanceIdentity (or manual's taskName) N instances of a standard are N + # indistinguishable rows in every view. And an instanceIdentity naming a variable + # that does not exist silently titles every instance with a blank. + $Broken = @($script:Definitions | Where-Object { $_.Definition.multiple -eq $true } | Where-Object { + -not $_.Definition.manual -and ( + -not $_.Definition.instanceIdentity -or + -not ($_.Definition.variables ?? [PSCustomObject]@{}).PSObject.Properties["$($_.Definition.instanceIdentity)"] + ) + } | ForEach-Object { $_.Name }) + $Broken | Should -BeNullOrEmpty + } + + It 'declares a single-select picker for every instance identity variable' { + # instanceIdentity carries ONE value per instance - a multi-select picker there + # stringifies to 'System.Object[]' in every label and defeats the instance model. + $Broken = @($script:Definitions | Where-Object { $_.Definition.instanceIdentity } | Where-Object { + ($_.Definition.variables ?? [PSCustomObject]@{}).($_.Definition.instanceIdentity).multiple -eq $true + } | ForEach-Object { $_.Name }) + $Broken | Should -BeNullOrEmpty + } + + It 'pairs every identity block with an instanceIdentity and a partition' { + # The identity block tells the label resolvers (and the export bundler) where the + # referenced template lives. Half a declaration is worse than none: a partition-less + # block silently falls back to the executor-name convention it exists to override. + $Broken = @($script:Definitions | Where-Object { $_.Definition.identity } | Where-Object { + -not $_.Definition.instanceIdentity -or + [string]::IsNullOrWhiteSpace("$($_.Definition.identity.partition)") + } | ForEach-Object { $_.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/Tests/Baselines/BaselineEntraHeavies.Tests.ps1 b/Tests/Baselines/BaselineEntraHeavies.Tests.ps1 new file mode 100644 index 0000000000000..772f763b278c9 --- /dev/null +++ b/Tests/Baselines/BaselineEntraHeavies.Tests.ps1 @@ -0,0 +1,260 @@ +# Batch 3b: the Entra heavies. These tests pin the targeting semantics that fail silently - +# keep-current versus all_users, unconfigured-method skips, default-profile-only grading, and +# additive include management. + +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) } + function Get-CIPPDbItem { param($TenantFilter, $Type, [switch]$CountsOnly) } + function New-GraphGetRequest { param($uri, $tenantid, $AsApp) } + function New-GraphPostRequest { param($uri, $tenantid, $type, $body, $AsApp, $ContentType, $scope) } + function Set-CIPPRegistrationCampaign { param($Tenant, $State, $TargetedAuthenticationMethod, $SnoozeDurationInDays, $EnforceRegistrationAfterAllowedSnoozes, $IncludeTargets, $ExcludeTargets, $APIName) } + function Set-CIPPAuthenticationPolicy { param($Tenant, $APIName, $AuthenticationMethodId, [bool]$Enabled, $GroupIds, $MicrosoftAuthenticatorSoftwareOathEnabled, $MicrosoftAuthenticatorDisplayAppInfo, $MicrosoftAuthenticatorDisplayLocation, $MicrosoftAuthenticatorCompanionApp, $TAPisUsableOnce, $TAPDefaultLifeTime, $TAPMinimumLifetime, $TAPMaximumLifetime, $TAPDefaultLength, $QRCodeLifetimeInDays, $QRCodePinLength, $EmailAllowExternalIdToUseEmailOtp, $EmailExcludeGroupIds) } + + . (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 'Test-CIPPBaselineCacheCollected.ps1') + foreach ($Name in @('FIDO2PasskeyProfiles', 'CopilotLimitedMode', 'NudgeMFA', 'OauthConsent', 'DisableSelfServiceLicenses', 'AuthenticationMethods')) { + . (Join-Path $Baselines "Get-CIPPBaseline${Name}State.ps1") + . (Join-Path $Baselines "Invoke-CIPPBaseline${Name}.ps1") + } + + $script:Tenant = 'contoso.onmicrosoft.com' + function ConvertTo-Cached { param([Parameter(ValueFromPipeline = $true)]$InputObject) process { $InputObject | ConvertTo-Json -Depth 20 | ConvertFrom-Json } } + 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 { $_ }) + } + Mock Get-CIPPDbItem { [PSCustomObject]@{ RowKey = 'X-Count'; DataCount = 1 } } +} + +Describe 'Get-CIPPBaselineNudgeMFAState' { + BeforeAll { + $script:Campaign = @{ registrationEnforcement = @{ authenticationMethodsRegistrationCampaign = @{ + state = 'enabled'; snoozeDurationInDays = 1; enforceRegistrationAfterAllowedSnoozes = $true + includeTargets = @(@{ id = 'g-existing'; targetType = 'group'; targetedAuthenticationMethod = 'microsoftAuthenticator' }) + excludeTargets = @() + } } } + } + + It 'keeps the tenant''s current targets when the include field is blank - never resets to all_users' { + # NudgeMFA predates the targeting fields; grading blank as all_users would flag + # every portal-targeted deployment as drift and remediation would flatten it. + Mock New-CIPPDbRequest { @($script:Campaign | ConvertTo-Cached) } + $Item = [PSCustomObject]@{ Variables = [PSCustomObject]@{ state = 'enabled'; snoozeDurationInDays = 1; enforceRegistrationAfterAllowedSnoozes = $true } } + $Prepared = Get-CIPPBaselineNudgeMFAState -Item $Item -TenantFilter $script:Tenant + $Prepared.Expected.includeTargetIds | Should -Be @('g-existing') + (Get-Verdict -Expected $Prepared.Expected -Current $Prepared.Current).Count | Should -Be 0 + $Prepared.Current.campaignParams.IncludeTargets | Should -BeNullOrEmpty + } + + It 'the literal AllUsers entry targets everyone explicitly' { + Mock New-CIPPDbRequest { + if ($Type -eq 'AuthenticationMethodsPolicy') { @($script:Campaign | ConvertTo-Cached) } else { @() } + } + $Item = [PSCustomObject]@{ Variables = [PSCustomObject]@{ state = 'enabled'; snoozeDurationInDays = 1; enforceRegistrationAfterAllowedSnoozes = $true; includeTargets = 'AllUsers' } } + $Prepared = Get-CIPPBaselineNudgeMFAState -Item $Item -TenantFilter $script:Tenant + $Prepared.Expected.includeTargetIds | Should -Be @('all_users') + (Get-Verdict -Expected $Prepared.Expected -Current $Prepared.Current).Count | Should -BeGreaterThan 0 + } + + It 'reports No Data when a configured group entry resolves to nothing' { + Mock New-CIPPDbRequest { + if ($Type -eq 'AuthenticationMethodsPolicy') { @($script:Campaign | ConvertTo-Cached) } else { @(@{ id = 'g1'; displayName = 'Some Other Group' } | ConvertTo-Cached) } + } + $Item = [PSCustomObject]@{ Variables = [PSCustomObject]@{ state = 'enabled'; snoozeDurationInDays = 1; enforceRegistrationAfterAllowedSnoozes = $true; includeTargets = 'Nonexistent Group' } } + (Get-CIPPBaselineNudgeMFAState -Item $Item -TenantFilter $script:Tenant).Current | Should -BeNullOrEmpty + } +} + +Describe 'Get-CIPPBaselineAuthenticationMethodsState' { + BeforeAll { + $script:AuthPolicy = @{ authenticationMethodConfigurations = @( + @{ id = 'MicrosoftAuthenticator'; state = 'enabled'; isSoftwareOathEnabled = $false; includeTargets = @(@{ id = 'all_users'; targetType = 'group' }); featureSettings = @{} } + @{ id = 'Sms'; state = 'enabled'; includeTargets = @(@{ id = 'all_users'; targetType = 'group' }) } + ) } + } + + It 'grades ONLY the methods the baseline configures - unmanaged methods are invisible' { + # SMS is enabled in the tenant but unconfigured in the baseline: grading it would + # invent an opinion the operator never expressed. + Mock New-CIPPDbRequest { @($script:AuthPolicy | ConvertTo-Cached) } + $Item = [PSCustomObject]@{ Variables = [PSCustomObject]@{ MicrosoftAuthenticatorEnabled = $true } } + $Prepared = Get-CIPPBaselineAuthenticationMethodsState -Item $Item -TenantFilter $script:Tenant + @($Prepared.Current.methodsOutOfPolicy).Count | Should -Be 0 + (Get-Verdict -Expected $Prepared.Expected -Current $Prepared.Current).Count | Should -Be 0 + } + + It 'a drifted method contributes named drifts AND a remediation parameter set' { + Mock New-CIPPDbRequest { @($script:AuthPolicy | ConvertTo-Cached) } + $Item = [PSCustomObject]@{ Variables = [PSCustomObject]@{ SMSEnabled = $false } } + $Prepared = Get-CIPPBaselineAuthenticationMethodsState -Item $Item -TenantFilter $script:Tenant + $Prepared.Current.methodsOutOfPolicy | Should -Match 'SMS' + @($Prepared.Current.remediationSets).Count | Should -Be 1 + $Prepared.Current.remediationSets[0].Params.AuthenticationMethodId | Should -Be 'SMS' + $Prepared.Current.remediationSets[0].Params.Enabled | Should -BeFalse + } + + It 'writes each drifted method through the shared policy helper, compliant ones untouched' { + Mock Set-CIPPAuthenticationPolicy { } + $Current = [PSCustomObject]@{ remediationSets = @([PSCustomObject]@{ Label = 'SMS'; Params = @{ AuthenticationMethodId = 'SMS'; Enabled = $false } }) } + 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' { + BeforeAll { + # The OPERATOR profile is deliberately first: a hook that grabs the first profile + # instead of resolving defaultPasskeyProfile grades the wrong object and fails here. + $script:Fido2 = @{ defaultPasskeyProfile = 'p-default'; passkeyProfiles = @( + @{ id = 'p-other'; name = 'Operator profile'; passkeyTypes = 'synced'; attestationEnforcement = 'disabled'; keyRestrictions = @{ isEnforced = $true; enforcementType = 'allow'; aaGuids = @('guid-1') } } + @{ id = 'p-default'; name = 'Default'; passkeyTypes = 'deviceBound'; attestationEnforcement = 'registrationOnly'; keyRestrictions = @{ isEnforced = $false; enforcementType = 'block'; aaGuids = @() } } + ) } + } + + It 'grades the DEFAULT profile only - operator profiles are never graded' { + # The operator profile (p-other) differs on every field; only the default profile + # participates. Note enforcementType grades even while restrictions are off - the + # classic graded the dormant field, and Graph defaults it to 'block'. + Mock New-CIPPDbRequest { @($script:Fido2 | ConvertTo-Cached) } + $Item = [PSCustomObject]@{ Variables = [PSCustomObject]@{ PasskeyTypes = 'deviceBound'; AttestationEnforcement = 'registrationOnly'; EnforceKeyRestrictions = $false; EnforcementType = 'block' } } + $Prepared = Get-CIPPBaselineFIDO2PasskeyProfilesState -Item $Item -TenantFilter $script:Tenant + (Get-Verdict -Expected $Prepared.Expected -Current $Prepared.Current).Count | Should -Be 0 + } + + It 'refuses key restrictions with no AAGUIDs - allow mode would lock every authenticator out' { + Mock New-CIPPDbRequest { @($script:Fido2 | ConvertTo-Cached) } + $Item = [PSCustomObject]@{ Variables = [PSCustomObject]@{ PasskeyTypes = 'deviceBound'; AttestationEnforcement = 'registrationOnly'; EnforceKeyRestrictions = $true } } + (Get-CIPPBaselineFIDO2PasskeyProfilesState -Item $Item -TenantFilter $script:Tenant).Current | Should -BeNullOrEmpty + } + + It 'rewrites the default profile while resending every other profile untouched' { + Mock New-GraphPostRequest { } + $Current = [PSCustomObject]@{ + defaultProfileId = 'p-default' + allProfiles = @($script:Fido2.passkeyProfiles | ConvertTo-Cached) + } + Invoke-CIPPBaselineFIDO2PasskeyProfiles -Remediate ([PSCustomObject]@{ passkeyTypes = 'deviceBound'; attestationEnforcement = 'registrationOnly'; enforceKeyRestrictions = $false }) -TenantFilter $script:Tenant -Current $Current + Should -Invoke New-GraphPostRequest -Times 1 -Exactly -ParameterFilter { + $type -eq 'PATCH' -and $AsApp -eq $true -and $body -match 'p-other' -and $body -match 'guid-1' + } + } +} + +Describe 'Get-CIPPBaselineOauthConsentState' { + It 'grades additively: includes an operator added are never drift' { + Mock New-CIPPDbRequest { @(@{ permissionGrantPolicyIdsAssignedToDefaultUserRole = @('ManagePermissionGrantsForSelf.cipp-consent-policy') } | ConvertTo-Cached) } + Mock New-GraphGetRequest { @( + (@{ permissionType = 'delegated'; clientApplicationIds = @('00b41c95-dab0-4487-9791-b9d2c32c80f2') } | ConvertTo-Cached), + (@{ permissionType = 'delegated'; clientApplicationIds = @('operator-added-app') } | ConvertTo-Cached) + ) } + $Item = [PSCustomObject]@{ Variables = [PSCustomObject]@{} } + $Prepared = Get-CIPPBaselineOauthConsentState -Item $Item -TenantFilter $script:Tenant + (Get-Verdict -Expected $Prepared.Expected -Current $Prepared.Current).Count | Should -Be 0 + } + + It 'requires BOTH a delegated and an application include per allowed app' { + Mock New-CIPPDbRequest { @(@{ permissionGrantPolicyIdsAssignedToDefaultUserRole = @('ManagePermissionGrantsForSelf.cipp-consent-policy') } | ConvertTo-Cached) } + Mock New-GraphGetRequest { @( + (@{ permissionType = 'delegated'; clientApplicationIds = @('00b41c95-dab0-4487-9791-b9d2c32c80f2') } | ConvertTo-Cached), + (@{ permissionType = 'delegated'; clientApplicationIds = @('app-1') } | ConvertTo-Cached) + ) } + $Item = [PSCustomObject]@{ Variables = [PSCustomObject]@{ AllowedApps = 'app-1' } } + $Prepared = Get-CIPPBaselineOauthConsentState -Item $Item -TenantFilter $script:Tenant + $Prepared.Current.missingIncludes | Should -Be @('application|app-1') + } +} + +Describe 'Get-CIPPBaselineDisableSelfServiceLicensesState' { + BeforeAll { + $script:Products = @( + @{ productId = 'CFQ7TTC0KP0N'; productName = 'Power Automate'; policyValue = 'Enabled' } + @{ productId = 'CFQ7TTC0KXG7'; productName = 'Power BI Pro'; policyValue = 'Disabled' } + @{ productId = 'autoclaim'; productName = 'Trial Autoclaim'; policyValue = 'Enabled' } + ) + } + + It 'excluded product ids are expected Enabled, everything else Disabled' { + Mock New-CIPPDbRequest { + if ($Type -eq 'SelfServicePurchaseProducts') { @($script:Products | ConvertTo-Cached) } + else { @(@{ allowedToSignUpEmailBasedSubscriptions = $false } | ConvertTo-Cached) } + } + $Item = [PSCustomObject]@{ Variables = [PSCustomObject]@{ Exclusions = 'CFQ7TTC0KP0N' } } + $Prepared = Get-CIPPBaselineDisableSelfServiceLicensesState -Item $Item -TenantFilter $script:Tenant + @($Prepared.Current.productsOutOfPolicy).Count | Should -Be 0 + } + + It 'grades autoclaim only when trials are disabled, and routes each offender to its endpoint' { + Mock New-CIPPDbRequest { + if ($Type -eq 'SelfServicePurchaseProducts') { @($script:Products | ConvertTo-Cached) } + else { @(@{ allowedToSignUpEmailBasedSubscriptions = $true } | ConvertTo-Cached) } + } + $Item = [PSCustomObject]@{ Variables = [PSCustomObject]@{ DisableTrials = $true } } + $Prepared = Get-CIPPBaselineDisableSelfServiceLicensesState -Item $Item -TenantFilter $script:Tenant + $Prepared.Current.productsOutOfPolicy | Should -Contain 'Trial Autoclaim' + $Prepared.Current.productsOutOfPolicy | Should -Contain 'Email Based Subscriptions' + Mock New-GraphPostRequest { } + Invoke-CIPPBaselineDisableSelfServiceLicenses -Remediate $null -TenantFilter $script:Tenant -Current $Prepared.Current + Should -Invoke New-GraphPostRequest -ParameterFilter { $uri -match 'autoclaim' } + Should -Invoke New-GraphPostRequest -ParameterFilter { $uri -match 'authorizationPolicy' -and $type -eq 'PATCH' } + Should -Invoke New-GraphPostRequest -ParameterFilter { $uri -match 'licensing.m365.microsoft.com' -and $type -eq 'PUT' } + } +} + +Describe 'Get-CIPPBaselineCopilotLimitedModeState' { + It 'disabled posture grades the flag alone - no group needed' { + Mock New-CIPPDbRequest { @(@{ isEnabledForGroup = $false; groupId = $null } | ConvertTo-Cached) } + $Item = [PSCustomObject]@{ Variables = [PSCustomObject]@{ LimitedModeEnabled = $false } } + $Prepared = Get-CIPPBaselineCopilotLimitedModeState -Item $Item -TenantFilter $script:Tenant + (Get-Verdict -Expected $Prepared.Expected -Current $Prepared.Current).Count | Should -Be 0 + } + + It 'enabled posture with an unresolvable group reports No Data, never a fake verdict' { + Mock New-CIPPDbRequest { + if ($Type -eq 'CopilotAdminSettings') { @(@{ isEnabledForGroup = $false; groupId = $null } | ConvertTo-Cached) } else { @() } + } + $Item = [PSCustomObject]@{ Variables = [PSCustomObject]@{ LimitedModeEnabled = $true; GroupName = 'Missing Group' } } + (Get-CIPPBaselineCopilotLimitedModeState -Item $Item -TenantFilter $script:Tenant).Current | Should -BeNullOrEmpty + } +} diff --git a/Tests/Baselines/BaselineExchangeBatch.Tests.ps1 b/Tests/Baselines/BaselineExchangeBatch.Tests.ps1 new file mode 100644 index 0000000000000..c3fabe6f1b671 --- /dev/null +++ b/Tests/Baselines/BaselineExchangeBatch.Tests.ps1 @@ -0,0 +1,302 @@ +# Backlog batch 2: the Exchange cluster. Tests pin the normalization and grading decisions +# that fail silently - wrong-shaped duration compares, per-language contains semantics, +# additive-vs-strict list ownership, and the link write that must never unlink other tags. +# Also hosts the TeamsFederation single-domain array-shape regression. + +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) } + function Get-CIPPDbItem { param($TenantFilter, $Type, [switch]$CountsOnly) } + function New-ExoRequest { param($tenantid, $cmdlet, $cmdParams, $useSystemMailbox) } + function New-ExoBulkRequest { param($tenantid, $cmdletArray, $useSystemMailbox, $ReturnWithCommand) } + function Get-CIPPTextReplacement { param($TenantFilter, $Text) $Text } + function Get-NormalizedError { param($Message) "$Message" } + + . (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 'Test-CIPPBaselineCacheCollected.ps1') + foreach ($Name in @('GlobalQuarantineSettings', 'GlobalQuarantineNotifications', 'UserSubmissions', 'RetentionPolicyTag', + 'SendReceiveLimitTenant', 'AddDKIM', 'RotateDKIM', 'PhishSimSpoofIntelligence', 'PhishingSimulations')) { + . (Join-Path $Baselines "Get-CIPPBaseline${Name}State.ps1") + . (Join-Path $Baselines "Invoke-CIPPBaseline${Name}.ps1") + } + + $script:Tenant = 'contoso.onmicrosoft.com' + function ConvertTo-Cached { param([Parameter(ValueFromPipeline = $true)]$InputObject) process { $InputObject | ConvertTo-Json -Depth 20 | ConvertFrom-Json } } + 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 { $_ }) + } + Mock Get-CIPPDbItem { [PSCustomObject]@{ RowKey = 'X-Count'; DataCount = 1 } } +} + +Describe 'Get-CIPPBaselineGlobalQuarantineSettingsState' { + BeforeAll { + $script:GqPolicy = @{ Name = 'DefaultGlobalTag'; Identity = 'DefaultGlobalTag'; MultiLanguageSetting = @('Default', 'Dutch') + MultiLanguageSenderName = @('IT Alerts', 'IT Meldingen'); ESNCustomSubject = @('Quarantined mail', 'Bericht in quarantaine') + MultiLanguageCustomDisclaimer = @(); EndUserSpamNotificationCustomFromAddress = 'alerts@contoso.com'; OrganizationBrandingEnabled = $true } + } + + It 'grades a per-language text as contains: present in ANY language slot is compliant' { + # The configured value sits in the SECOND language slot on purpose - a first-slot + # equality compare would miss it and report drift the write cannot clear. + Mock New-CIPPDbRequest { @($script:GqPolicy | ConvertTo-Cached) } + $Item = [PSCustomObject]@{ Variables = [PSCustomObject]@{ SenderName = 'IT Meldingen'; OrganizationBrandingEnabled = $true } } + $Prepared = Get-CIPPBaselineGlobalQuarantineSettingsState -Item $Item -TenantFilter $script:Tenant + (Get-Verdict -Expected $Prepared.Expected -Current $Prepared.Current).Count | Should -Be 0 + } + + It 'does not grade fields the baseline leaves empty - the classic graded them as permanent drift' { + Mock New-CIPPDbRequest { @($script:GqPolicy | ConvertTo-Cached) } + $Item = [PSCustomObject]@{ Variables = [PSCustomObject]@{ OrganizationBrandingEnabled = $true } } + $Prepared = Get-CIPPBaselineGlobalQuarantineSettingsState -Item $Item -TenantFilter $script:Tenant + $Prepared.Expected.PSObject.Properties.Name | Should -Not -Contain 'senderNamePresent' + (Get-Verdict -Expected $Prepared.Expected -Current $Prepared.Current).Count | Should -Be 0 + } + + It 'sends ALL THREE per-language arrays at equal counts, preserving unconfigured values' { + # Exchange rejects the write outright unless sender, subject and disclaimer arrays + # all match the language count - and the unconfigured subject must resend the + # tenant's existing values, not blanks. + Mock New-ExoRequest { } + $Current = [PSCustomObject]@{ + languages = @('Default', 'Dutch'); policyName = 'DefaultGlobalTag'; policyIdentity = 'DefaultGlobalTag' + currentSenderNames = @('Old', 'Oud'); currentSubjects = @('Quarantined mail', 'Bericht in quarantaine'); currentDisclaimers = @() + } + Invoke-CIPPBaselineGlobalQuarantineSettings -Remediate ([PSCustomObject]@{ senderName = 'IT Alerts'; organizationBrandingEnabled = $true }) -TenantFilter $script:Tenant -Current $Current + Should -Invoke New-ExoRequest -Times 1 -Exactly -ParameterFilter { + $cmdlet -eq 'Set-QuarantinePolicy' -and + @($cmdParams.MultiLanguageSenderName).Count -eq 2 -and $cmdParams.MultiLanguageSenderName[0] -eq 'IT Alerts' -and + @($cmdParams.ESNCustomSubject).Count -eq 2 -and $cmdParams.ESNCustomSubject[1] -eq 'Bericht in quarantaine' -and + @($cmdParams.MultiLanguageCustomDisclaimer).Count -eq 2 + } + } + + It 'creates the custom global policy when the tenant still runs the Microsoft default' { + Mock New-ExoRequest { } + $Current = [PSCustomObject]@{ languages = @('Default'); policyName = 'DefaultGlobalPolicy'; policyIdentity = 'DefaultGlobalPolicy'; currentSenderNames = @(); currentSubjects = @(); currentDisclaimers = @() } + Invoke-CIPPBaselineGlobalQuarantineSettings -Remediate ([PSCustomObject]@{ organizationBrandingEnabled = $false }) -TenantFilter $script:Tenant -Current $Current + Should -Invoke New-ExoRequest -Times 1 -Exactly -ParameterFilter { $cmdlet -eq 'New-QuarantinePolicy' -and $cmdParams.Name -eq 'DefaultGlobalTag' } + } +} + +Describe 'Get-CIPPBaselineGlobalQuarantineNotificationsState' { + It 'normalizes the ISO duration and the timespan string to the same hours' { + Mock New-CIPPDbRequest { @(@{ Name = 'DefaultGlobalTag'; Identity = 'x'; EndUserSpamNotificationFrequency = 'P1D' } | ConvertTo-Cached) } + $Item = [PSCustomObject]@{ Variables = [PSCustomObject]@{ NotificationInterval = '1.00:00:00' } } + $Prepared = Get-CIPPBaselineGlobalQuarantineNotificationsState -Item $Item -TenantFilter $script:Tenant + (Get-Verdict -Expected $Prepared.Expected -Current $Prepared.Current).Count | Should -Be 0 + } + + It 'grades an unrecognized tenant interval as drift, never as compliant' { + Mock New-CIPPDbRequest { @(@{ Name = 'DefaultGlobalTag'; Identity = 'x'; EndUserSpamNotificationFrequency = 'PT15M' } | ConvertTo-Cached) } + $Item = [PSCustomObject]@{ Variables = [PSCustomObject]@{ NotificationInterval = '04:00:00' } } + $Prepared = Get-CIPPBaselineGlobalQuarantineNotificationsState -Item $Item -TenantFilter $script:Tenant + (Get-Verdict -Expected $Prepared.Expected -Current $Prepared.Current).Count | Should -BeGreaterThan 0 + } +} + +Describe 'Get-CIPPBaselineUserSubmissionsState' { + It 'requires ALL THREE report types at the custom address, not just one' { + Mock New-CIPPDbRequest { + if ($Type -eq 'ReportSubmissionPolicy') { @(@{ EnableReportToMicrosoft = $true; ReportJunkToCustomizedAddress = $true; ReportJunkAddresses = @('soc@contoso.com'); ReportNotJunkToCustomizedAddress = $false; ReportNotJunkAddresses = @(); ReportPhishToCustomizedAddress = $true; ReportPhishAddresses = @('soc@contoso.com') } | ConvertTo-Cached) } + else { @(@{ State = 'Enabled'; SentTo = @('soc@contoso.com') } | ConvertTo-Cached) } + } + $Item = [PSCustomObject]@{ Variables = [PSCustomObject]@{ state = 'enable'; email = 'soc@contoso.com' } } + $Prepared = Get-CIPPBaselineUserSubmissionsState -Item $Item -TenantFilter $script:Tenant + $Prepared.Current.customAddressCorrect | Should -BeFalse + (Get-Verdict -Expected $Prepared.Expected -Current $Prepared.Current).Count | Should -BeGreaterThan 0 + } + + It 'treats no policy at all as compliant for the disable posture' { + Mock New-CIPPDbRequest { @() } + $Item = [PSCustomObject]@{ Variables = [PSCustomObject]@{ state = 'disable' } } + $Prepared = Get-CIPPBaselineUserSubmissionsState -Item $Item -TenantFilter $script:Tenant + (Get-Verdict -Expected $Prepared.Expected -Current $Prepared.Current).Count | Should -Be 0 + } + + It 'removes an enabled rule when turning reporting off, and only then' { + Mock New-ExoRequest { } + $Current = [PSCustomObject]@{ policyExists = $true; ruleExists = $true; ruleEnabled = $true; resolvedEmail = '' } + Invoke-CIPPBaselineUserSubmissions -Remediate ([PSCustomObject]@{ state = 'disable' }) -TenantFilter $script:Tenant -Current $Current + Should -Invoke New-ExoRequest -Times 1 -Exactly -ParameterFilter { $cmdlet -eq 'Remove-ReportSubmissionRule' } + } +} + +Describe 'Get-CIPPBaselineRetentionPolicyTagState' { + BeforeAll { + $script:GoodTag = @{ Identity = 'CIPP Deleted Items'; Name = 'CIPP Deleted Items'; RetentionEnabled = $true; RetentionAction = 'PermanentlyDelete'; AgeLimitForRetention = '30.00:00:00'; Type = 'DeletedItems' } + } + + It 'grades the MRM policy link separately - an unlinked tag does nothing' { + Mock New-CIPPDbRequest { + if ($Type -eq 'ExoRetentionPolicyTags') { @($script:GoodTag | ConvertTo-Cached) } + else { @(@{ Identity = 'Default MRM Policy'; RetentionPolicyTagLinks = @('Junk Email', '1 Month Delete') } | ConvertTo-Cached) } + } + $Item = [PSCustomObject]@{ Variables = [PSCustomObject]@{ AgeLimitForRetention = 30 } } + $Prepared = Get-CIPPBaselineRetentionPolicyTagState -Item $Item -TenantFilter $script:Tenant + $Prepared.Current.linkedToPolicy | Should -BeFalse + (Get-Verdict -Expected $Prepared.Expected -Current $Prepared.Current).Count | Should -BeGreaterThan 0 + } + + It 'resends the FULL link list plus ours - sending only the new tag unlinks everything else' { + Mock New-ExoRequest { } + $Current = [PSCustomObject]@{ tagExists = $true; linkedToPolicy = $false; existingLinks = @('Junk Email', '1 Month Delete') } + Invoke-CIPPBaselineRetentionPolicyTag -Remediate ([PSCustomObject]@{ ageLimitForRetention = 30 }) -TenantFilter $script:Tenant -Current $Current + Should -Invoke New-ExoRequest -Times 1 -Exactly -ParameterFilter { + $cmdlet -eq 'Set-RetentionPolicy' -and @($cmdParams.RetentionPolicyTagLinks).Count -eq 3 -and $cmdParams.RetentionPolicyTagLinks -contains 'Junk Email' + } + } +} + +Describe 'Get-CIPPBaselineSendReceiveLimitTenantState' { + It 'parses the display-string byte counts and treats Unlimited as an offender' { + Mock New-CIPPDbRequest { @( + (@{ DisplayName = 'ExchangeOnlineEnterprise'; MaxSendSize = '35 MB (36,700,160 bytes)'; MaxReceiveSize = '36 MB (37,748,736 bytes)'; Guid = 'g1' } | ConvertTo-Cached), + (@{ DisplayName = 'ExchangeOnlineDeskless'; MaxSendSize = 'Unlimited'; MaxReceiveSize = '36 MB (37,748,736 bytes)'; Guid = 'g2' } | ConvertTo-Cached) + ) } + $Item = [PSCustomObject]@{ Variables = [PSCustomObject]@{ SendLimit = 35; ReceiveLimit = 36 } } + $Prepared = Get-CIPPBaselineSendReceiveLimitTenantState -Item $Item -TenantFilter $script:Tenant + $Prepared.Current.plansOffLimits | Should -Be @('ExchangeOnlineDeskless') + $Prepared.Current.offenderGuids | Should -Be @('g2') + } +} + +Describe 'Get-CIPPBaselineAddDKIMState' { + It 'excludes service domains and splits create-vs-enable for the executor' { + Mock New-CIPPDbRequest { + if ($Type -eq 'ExoAcceptedDomains') { @( + (@{ DomainName = 'contoso.com' } | ConvertTo-Cached), + (@{ DomainName = 'contoso.mail.onmicrosoft.com' } | ConvertTo-Cached), + (@{ DomainName = 'fabrikam.com' } | ConvertTo-Cached) + ) } + else { @(@{ Domain = 'fabrikam.com'; Enabled = $false } | ConvertTo-Cached) } + } + $Item = [PSCustomObject]@{ Variables = [PSCustomObject]@{} } + $Prepared = Get-CIPPBaselineAddDKIMState -Item $Item -TenantFilter $script:Tenant + $Prepared.Current.domainsToCreate | Should -Be @('contoso.com') + $Prepared.Current.domainsToEnable | Should -Be @('fabrikam.com') + $Prepared.Current.domainsWithoutDkim | Should -Not -Contain 'contoso.mail.onmicrosoft.com' + } +} + +Describe 'Get-CIPPBaselineRotateDKIMState' { + It 'grades only ENABLED configs on 1024-bit keys - rotating a disabled config does nothing' { + Mock New-CIPPDbRequest { @( + (@{ Identity = 'contoso.com'; Selector1KeySize = 1024; Selector2KeySize = 2048; Enabled = $true } | ConvertTo-Cached), + (@{ Identity = 'fabrikam.com'; Selector1KeySize = 1024; Selector2KeySize = 1024; Enabled = $false } | ConvertTo-Cached), + (@{ Identity = 'tailspin.com'; Selector1KeySize = 2048; Selector2KeySize = 2048; Enabled = $true } | ConvertTo-Cached) + ) } + $Item = [PSCustomObject]@{ Variables = [PSCustomObject]@{} } + (Get-CIPPBaselineRotateDKIMState -Item $Item -TenantFilter $script:Tenant).Current.domainsWith1024BitDkim | Should -Be @('contoso.com') + } +} + +Describe 'Get-CIPPBaselinePhishSimSpoofIntelligenceState' { + It 'is additive by default: hand-added spoof allowances are not drift' { + Mock New-CIPPDbRequest { @( + (@{ Identity = 'id1'; SendingInfrastructure = 'sim.knowbe4.com' } | ConvertTo-Cached), + (@{ Identity = 'id2'; SendingInfrastructure = 'operator-added.example' } | ConvertTo-Cached) + ) } + $Item = [PSCustomObject]@{ Variables = [PSCustomObject]@{ AllowedDomains = @('sim.knowbe4.com'); RemoveExtraDomains = $false } } + $Prepared = Get-CIPPBaselinePhishSimSpoofIntelligenceState -Item $Item -TenantFilter $script:Tenant + (Get-Verdict -Expected $Prepared.Expected -Current $Prepared.Current).Count | Should -Be 0 + } + + It 'strict mode grades extras and carries their item ids for removal' { + Mock New-CIPPDbRequest { @(@{ Identity = 'id2'; SendingInfrastructure = 'operator-added.example' } | ConvertTo-Cached) } + $Item = [PSCustomObject]@{ Variables = [PSCustomObject]@{ AllowedDomains = @('sim.knowbe4.com'); RemoveExtraDomains = $true } } + $Prepared = Get-CIPPBaselinePhishSimSpoofIntelligenceState -Item $Item -TenantFilter $script:Tenant + $Prepared.Current.extraDomains | Should -Contain 'operator-added.example' + $Prepared.Current.extraItemIds | Should -Be @('id2') + } + + It 'writes each missing domain as BOTH Internal and External spoof types' { + Mock New-ExoBulkRequest { @() } + $Current = [PSCustomObject]@{ missingDomains = @('sim.knowbe4.com'); extraItemIds = @() } + Invoke-CIPPBaselinePhishSimSpoofIntelligence -Remediate $null -TenantFilter $script:Tenant -Current $Current + Should -Invoke New-ExoBulkRequest -Times 1 -Exactly -ParameterFilter { + @($cmdletArray).Count -eq 2 -and @($cmdletArray.CmdletInput.Parameters.SpoofType) -contains 'Internal' -and @($cmdletArray.CmdletInput.Parameters.SpoofType) -contains 'External' + } + } +} + +Describe 'Get-CIPPBaselinePhishingSimulationsState' { + BeforeAll { + $script:PsItem = [PSCustomObject]@{ Variables = [PSCustomObject]@{ + Domains = @('sim.example.com'); SenderIpRanges = @('10.1.1.0/24'); PhishingSimUrls = @('https://sim.example.com/*'); RemoveExtraUrls = $false } } + } + + It 'grades the three legs separately so the drift row names the broken one' { + Mock New-CIPPDbRequest { + switch ($Type) { + 'ExoPhishSimOverridePolicy' { @(@{ Name = 'PhishSimOverridePolicy'; Enabled = $true } | ConvertTo-Cached) } + 'ExoPhishSimOverrideRule' { @(@{ Name = 'PhishSimOverrideRule'; Identity = 'r1'; SenderIpRanges = @('10.1.1.0/24'); Domains = @() } | ConvertTo-Cached) } + default { @(@{ Value = 'https://sim.example.com/*' } | ConvertTo-Cached) } + } + } + $Prepared = Get-CIPPBaselinePhishingSimulationsState -Item $script:PsItem -TenantFilter $script:Tenant + $Prepared.Current.policyEnabled | Should -BeTrue + $Prepared.Current.missingDomains | Should -Be @('sim.example.com') + @($Prepared.Current.missingUrls).Count | Should -Be 0 + (Get-Verdict -Expected $Prepared.Expected -Current $Prepared.Current).Count | Should -BeGreaterThan 0 + } + + It 'uses add/remove DELTAS on an existing rule, never replacement lists' { + Mock New-ExoRequest { } + $Current = [PSCustomObject]@{ + policyEnabled = $true; policyExists = $true; ruleExists = $true; ruleIdentity = 'r1' + missingDomains = @('sim.example.com'); missingSenderIpRanges = @(); extraDomains = @('old.example.com'); extraSenderIpRanges = @() + missingUrls = @(); extraUrls = @() + } + Invoke-CIPPBaselinePhishingSimulations -Remediate ([PSCustomObject]@{ domains = @('sim.example.com'); senderIpRanges = @('10.1.1.0/24') }) -TenantFilter $script:Tenant -Current $Current + Should -Invoke New-ExoRequest -Times 1 -Exactly -ParameterFilter { + $cmdlet -eq 'Set-ExoPhishSimOverrideRule' -and $cmdParams.AddDomains -contains 'sim.example.com' -and $cmdParams.RemoveDomains -contains 'old.example.com' + } + } +} + +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/Tests/Baselines/BaselineExecutors.Tests.ps1 b/Tests/Baselines/BaselineExecutors.Tests.ps1 new file mode 100644 index 0000000000000..40f3b7ac7d6b1 --- /dev/null +++ b/Tests/Baselines/BaselineExecutors.Tests.ps1 @@ -0,0 +1,514 @@ +# 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-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' + + # 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 + } +} + +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 } + } +} + +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]) + } +} + +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' } + } +} + +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' } + } + + 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/Tests/Baselines/BaselineIntuneDeployers.Tests.ps1 b/Tests/Baselines/BaselineIntuneDeployers.Tests.ps1 new file mode 100644 index 0000000000000..490a7a8b54290 --- /dev/null +++ b/Tests/Baselines/BaselineIntuneDeployers.Tests.ps1 @@ -0,0 +1,416 @@ +# Backlog batch 5: the Intune policy deployers. Tests pin the parsing and gating decisions +# that fail silently - the settings catalog choice-suffix conventions, ASR presence-as-enabled, +# only-configured remediation grading, the Windows partner-data force rule, self-deploying +# mode's derivations, repair-in-place vs recreate, and the Chrome app's fingerprint gate. + +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, $Fields) } + function Write-LogMessage { param($API, $tenant, $message, $Sev, $LogData) } + function Get-CIPPDbItem { param($TenantFilter, $Type, [switch]$CountsOnly) } + function New-GraphGetRequest { param($uri, $tenantid, $AsApp) } + function New-GraphPostRequest { param($uri, $tenantid, $type, $body, $AsApp, $ContentType) } + function New-GraphBulkRequest { param($tenantid, $Requests) } + function Set-CIPPDefenderAVPolicy { param($TenantFilter, $PolicySettings, $APIName) } + function Set-CIPPDefenderASRPolicy { param($TenantFilter, $ASR, $APIName) } + function Set-CIPPDefenderEDRPolicy { param($TenantFilter, $EDR, $APIName) } + function Set-CIPPDefenderExclusionPolicy { param($TenantFilter, $DefenderExclusions, $APIName) } + function Enable-CIPPMDEConnector { param($TenantFilter) } + function Set-CIPPDefaultAPDeploymentProfile { param($TenantFilter, $DisplayName, $Description, $UserType, $DeploymentMode, $AssignTo, $DeviceNameTemplate, $AllowWhiteGlove, $CollectHash, $HideChangeAccount, $HidePrivacy, $HideTerms, $AutoKeyboard, $Language) } + function Get-CIPPIntuneAssignmentTarget { param($AssignTo, $PolicyType) } + function Compare-CIPPIntuneAssignments { param($ExistingAssignments, $ExpectedAssignTo, $PolicyType, $TenantFilter) } + function Add-CIPPW32ScriptApplication { param($TenantFilter, $Properties) } + function Set-CIPPAssignedApplication { param($ApplicationId, $TenantFilter, $GroupName, $ExcludeGroup, $Intent, $AppType, $APIName) } + function New-CIPPApplicationCopy { param($App, $Tenant) } + function Add-CIPPDelegatedPermission { param($RequiredResourceAccess, $ApplicationId, $TemplateId, $TenantFilter) } + function Add-CIPPApplicationPermission { param($RequiredResourceAccess, $ApplicationId, $TemplateId, $TenantFilter) } + function Get-CippTable { param($tablename) @{} } + function Get-CIPPAzDataTableEntity { param($Filter) } + function Get-CIPPTextReplacement { param($TenantFilter, $Text) $Text } + function Get-NormalizedError { param($Message) "$Message" } + + . (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 'Test-CIPPBaselineCacheCollected.ps1') + foreach ($Name in @('DefenderAVPolicy', 'DefenderASRPolicy', 'DefenderEDRPolicy', 'DefenderExclusionPolicy', + 'DefenderCompliancePolicy', 'AutopilotProfile', 'DevicePrepProfile', 'DeployCheckChromeExtension', 'AppDeploy')) { + . (Join-Path $Baselines "Get-CIPPBaseline${Name}State.ps1") + . (Join-Path $Baselines "Invoke-CIPPBaseline${Name}.ps1") + } + + $script:Tenant = 'contoso.onmicrosoft.com' + function ConvertTo-Cached { param([Parameter(ValueFromPipeline = $true)]$InputObject) process { $InputObject | ConvertTo-Json -Depth 25 | ConvertFrom-Json } } + 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 { $_ }) + } + function New-ChoiceSetting { param($DefId, $Value) @{ settingInstance = @{ settingDefinitionId = $DefId; choiceSettingValue = @{ value = $Value } } } } + Mock Get-CIPPDbItem { [PSCustomObject]@{ RowKey = 'X-Count'; DataCount = 1 } } +} + +Describe 'Get-CIPPBaselineDefenderAVPolicyState' { + BeforeAll { + $DP = 'device_vendor_msft_policy_config_defender' + $script:AvPolicy = @{ id = 'av-1'; name = 'Default AV Policy'; settings = @( + (New-ChoiceSetting "${DP}_allowarchivescanning" "${DP}_allowarchivescanning_1") + (New-ChoiceSetting "${DP}_allowrealtimemonitoring" "${DP}_allowrealtimemonitoring_1") + (New-ChoiceSetting "${DP}_cloudblocklevel" "${DP}_cloudblocklevel_2") + @{ settingInstance = @{ settingDefinitionId = "${DP}_avgcpuloadfactor"; simpleSettingValue = @{ value = 50 } } } + @{ settingInstance = @{ settingDefinitionId = "${DP}_threatseveritydefaultaction"; groupSettingCollectionValue = @(@{ children = @( + @{ settingDefinitionId = "${DP}_threatseveritydefaultaction_lowseveritythreats"; choiceSettingValue = @{ value = 'x_quarantine' } } + ) }) } } + ) } + } + + It 'parses the choice-suffix conventions: _1 booleans, choice suffixes, integers, remediation children' { + Mock New-CIPPDbRequest { @($script:AvPolicy | ConvertTo-Cached) } + $Item = [PSCustomObject]@{ Variables = [PSCustomObject]@{ ScanArchives = $true; AllowRealTime = $true; CloudBlockLevel = [PSCustomObject]@{ value = '2' }; AvgCPULoadFactor = 50; RemediationLow = 'quarantine' } } + $Prepared = Get-CIPPBaselineDefenderAVPolicyState -Item $Item -TenantFilter $script:Tenant + $Prepared.Current.scanArchives | Should -BeTrue + $Prepared.Current.cloudBlockLevel | Should -Be '2' + $Prepared.Current.avgCPULoadFactor | Should -Be 50 + $Prepared.Current.remediationLow | Should -Be 'quarantine' + } + + It 'grades a remediation action ONLY when the baseline configures it - the classic compared conditionally' { + Mock New-CIPPDbRequest { @($script:AvPolicy | ConvertTo-Cached) } + $Item = [PSCustomObject]@{ Variables = [PSCustomObject]@{ ScanArchives = $true } } + $Prepared = Get-CIPPBaselineDefenderAVPolicyState -Item $Item -TenantFilter $script:Tenant + $Prepared.Expected.PSObject.Properties.Name | Should -Not -Contain 'remediationLow' + $Prepared.Expected.PSObject.Properties.Name | Should -Not -Contain 'remediationSevere' + } + + It 'grades blank integers as the write-side defaults - the recreate writes 50/8, never 0' { + Mock New-CIPPDbRequest { @($script:AvPolicy | ConvertTo-Cached) } + $Item = [PSCustomObject]@{ Variables = [PSCustomObject]@{ ScanArchives = $true; AvgCPULoadFactor = ''; SignatureUpdateInterval = ''; CloudExtendedTimeout = '' } } + $Prepared = Get-CIPPBaselineDefenderAVPolicyState -Item $Item -TenantFilter $script:Tenant + $Prepared.Expected.avgCPULoadFactor | Should -Be 50 + $Prepared.Expected.signatureUpdateInterval | Should -Be 8 + $Prepared.Expected.cloudExtendedTimeout | Should -Be 0 + } + + It 'grades a choice setting ONLY when configured - the recreate omits blank ones' { + Mock New-CIPPDbRequest { @($script:AvPolicy | ConvertTo-Cached) } + $Item = [PSCustomObject]@{ Variables = [PSCustomObject]@{ ScanArchives = $true; EnableNetworkProtection = ''; SubmitSamplesConsent = $null } } + $Prepared = Get-CIPPBaselineDefenderAVPolicyState -Item $Item -TenantFilter $script:Tenant + $Prepared.Expected.PSObject.Properties.Name | Should -Not -Contain 'enableNetworkProtection' + $Prepared.Expected.PSObject.Properties.Name | Should -Not -Contain 'cloudBlockLevel' + $Prepared.Expected.PSObject.Properties.Name | Should -Not -Contain 'submitSamplesConsent' + } + + It 'grades a configured choice setting against the policy, absent setting as empty' { + Mock New-CIPPDbRequest { @($script:AvPolicy | ConvertTo-Cached) } + $Item = [PSCustomObject]@{ Variables = [PSCustomObject]@{ EnableNetworkProtection = '0'; CloudBlockLevel = [PSCustomObject]@{ value = '2' } } } + $Prepared = Get-CIPPBaselineDefenderAVPolicyState -Item $Item -TenantFilter $script:Tenant + $Prepared.Expected.enableNetworkProtection | Should -Be '0' + $Prepared.Current.enableNetworkProtection | Should -Be '' + $Prepared.Expected.cloudBlockLevel | Should -Be '2' + $Prepared.Current.cloudBlockLevel | Should -Be '2' + } + + It 'deletes the drifted policy before the helper recreates it' { + Mock New-GraphPostRequest { } + Mock Set-CIPPDefenderAVPolicy { 'ok' } + Invoke-CIPPBaselineDefenderAVPolicy -Remediate ([PSCustomObject]@{ scanArchives = $true }) -TenantFilter $script:Tenant -Current ([PSCustomObject]@{ policyId = 'av-1' }) + Should -Invoke New-GraphPostRequest -Times 1 -Exactly -ParameterFilter { $type -eq 'DELETE' -and $uri -like "*configurationPolicies('av-1')" } + Should -Invoke Set-CIPPDefenderAVPolicy -Times 1 -Exactly -ParameterFilter { $PolicySettings.ScanArchives -eq $true -and -not $PolicySettings.ContainsKey('Remediation') } + } +} + +Describe 'Get-CIPPBaselineDefenderASRPolicyState' { + BeforeAll { + $ASR = 'device_vendor_msft_policy_config_defender_attacksurfacereductionrules' + $script:AsrPolicy = @{ id = 'asr-1'; name = 'ASR Default rules'; settings = @( + @{ settingInstance = @{ settingDefinitionId = $ASR; groupSettingCollectionValue = @(@{ children = @( + @{ settingDefinitionId = "${ASR}_blockadobereaderfromcreatingchildprocesses"; choiceSettingValue = @{ value = 'x_block' } } + @{ settingDefinitionId = "${ASR}_blockrebootingmachineinsafemode"; choiceSettingValue = @{ value = 'x_block' } } + ) }) } } + ) } + } + + It 'a rule PRESENT in the group is enabled; the mode comes from the first rule' { + Mock New-CIPPDbRequest { @($script:AsrPolicy | ConvertTo-Cached) } + $Item = [PSCustomObject]@{ Variables = [PSCustomObject]@{ Mode = 'block'; BlockAdobeChild = $true; BlockSafeMode = $true } } + $Prepared = Get-CIPPBaselineDefenderASRPolicyState -Item $Item -TenantFilter $script:Tenant + $Prepared.Current.mode | Should -Be 'block' + $Prepared.Current.blockAdobeChild | Should -BeTrue + (Get-Verdict -Expected $Prepared.Expected -Current $Prepared.Current).Count | Should -Be 0 + } + + It 'a rule the baseline turns OFF but the policy carries is drift' { + Mock New-CIPPDbRequest { @($script:AsrPolicy | ConvertTo-Cached) } + $Item = [PSCustomObject]@{ Variables = [PSCustomObject]@{ Mode = 'block'; BlockAdobeChild = $true; BlockSafeMode = $false } } + $Prepared = Get-CIPPBaselineDefenderASRPolicyState -Item $Item -TenantFilter $script:Tenant + (Get-Verdict -Expected $Prepared.Expected -Current $Prepared.Current).Count | Should -BeGreaterThan 0 + } +} + +Describe 'Get-CIPPBaselineDefenderEDRPolicyState' { + It 'config is correct only when the configuration type is auto-from-connector' { + Mock New-CIPPDbRequest { @(@{ id = 'edr-1'; name = 'EDR Configuration'; settings = @( + (New-ChoiceSetting 'device_vendor_msft_windowsadvancedthreatprotection_configurationtype' 'x_onboardingblob') + (New-ChoiceSetting 'device_vendor_msft_windowsadvancedthreatprotection_configuration_samplesharing' 'x_1') + ) } | ConvertTo-Cached) } + $Item = [PSCustomObject]@{ Variables = [PSCustomObject]@{ Config = $true; SampleSharing = $true } } + $Prepared = Get-CIPPBaselineDefenderEDRPolicyState -Item $Item -TenantFilter $script:Tenant + $Prepared.Current.config | Should -BeFalse + $Prepared.Current.sampleSharing | Should -BeTrue + (Get-Verdict -Expected $Prepared.Expected -Current $Prepared.Current).Count | Should -BeGreaterThan 0 + } +} + +Describe 'Get-CIPPBaselineDefenderExclusionPolicyState' { + It 'grades the three collections as sorted sets - order never matters, extras are drift' { + Mock New-CIPPDbRequest { @(@{ id = 'excl-1'; name = 'Default AV Exclusion Policy'; settings = @( + @{ settingInstance = @{ settingDefinitionId = 'device_vendor_msft_policy_config_defender_excludedpaths'; simpleSettingCollectionValue = @(@{ value = 'C:\Temp' }, @{ value = 'C:\App' }) } } + ) } | ConvertTo-Cached) } + $Item = [PSCustomObject]@{ Variables = [PSCustomObject]@{ excludedPaths = 'C:\App, C:\Temp' } } + $Prepared = Get-CIPPBaselineDefenderExclusionPolicyState -Item $Item -TenantFilter $script:Tenant + (Get-Verdict -Expected $Prepared.Expected -Current $Prepared.Current).Count | Should -Be 0 + $Item2 = [PSCustomObject]@{ Variables = [PSCustomObject]@{ excludedPaths = 'C:\App' } } + $Prepared2 = Get-CIPPBaselineDefenderExclusionPolicyState -Item $Item2 -TenantFilter $script:Tenant + (Get-Verdict -Expected $Prepared2.Expected -Current $Prepared2.Current).Count | Should -BeGreaterThan 0 + } + + It 'ALL-EMPTY collections report No Data - a zero-exclusion policy never materializes' { + Mock New-CIPPDbRequest { @() } + $Item = [PSCustomObject]@{ Variables = [PSCustomObject]@{ excludedExtensions = ''; excludedPaths = ''; excludedProcesses = '' } } + (Get-CIPPBaselineDefenderExclusionPolicyState -Item $Item -TenantFilter $script:Tenant).Current | Should -BeNullOrEmpty + } + + It 'sends ONLY the configured collections to the helper' { + Mock New-GraphPostRequest { } + Mock Set-CIPPDefenderExclusionPolicy { 'ok' } + Invoke-CIPPBaselineDefenderExclusionPolicy -Remediate ([PSCustomObject]@{ excludedPaths = 'C:\Temp' }) -TenantFilter $script:Tenant -Current ([PSCustomObject]@{ policyId = '' }) + Should -Invoke Set-CIPPDefenderExclusionPolicy -Times 1 -Exactly -ParameterFilter { + $DefenderExclusions.ContainsKey('excludedPaths') -and -not $DefenderExclusions.ContainsKey('excludedExtensions') -and -not $DefenderExclusions.ContainsKey('excludedProcesses') + } + } +} + +Describe 'Get-CIPPBaselineDefenderCompliancePolicyState' { + It 'connecting Windows FORCES the Windows partner-data block on - Microsoft enforces it' { + Mock New-GraphGetRequest { [PSCustomObject]@{ windowsEnabled = $true; windowsDeviceBlockedOnMissingPartnerData = $true; microsoftDefenderForEndpointAttachEnabled = $true } } + $Item = [PSCustomObject]@{ Variables = [PSCustomObject]@{ ConnectWindows = $true; windowsDeviceBlockedOnMissingPartnerData = $false } } + $Prepared = Get-CIPPBaselineDefenderCompliancePolicyState -Item $Item -TenantFilter $script:Tenant + $Prepared.Expected.windowsDeviceBlockedOnMissingPartnerData | Should -BeTrue + $Prepared.Expected.microsoftDefenderForEndpointAttachEnabled | Should -BeTrue + } + + It 'a missing connector grades every surface false, not an error' { + Mock New-GraphGetRequest { throw 'not found' } + $Item = [PSCustomObject]@{ Variables = [PSCustomObject]@{ ConnectWindows = $true } } + $Prepared = Get-CIPPBaselineDefenderCompliancePolicyState -Item $Item -TenantFilter $script:Tenant + $Prepared.Current.windowsEnabled | Should -BeFalse + $Prepared.Current.connectorExists | Should -BeFalse + (Get-Verdict -Expected $Prepared.Expected -Current $Prepared.Current).Count | Should -BeGreaterThan 0 + } + + It 'refuses to write when the MDE connector cannot be enabled' { + Mock Enable-CIPPMDEConnector { [PSCustomObject]@{ Success = $false; ErrorMessage = 'no license' } } + Mock New-GraphPostRequest { } + { Invoke-CIPPBaselineDefenderCompliancePolicy -Remediate ([PSCustomObject]@{ connectWindows = $true }) -TenantFilter $script:Tenant -Current ([PSCustomObject]@{ connectorExists = $false }) } | Should -Throw + Should -Invoke New-GraphPostRequest -Times 0 -Exactly + } +} + +Describe 'Get-CIPPBaselineAutopilotProfileState' { + BeforeAll { + $script:ApProfile = @{ id = 'ap-1'; displayName = 'CIPP Autopilot'; description = 'd'; deviceNameTemplate = ''; locale = '' + preprovisioningAllowed = $false; hardwareHashExtractionEnabled = $true + outOfBoxExperienceSetting = @{ deviceUsageType = 'shared'; privacySettingsHidden = $true; eulaHidden = $true; keyboardSelectionPageSkipped = $true; userType = 'standard' } } + } + + It 'self-deploying mode forces White Glove off and drops userType from the grade' { + Mock New-CIPPDbRequest { @($script:ApProfile | ConvertTo-Cached) } + $Item = [PSCustomObject]@{ Variables = [PSCustomObject]@{ DisplayName = 'CIPP Autopilot'; Description = 'd'; SelfDeployingMode = $true; AllowWhiteGlove = $true; CollectHash = $true; HidePrivacy = $true; HideTerms = $true; AutoKeyboard = $true; NotLocalAdmin = $true } } + $Prepared = Get-CIPPBaselineAutopilotProfileState -Item $Item -TenantFilter $script:Tenant + $Prepared.Expected.preprovisioningAllowed | Should -BeFalse + $Prepared.Expected.PSObject.Properties.Name | Should -Not -Contain 'userType' + (Get-Verdict -Expected $Prepared.Expected -Current $Prepared.Current).Count | Should -Be 0 + } + + It 'an empty baseline language matches a profile with no locale' { + Mock New-CIPPDbRequest { @($script:ApProfile | ConvertTo-Cached) } + $Item = [PSCustomObject]@{ Variables = [PSCustomObject]@{ DisplayName = 'CIPP Autopilot'; Description = 'd'; SelfDeployingMode = $true; CollectHash = $true; HidePrivacy = $true; HideTerms = $true; AutoKeyboard = $true } } + $Prepared = Get-CIPPBaselineAutopilotProfileState -Item $Item -TenantFilter $script:Tenant + $Prepared.Current.locale | Should -Be $Prepared.Expected.locale + } + + It 'locale grades '''' and ''os-default'' as the SAME posture in every combination - Graph flips between them by write path' { + # Create normalizes '' to 'os-default'; update stores the literal ''. Grading them + # apart flip-flops forever, so OS-default intent tolerates both representations. + foreach ($Pair in @(@('', 'os-default'), @('os-default', ''), @('', ''), @('os-default', 'os-default'))) { + $ProfileCopy = $script:ApProfile.Clone(); $ProfileCopy.locale = $Pair[1] + Mock New-CIPPDbRequest { @($script:LocaleProfile | ConvertTo-Cached) } + $script:LocaleProfile = $ProfileCopy + $Item = [PSCustomObject]@{ Variables = [PSCustomObject]@{ DisplayName = 'CIPP Autopilot'; Description = 'd'; Languages = $Pair[0]; SelfDeployingMode = $true; CollectHash = $true; HidePrivacy = $true; HideTerms = $true; AutoKeyboard = $true } } + $Prepared = Get-CIPPBaselineAutopilotProfileState -Item $Item -TenantFilter $script:Tenant + $Prepared.Current.locale | Should -Be $Prepared.Expected.locale -Because "configured '$($Pair[0])' vs stored '$($Pair[1])'" + } + } + + It 'an option-object DisplayName ({label, value}) unwraps instead of stringifying into the title' { + # Legacy saves stored the identity as an option object; interpolating it raw + # deployed profiles literally named '@{label=...}'. + $script:LocaleProfile = $script:ApProfile.Clone() + $script:LocaleProfile.displayName = 'CIPP Autopilot' + Mock New-CIPPDbRequest { @($script:LocaleProfile | ConvertTo-Cached) } + $Item = [PSCustomObject]@{ Variables = [PSCustomObject]@{ DisplayName = [PSCustomObject]@{ label = 'CIPP Autopilot'; value = 'CIPP Autopilot' }; Description = 'd'; SelfDeployingMode = $true; CollectHash = $true; HidePrivacy = $true; HideTerms = $true; AutoKeyboard = $true } } + $Prepared = Get-CIPPBaselineAutopilotProfileState -Item $Item -TenantFilter $script:Tenant + $Prepared.Expected.displayName | Should -Be 'CIPP Autopilot' + $Prepared.Current.profileExists | Should -BeTrue + Mock Set-CIPPDefaultAPDeploymentProfile { } + Invoke-CIPPBaselineAutopilotProfile -Remediate ([PSCustomObject]@{ displayName = [PSCustomObject]@{ label = 'CIPP Autopilot'; value = 'CIPP Autopilot' } }) -TenantFilter $script:Tenant -Current $null + Should -Invoke Set-CIPPDefaultAPDeploymentProfile -Times 1 -Exactly -ParameterFilter { $DisplayName -eq 'CIPP Autopilot' } + } + + It 'a REAL locale still grades exactly - the equivalence never blesses en-US against os-default' { + $ProfileCopy = $script:ApProfile.Clone(); $ProfileCopy.locale = 'en-US' + $script:LocaleProfile = $ProfileCopy + Mock New-CIPPDbRequest { @($script:LocaleProfile | ConvertTo-Cached) } + $Item = [PSCustomObject]@{ Variables = [PSCustomObject]@{ DisplayName = 'CIPP Autopilot'; Description = 'd'; Languages = 'os-default'; SelfDeployingMode = $true; CollectHash = $true; HidePrivacy = $true; HideTerms = $true; AutoKeyboard = $true } } + $Prepared = Get-CIPPBaselineAutopilotProfileState -Item $Item -TenantFilter $script:Tenant + $Prepared.Current.locale | Should -Be 'en-US' + $Prepared.Expected.locale | Should -Be 'os-default' + } +} + +Describe 'Get-CIPPBaselineDevicePrepProfileState' { + BeforeAll { + $script:DppPolicy = @{ id = 'dpp-1'; name = 'CIPP Device Prep'; assignments = @(@{ target = @{ '@odata.type' = '#microsoft.graph.groupAssignmentTarget'; groupId = 'g1' } }); settings = @( + (New-ChoiceSetting 'enrollment_autopilot_dpp_deploymentmode' 'enrollment_autopilot_dpp_deploymentmode_0') + (New-ChoiceSetting 'enrollment_autopilot_dpp_deploymenttype' 'enrollment_autopilot_dpp_deploymenttype_0') + (New-ChoiceSetting 'enrollment_autopilot_dpp_jointype' 'enrollment_autopilot_dpp_jointype_0') + (New-ChoiceSetting 'enrollment_autopilot_dpp_accountype' 'enrollment_autopilot_dpp_accountype_0') + (New-ChoiceSetting 'enrollment_autopilot_dpp_allowskip' 'enrollment_autopilot_dpp_allowskip_0') + (New-ChoiceSetting 'enrollment_autopilot_dpp_allowdiagnostics' 'enrollment_autopilot_dpp_allowdiagnostics_0') + @{ settingInstance = @{ settingDefinitionId = 'enrollment_autopilot_dpp_timeout'; simpleSettingValue = @{ value = 60 } } } + @{ settingInstance = @{ settingDefinitionId = 'enrollment_autopilot_dpp_customerrormessage'; simpleSettingValue = @{ value = 'msg' } } } + @{ settingInstance = @{ settingDefinitionId = 'enrollment_autopilot_dpp_devicesecuritygroupids'; simpleSettingValue = @{ value = '' } } } + ) } + } + + It 'EMPTY-STRING timeout and error message grade as the defaults the executor writes' { + # '' survives ?? (only null falls through) and [int]'' is 0 - which graded + # timeout 0 / message '' against the 60 / default text the executor deploys. + Mock New-CIPPDbRequest { @($script:DppPolicy | ConvertTo-Cached) } + Mock Compare-CIPPIntuneAssignments { [PSCustomObject]@{ Unknown = $true } } + $Item = [PSCustomObject]@{ Variables = [PSCustomObject]@{ ProfileName = 'CIPP Device Prep'; Timeout = ''; CustomErrorMessage = ''; AssignTo = 'none' } } + $Prepared = Get-CIPPBaselineDevicePrepProfileState -Item $Item -TenantFilter $script:Tenant + $Prepared.Expected.timeout | Should -Be 60 + $Prepared.Expected.customErrorMessage | Should -Match 'support person' + } + + It 'an UNKNOWN assignment lookup leaves the dimension out of the grade entirely' { + Mock New-CIPPDbRequest { @($script:DppPolicy | ConvertTo-Cached) } + Mock Compare-CIPPIntuneAssignments { [PSCustomObject]@{ Unknown = $true; Matched = $false } } + $Item = [PSCustomObject]@{ Variables = [PSCustomObject]@{ ProfileName = 'CIPP Device Prep'; CustomErrorMessage = 'msg'; AssignTo = 'AllDevicesAndUsers' } } + $Prepared = Get-CIPPBaselineDevicePrepProfileState -Item $Item -TenantFilter $script:Tenant + $Prepared.Expected.PSObject.Properties.Name | Should -Not -Contain 'isAssigned' + $Prepared.Current.settingsCorrect | Should -BeTrue + } + + It 'a readable mismatched assignment grades isAssigned false while settings stay correct' { + Mock New-CIPPDbRequest { @($script:DppPolicy | ConvertTo-Cached) } + Mock Compare-CIPPIntuneAssignments { [PSCustomObject]@{ Unknown = $false; Matched = $false } } + $Item = [PSCustomObject]@{ Variables = [PSCustomObject]@{ ProfileName = 'CIPP Device Prep'; CustomErrorMessage = 'msg'; AssignTo = 'AllDevicesAndUsers' } } + $Prepared = Get-CIPPBaselineDevicePrepProfileState -Item $Item -TenantFilter $script:Tenant + $Prepared.Current.isAssigned | Should -BeFalse + $Prepared.Current.settingsCorrect | Should -BeTrue + (Get-Verdict -Expected $Prepared.Expected -Current $Prepared.Current).Count | Should -BeGreaterThan 0 + } + + It 'repairs a wrong assignment IN PLACE - no delete, no recreate' { + Mock Get-CIPPIntuneAssignmentTarget { [PSCustomObject]@{ Targets = @(@{ '@odata.type' = '#microsoft.graph.groupAssignmentTarget'; groupId = 'g2' }); Unsupported = $null } } + Mock New-GraphPostRequest { } + Mock New-GraphGetRequest { @() } + $Remediate = [PSCustomObject]@{ profileName = 'CIPP Device Prep'; assignTo = 'AllDevicesAndUsers' } + Invoke-CIPPBaselineDevicePrepProfile -Remediate $Remediate -TenantFilter $script:Tenant -Current ([PSCustomObject]@{ policyId = 'dpp-1'; settingsCorrect = $true }) + Should -Invoke New-GraphPostRequest -Times 1 -Exactly + Should -Invoke New-GraphPostRequest -Times 1 -Exactly -ParameterFilter { $uri -like "*configurationPolicies('dpp-1')/assign" -and $type -eq 'POST' } + } + + It 'drifted settings delete and recreate with the classic''s policy body' { + Mock Get-CIPPIntuneAssignmentTarget { [PSCustomObject]@{ Targets = @(); Unsupported = $null } } + Mock New-GraphGetRequest { @() } + Mock New-GraphPostRequest { [PSCustomObject]@{ id = 'dpp-new' } } + $Remediate = [PSCustomObject]@{ profileName = 'CIPP Device Prep'; assignTo = 'none'; timeout = 90 } + Invoke-CIPPBaselineDevicePrepProfile -Remediate $Remediate -TenantFilter $script:Tenant -Current ([PSCustomObject]@{ policyId = 'dpp-1'; settingsCorrect = $false }) + Should -Invoke New-GraphPostRequest -Times 1 -Exactly -ParameterFilter { $type -eq 'DELETE' } + Should -Invoke New-GraphPostRequest -Times 1 -Exactly -ParameterFilter { + $type -eq 'POST' -and $uri -like '*configurationPolicies' -and ($body | ConvertFrom-Json).templateReference.templateId -eq '80d33118-b7b4-40d8-b15f-81be745e053f_1' + } + } +} + +Describe 'Get-CIPPBaselineDeployCheckChromeExtensionState' { + It 'grades presence of the Win32 app from the mobile apps cache' { + Mock New-CIPPDbRequest { @(@{ id = 'app-1'; displayName = 'Check by CyberDrain - Browser Extension'; description = 'x [cfg:AAAABBBBCCCCDDDD]' } | ConvertTo-Cached) } + $Prepared = Get-CIPPBaselineDeployCheckChromeExtensionState -Item ([PSCustomObject]@{ Variables = [PSCustomObject]@{} }) -TenantFilter $script:Tenant + (Get-Verdict -Expected $Prepared.Expected -Current $Prepared.Current).Count | Should -Be 0 + } + + It 'the fingerprint gate SKIPS the redeploy when the config hash is unchanged' { + # Compute the hash the executor would produce for these settings by letting it run + # once against a non-matching app, capturing the description it deploys. + Mock Get-CIPPAzDataTableEntity { [PSCustomObject]@{ Value = 'cipp.example.com' } } + Mock New-GraphGetRequest { @() } + Mock New-GraphBulkRequest { @() } + Mock New-GraphPostRequest { } + $script:DeployedDescription = $null + Mock Add-CIPPW32ScriptApplication { $script:DeployedDescription = $Properties.description; [PSCustomObject]@{ Id = 'new-app' } } + Mock Set-CIPPAssignedApplication { } + Mock Start-Sleep { } + $Remediate = [PSCustomObject]@{ showNotifications = $true; assignTo = 'AllDevices' } + Invoke-CIPPBaselineDeployCheckChromeExtension -Remediate $Remediate -TenantFilter $script:Tenant -Current $null + Should -Invoke Add-CIPPW32ScriptApplication -Times 1 -Exactly + $script:DeployedDescription | Should -Match '\[cfg:[0-9A-Fa-f]{16}\]' + + # Second run: the live app read returns the app with the SAME fingerprint - no + # delete, no redeploy. + Mock New-GraphGetRequest { + if ($uri -like '*mobileApps*') { @([PSCustomObject]@{ id = 'app-1'; displayName = 'Check by CyberDrain - Browser Extension'; description = $script:DeployedDescription; '@odata.type' = '#microsoft.graph.win32LobApp' }) } + else { @() } + } + Invoke-CIPPBaselineDeployCheckChromeExtension -Remediate $Remediate -TenantFilter $script:Tenant -Current $null + Should -Invoke Add-CIPPW32ScriptApplication -Times 1 -Exactly + Should -Invoke New-GraphPostRequest -Times 0 -Exactly -ParameterFilter { $type -eq 'DELETE' } + } +} + +Describe 'Get-CIPPBaselineAppDeployState' { + It 'copy mode accepts an app present by appId OR applicationTemplateId' { + Mock New-CIPPDbRequest { @( + (@{ appId = 'aaa'; displayName = 'App A' } | ConvertTo-Cached) + (@{ appId = 'zzz'; applicationTemplateId = 'bbb'; displayName = 'App B' } | ConvertTo-Cached) + ) } + $Item = [PSCustomObject]@{ Variables = [PSCustomObject]@{ mode = 'copy'; appids = 'aaa, bbb' } } + $Prepared = Get-CIPPBaselineAppDeployState -Item $Item -TenantFilter $script:Tenant + (Get-Verdict -Expected $Prepared.Expected -Current $Prepared.Current).Count | Should -Be 0 + } + + It 'template mode resolves each template to its type-specific identity' { + Mock New-CIPPDbRequest { @((@{ appId = 'ent-1'; displayName = 'SomethingElse' } | ConvertTo-Cached)) } + Mock Get-CIPPAzDataTableEntity { + if ($Filter -like "*'t-manifest'") { [PSCustomObject]@{ JSON = '{"AppType":"ApplicationManifest","AppName":"Manifest App"}' } } + else { [PSCustomObject]@{ JSON = '{"AppType":"EnterpriseApp","AppId":"ent-1","AppName":"Enterprise App"}' } } + } + $Item = [PSCustomObject]@{ Variables = [PSCustomObject]@{ mode = [PSCustomObject]@{ value = 'template' }; templateIds = @([PSCustomObject]@{ value = 't-manifest' }, [PSCustomObject]@{ value = 't-ent' }) } } + $Prepared = Get-CIPPBaselineAppDeployState -Item $Item -TenantFilter $script:Tenant + @($Prepared.Current.missingApps) | Should -Contain 'Manifest App' + @($Prepared.Current.missingApps) | Should -Not -Contain 'Enterprise App' + } + + It 'copy mode remediation copies each configured app and continues past failures' { + Mock New-CIPPDbRequest { @((@{ appId = 'aaa'; displayName = 'App A' } | ConvertTo-Cached)) } + Mock New-CIPPApplicationCopy { if ($App -eq 'aaa') { throw 'boom' } } + Invoke-CIPPBaselineAppDeploy -Remediate ([PSCustomObject]@{ mode = 'copy'; appids = 'aaa,bbb' }) -TenantFilter $script:Tenant -Current $null + Should -Invoke New-CIPPApplicationCopy -Times 2 -Exactly + } +} diff --git a/Tests/Baselines/BaselineOddballs.Tests.ps1 b/Tests/Baselines/BaselineOddballs.Tests.ps1 new file mode 100644 index 0000000000000..bc1c8a685d2bf --- /dev/null +++ b/Tests/Baselines/BaselineOddballs.Tests.ps1 @@ -0,0 +1,328 @@ +# Backlog batch 6: the deferred oddballs. Tests pin the decisions that fail silently - the +# CSS append-never-overwrite rule, the dynamic display-name pattern computation and its +# merge-preserving write, the add-in role containment grade, the storage SP upsert, and the +# Office-by-type detection with the async deploy queue. + +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, $Fields) } + function Write-LogMessage { param($API, $tenant, $message, $Sev, $LogData) } + function Get-CIPPDbItem { param($TenantFilter, $Type, [switch]$CountsOnly) } + function New-GraphGetRequest { param($uri, $tenantid, $AsApp) } + function New-GraphPostRequest { param($uri, $tenantid, $type, $body, $AsApp, $ContentType, $AddedHeaders) } + function New-ExoRequest { param($tenantid, $cmdlet, $cmdParams, $useSystemMailbox) } + function Get-Tenants { param($TenantFilter) } + function Get-CippTable { param($tablename) @{} } + function Get-CIPPAzDataTableEntity { param($Filter) } + function Get-CippException { param($Exception) [PSCustomObject]@{ NormalizedError = "$Exception"; RawError = '{}' } } + function ConvertFrom-CippAppConfig { param([Parameter(ValueFromPipeline = $true)]$InputObject) process { $InputObject | ConvertFrom-Json } } + function New-CIPPIntuneAppDeployment { param($AppConfig, $TenantFilter, $APIName) } + function Get-CIPPTextReplacement { param($TenantFilter, $Text) $Text } + function Get-NormalizedError { param($Message) "$Message" } + + . (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 'Test-CIPPBaselineCacheCollected.ps1') + foreach ($Name in @('PhishProtection', 'ColleagueImpersonationAlert', 'DisableOutlookAddins', + 'RestrictThirdPartyStorageServices', 'IntuneAppTemplateDeploy')) { + . (Join-Path $Baselines "Get-CIPPBaseline${Name}State.ps1") + . (Join-Path $Baselines "Invoke-CIPPBaseline${Name}.ps1") + } + + $script:Tenant = 'contoso.onmicrosoft.com' + function ConvertTo-Cached { param([Parameter(ValueFromPipeline = $true)]$InputObject) process { $InputObject | ConvertTo-Json -Depth 25 | ConvertFrom-Json } } + 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 { $_ }) + } + Mock Get-CIPPDbItem { [PSCustomObject]@{ RowKey = 'X-Count'; DataCount = 1 } } +} + +Describe 'Get-CIPPBaselinePhishProtectionState' { + BeforeEach { + Mock Get-Tenants { [PSCustomObject]@{ customerId = 'cust-1' } } + Mock Get-CIPPAzDataTableEntity { [PSCustomObject]@{ Value = 'cipp.example.com' } } + } + + It 'grades compliant only when the CSS carries THIS instance''s canary URL' { + Mock New-GraphGetRequest { ".ext-sign-in-box {`n background-image: url(https://clone.cipp.app/api/PublicPhishingCheck?Tenantid=$script:Tenant&URL=https://cipp.example.com);`n}`n" } + $Prepared = Get-CIPPBaselinePhishProtectionState -Item ([PSCustomObject]@{ Variables = [PSCustomObject]@{} }) -TenantFilter $script:Tenant + $Prepared.Current.phishingCSSEnabled | Should -BeTrue + Mock New-GraphGetRequest { '.other-css { color: red; }' } + (Get-CIPPBaselinePhishProtectionState -Item ([PSCustomObject]@{ Variables = [PSCustomObject]@{} }) -TenantFilter $script:Tenant).Current.phishingCSSEnabled | Should -BeFalse + } + + It 'APPENDS the canary to the existing custom CSS - operator customizations survive' { + Mock New-GraphPostRequest { } + $Current = [PSCustomObject]@{ currentBody = '.operator-css { color: blue; }'; expectedCss = '.canary {}'; customerId = 'cust-1' } + Invoke-CIPPBaselinePhishProtection -Remediate ([PSCustomObject]@{}) -TenantFilter $script:Tenant -Current $Current + Should -Invoke New-GraphPostRequest -Times 1 -Exactly -ParameterFilter { + $type -eq 'PUT' -and $body -like '*.operator-css*' -and $body -like '*.canary*' + } + } + + It 'strips the malformed empty-URL canary variant before rewriting' { + Mock New-GraphPostRequest { } + $Malformed = ".ext-sign-in-box { background-image: url(https://clone.cipp.app/api/PublicPhishingCheck?Tenantid=$script:Tenant&URL=); }" + $Current = [PSCustomObject]@{ currentBody = $Malformed; expectedCss = '.canary {}'; customerId = 'cust-1' } + Invoke-CIPPBaselinePhishProtection -Remediate ([PSCustomObject]@{}) -TenantFilter $script:Tenant -Current $Current + Should -Invoke New-GraphPostRequest -Times 1 -Exactly -ParameterFilter { $type -eq 'PUT' -and $body -notlike '*URL=)*' } + } +} + +Describe 'Get-CIPPBaselineColleagueImpersonationAlertState' { + BeforeAll { + $script:CachedMailboxes = @( + @{ displayName = 'Alice Adams'; recipientTypeDetails = 'UserMailbox'; AccountDisabled = $false } + @{ displayName = 'Frank Field'; recipientTypeDetails = 'UserMailbox'; AccountDisabled = $false } + @{ displayName = 'Bob Burns (Leaver)'; recipientTypeDetails = 'UserMailbox'; AccountDisabled = $false } + @{ displayName = 'Carl Closed'; recipientTypeDetails = 'UserMailbox'; AccountDisabled = $true } + @{ displayName = 'Dana Device'; recipientTypeDetails = 'RoomMailbox'; AccountDisabled = $false } + ) + $script:CachedDomains = @(@{ DomainName = 'contoso.com' }, @{ DomainName = 'contoso.onmicrosoft.com' }) + $script:MockCaches = { + param($ExtraRules) + $script:CachedRules = @($ExtraRules) + Mock New-CIPPDbRequest { + switch ($Type) { + 'Mailboxes' { @($script:CachedMailboxes | ConvertTo-Cached) } + 'ExoAcceptedDomains' { @($script:CachedDomains | ConvertTo-Cached) } + 'ExoTransportRules' { @($script:CachedRules | ConvertTo-Cached) } + } + } + } + } + + It 'computes patterns from ENABLED user/shared mailboxes only - disabled, excluded, and room mailboxes drop out' { + & $script:MockCaches @() + $Item = [PSCustomObject]@{ Variables = [PSCustomObject]@{ disclaimerHtml = 'Warn'; excludedMailboxes = @('(Leaver)') } } + $Prepared = Get-CIPPBaselineColleagueImpersonationAlertState -Item $Item -TenantFilter $script:Tenant + $AE = @($Prepared.Current.ruleStates | Where-Object { $_.Range -eq 'A-E' })[0] + @($AE.Names) | Should -Be @([regex]::Escape('Alice Adams')) + # The onmicrosoft domain never lands on the exemption list. + @($Prepared.Current.autoExemptDomains) | Should -Be @('contoso.com') + } + + It 'the separator adds the short name as a second pattern' { + $script:CachedMailboxes = @(@{ displayName = 'Alice Adams | Contoso'; recipientTypeDetails = 'UserMailbox'; AccountDisabled = $false }) + & $script:MockCaches @() + $Item = [PSCustomObject]@{ Variables = [PSCustomObject]@{ disclaimerHtml = 'W'; displayNameSeparator = '|' } } + $Prepared = Get-CIPPBaselineColleagueImpersonationAlertState -Item $Item -TenantFilter $script:Tenant + $AE = @($Prepared.Current.ruleStates | Where-Object { $_.Range -eq 'A-E' })[0] + @($AE.Names) | Should -Contain ([regex]::Escape('Alice Adams | Contoso')) + @($AE.Names) | Should -Contain ([regex]::Escape('Alice Adams')) + $script:CachedMailboxes = @(@{ displayName = 'Alice Adams'; recipientTypeDetails = 'UserMailbox'; AccountDisabled = $false }) + } + + It 'an empty letter group carries its placeholder pattern and a matching rule grades compliant' { + $Rules = @(foreach ($Range in @('A-E', 'F-J', 'K-O', 'P-T', 'U-Z')) { + $Patterns = if ($Range -eq 'A-E') { @([regex]::Escape('Alice Adams')) } else { @([regex]::Escape("($Range)")) } + @{ Name = "($Range) Colleague Impersonation Alert"; HeaderMatchesPatterns = $Patterns; ExceptIfFromAddressContainsWords = @(); ExceptIfSenderDomainIs = @('contoso.com'); ApplyHtmlDisclaimerText = 'W' } + }) + & $script:MockCaches $Rules + $Item = [PSCustomObject]@{ Variables = [PSCustomObject]@{ disclaimerHtml = 'W' } } + $Prepared = Get-CIPPBaselineColleagueImpersonationAlertState -Item $Item -TenantFilter $script:Tenant + (Get-Verdict -Expected $Prepared.Expected -Current $Prepared.Current).Count | Should -Be 0 + } + + It 'a missing rule grades that rule as drift, not the whole set' { + & $script:MockCaches @() + $Item = [PSCustomObject]@{ Variables = [PSCustomObject]@{ disclaimerHtml = 'W' } } + $Prepared = Get-CIPPBaselineColleagueImpersonationAlertState -Item $Item -TenantFilter $script:Tenant + (Get-Verdict -Expected $Prepared.Expected -Current $Prepared.Current).Count | Should -Be 5 + } + + It 'the write MERGES existing rule exemptions with the configured ones, deduped case-insensitively' { + Mock New-ExoRequest { } + $Current = [PSCustomObject]@{ + ruleStates = @([PSCustomObject]@{ RuleName = '(A-E) Colleague Impersonation Alert'; Range = 'A-E'; Names = @('Alice'); RuleExists = $true + ExistingExemptSender = @('vip@partner.com', 'SOC@contoso.com'); ExistingExemptDomain = @('legacy.com'); ExistingDisclaimer = 'Old' }) + autoExemptDomains = @('contoso.com', 'LEGACY.com') + additionalExemptSenders = @('soc@contoso.com') + } + Invoke-CIPPBaselineColleagueImpersonationAlert -Remediate ([PSCustomObject]@{ disclaimerHtml = 'New' }) -TenantFilter $script:Tenant -Current $Current + Should -Invoke New-ExoRequest -Times 1 -Exactly -ParameterFilter { + $cmdlet -eq 'Set-TransportRule' -and + @($cmdParams.ExceptIfFromAddressContainsWords).Count -eq 2 -and + $cmdParams.ExceptIfFromAddressContainsWords -contains 'vip@partner.com' -and + @($cmdParams.ExceptIfSenderDomainIs).Count -eq 2 -and + $cmdParams.ApplyHtmlDisclaimerText -eq 'New' + } + } + + It 'OMITS the domain exemption parameter when the list is empty - Exchange rejects an empty one' { + # onmicrosoft-only tenants have no exemptable accepted domains; passing an empty + # ExceptIfSenderDomainIs failed every rule write on them. + Mock New-ExoRequest { } + $Current = [PSCustomObject]@{ + ruleStates = @([PSCustomObject]@{ RuleName = '(A-E) Colleague Impersonation Alert'; Range = 'A-E'; Names = @('Alice'); RuleExists = $false + ExistingExemptSender = @(); ExistingExemptDomain = @(); ExistingDisclaimer = '' }) + autoExemptDomains = @(); additionalExemptSenders = @() + } + Invoke-CIPPBaselineColleagueImpersonationAlert -Remediate ([PSCustomObject]@{ disclaimerHtml = 'W' }) -TenantFilter $script:Tenant -Current $Current + Should -Invoke New-ExoRequest -Times 1 -Exactly -ParameterFilter { + $cmdlet -eq 'New-TransportRule' -and -not $cmdParams.ContainsKey('ExceptIfSenderDomainIs') + } + } + + It 'refuses to write an empty banner: no configured HTML and no fallback throws' { + Mock New-ExoRequest { } + $Current = [PSCustomObject]@{ + ruleStates = @([PSCustomObject]@{ RuleName = '(A-E) Colleague Impersonation Alert'; Range = 'A-E'; Names = @('Alice'); RuleExists = $false + ExistingExemptSender = @(); ExistingExemptDomain = @(); ExistingDisclaimer = '' }) + autoExemptDomains = @(); additionalExemptSenders = @() + } + { Invoke-CIPPBaselineColleagueImpersonationAlert -Remediate ([PSCustomObject]@{}) -TenantFilter $script:Tenant -Current $Current } | Should -Throw + Should -Invoke New-ExoRequest -Times 0 -Exactly + } +} + +Describe 'Get-CIPPBaselineDisableOutlookAddinsState' { + It 'any remaining app-install role grades not-disabled' { + Mock New-ExoRequest { @([PSCustomObject]@{ IsDefault = $true; Identity = 'Default Role Assignment Policy'; AssignedRoles = @('MyBaseOptions', 'My Marketplace Apps') }) } + $Prepared = Get-CIPPBaselineDisableOutlookAddinsState -Item ([PSCustomObject]@{ Variables = [PSCustomObject]@{} }) -TenantFilter $script:Tenant + $Prepared.Current.disabledOutlookAddins | Should -BeFalse + @($Prepared.Current.rolesToRemove) | Should -Be @('My Marketplace Apps') + } + + It 'removes each role''s assignments by GUID and throws only when EVERY role fails' { + Mock New-ExoRequest { + if ($cmdlet -eq 'Get-ManagementRoleAssignment') { @([PSCustomObject]@{ Guid = 'guid-1' }) } + elseif ($cmdlet -eq 'Remove-ManagementRoleAssignment' -and $cmdParams.Identity -eq 'guid-1') { } + } + $Current = [PSCustomObject]@{ policyIdentity = 'Default Role Assignment Policy'; rolesToRemove = @('My Custom Apps', 'My Marketplace Apps') } + Invoke-CIPPBaselineDisableOutlookAddins -Remediate ([PSCustomObject]@{}) -TenantFilter $script:Tenant -Current $Current + Should -Invoke New-ExoRequest -Times 2 -Exactly -ParameterFilter { $cmdlet -eq 'Remove-ManagementRoleAssignment' } + Mock New-ExoRequest { throw 'denied' } + { Invoke-CIPPBaselineDisableOutlookAddins -Remediate ([PSCustomObject]@{}) -TenantFilter $script:Tenant -Current $Current } | Should -Throw + } +} + +Describe 'Get-CIPPBaselineRestrictThirdPartyStorageServicesState' { + It 'a MISSING service principal grades unrestricted - the platform default is enabled' { + Mock New-CIPPDbRequest { @(@{ appId = 'other'; accountEnabled = $true } | ConvertTo-Cached) } + (Get-CIPPBaselineRestrictThirdPartyStorageServicesState -Item ([PSCustomObject]@{ Variables = [PSCustomObject]@{} }) -TenantFilter $script:Tenant).Current.thirdPartyStorageRestricted | Should -BeFalse + } + + It 'a disabled service principal grades restricted' { + Mock New-CIPPDbRequest { @(@{ appId = 'c1f33bc0-bdb4-4248-ba9b-096807ddb43e'; accountEnabled = $false } | ConvertTo-Cached) } + (Get-CIPPBaselineRestrictThirdPartyStorageServicesState -Item ([PSCustomObject]@{ Variables = [PSCustomObject]@{} }) -TenantFilter $script:Tenant).Current.thirdPartyStorageRestricted | Should -BeTrue + } + + It 'the write is the appId-addressed upsert carrying Prefer: create-if-missing' { + Mock New-GraphPostRequest { } + Invoke-CIPPBaselineRestrictThirdPartyStorageServices -Remediate ([PSCustomObject]@{}) -TenantFilter $script:Tenant -Current $null + Should -Invoke New-GraphPostRequest -Times 1 -Exactly -ParameterFilter { + $type -eq 'PATCH' -and $uri -like "*servicePrincipals(appId='c1f33bc0*" -and + $AddedHeaders.Prefer -eq 'create-if-missing' -and ($body | ConvertFrom-Json).accountEnabled -eq $false + } + } +} + +Describe 'Get-CIPPBaselineDlpCompliancePolicyTemplateState' { + BeforeAll { + function Compare-CIPPDlpCompliancePolicy { param($TenantFilter, $Template) } + function Set-CIPPDlpCompliancePolicy { param($TenantFilter, $Template, $APIName) } + function ConvertTo-CIPPODataFilterValue { param($Value, $Type) "$Value" } + . (Join-Path $script:RepoRoot 'Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineDlpCompliancePolicyTemplateState.ps1') + . (Join-Path $script:RepoRoot 'Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineDlpCompliancePolicyTemplate.ps1') + $script:DlpItem = [PSCustomObject]@{ Variables = [PSCustomObject]@{ dlpCompliancePolicyTemplate = [PSCustomObject]@{ value = 'dlp-guid-1' } } } + } + BeforeEach { + Mock Get-CIPPAzDataTableEntity { [PSCustomObject]@{ JSON = '{"Name":"Finance DLP","RuleParams":[{"Name":"Rule1"}]}' } } + } + + It 'InSync grades compliant with an empty non-compliant list' { + Mock Compare-CIPPDlpCompliancePolicy { [PSCustomObject]@{ Name = 'Finance DLP'; State = 'InSync'; Differences = @() } } + $Prepared = Get-CIPPBaselineDlpCompliancePolicyTemplateState -Item $script:DlpItem -TenantFilter $script:Tenant + (Get-Verdict -Expected $Prepared.Expected -Current $Prepared.Current).Count | Should -Be 0 + } + + It 'Drift grades non-compliant with the compact Scope/Field projection and carries the template' { + Mock Compare-CIPPDlpCompliancePolicy { [PSCustomObject]@{ Name = 'Finance DLP'; State = 'Drift'; Differences = @( + [PSCustomObject]@{ Scope = 'Policy'; Field = 'Mode'; Expected = 'Enable'; Current = 'TestWithoutNotifications' } + ) } } + $Prepared = Get-CIPPBaselineDlpCompliancePolicyTemplateState -Item $script:DlpItem -TenantFilter $script:Tenant + (Get-Verdict -Expected $Prepared.Expected -Current $Prepared.Current).Count | Should -BeGreaterThan 0 + @($Prepared.Current.nonCompliantDlpPolicies)[0].Fields | Should -Be @('Policy/Mode') + @($Prepared.Current.remediableTemplates).Count | Should -Be 1 + } + + It 'PendingDeletion is non-compliant but NOT remediable - the deploy would just fail' { + Mock Compare-CIPPDlpCompliancePolicy { [PSCustomObject]@{ Name = 'Finance DLP'; State = 'PendingDeletion'; Differences = @() } } + $Prepared = Get-CIPPBaselineDlpCompliancePolicyTemplateState -Item $script:DlpItem -TenantFilter $script:Tenant + (Get-Verdict -Expected $Prepared.Expected -Current $Prepared.Current).Count | Should -BeGreaterThan 0 + @($Prepared.Current.remediableTemplates).Count | Should -Be 0 + Mock Set-CIPPDlpCompliancePolicy { 'should never run' } + Invoke-CIPPBaselineDlpCompliancePolicyTemplate -Remediate ([PSCustomObject]@{}) -TenantFilter $script:Tenant -Current $Prepared.Current + Should -Invoke Set-CIPPDlpCompliancePolicy -Times 0 -Exactly + } + + It 'the executor throws on the helper''s failure strings and succeeds otherwise' { + Mock Set-CIPPDlpCompliancePolicy { 'Could not deploy Finance DLP: bad rule' } + $Current = [PSCustomObject]@{ remediableTemplates = @([PSCustomObject]@{ Name = 'Finance DLP' }) } + { Invoke-CIPPBaselineDlpCompliancePolicyTemplate -Remediate ([PSCustomObject]@{}) -TenantFilter $script:Tenant -Current $Current } | Should -Throw + Mock Set-CIPPDlpCompliancePolicy { 'Deployed policy Finance DLP with 1 rule(s)' } + { Invoke-CIPPBaselineDlpCompliancePolicyTemplate -Remediate ([PSCustomObject]@{}) -TenantFilter $script:Tenant -Current $Current } | Should -Not -Throw + } +} + +Describe 'Get-CIPPBaselineIntuneAppTemplateDeployState' { + BeforeAll { + $script:AppTemplate = @{ JSON = (@{ Displayname = 'Baseline Apps'; Apps = @( + @{ appType = 'StoreApp'; appName = '7zip'; config = @{ ApplicationName = '7-Zip'; AssignTo = 'AllDevices' } } + @{ appType = 'officeApp'; appName = 'M365 Apps'; config = @{ ApplicationName = 'Office' } } + ) } | ConvertTo-Json -Depth 10) } + } + + It 'Office is tracked by @odata.type - Graph renames it, the template name never matches' { + Mock Get-CIPPAzDataTableEntity { [PSCustomObject]@{ JSON = $script:AppTemplate.JSON } } + Mock New-CIPPDbRequest { @( + (@{ displayName = '7-Zip'; '@odata.type' = '#microsoft.graph.win32LobApp' } | ConvertTo-Cached) + (@{ displayName = 'Microsoft 365 Apps for Windows 10 and later'; '@odata.type' = '#microsoft.graph.officeSuiteApp' } | ConvertTo-Cached) + ) } + $Item = [PSCustomObject]@{ Variables = [PSCustomObject]@{ templateIds = @([PSCustomObject]@{ value = 't-1' }) } } + $Prepared = Get-CIPPBaselineIntuneAppTemplateDeployState -Item $Item -TenantFilter $script:Tenant + (Get-Verdict -Expected $Prepared.Expected -Current $Prepared.Current).Count | Should -Be 0 + } + + It 'a missing app grades drift and carries its full deploy config for the executor' { + Mock Get-CIPPAzDataTableEntity { [PSCustomObject]@{ JSON = $script:AppTemplate.JSON } } + Mock New-CIPPDbRequest { @((@{ displayName = 'Microsoft 365 Apps for Windows 10 and later'; '@odata.type' = '#microsoft.graph.officeSuiteApp' } | ConvertTo-Cached)) } + $Item = [PSCustomObject]@{ Variables = [PSCustomObject]@{ templateIds = @('t-1') } } + $Prepared = Get-CIPPBaselineIntuneAppTemplateDeployState -Item $Item -TenantFilter $script:Tenant + @($Prepared.Current.missingApps) | Should -Be @('7-Zip') + @($Prepared.Current.missingAppObjects)[0].AppType | Should -Be 'StoreApp' + } + + It 'a NAMELESS template app is skipped with a warning instead of an unnameable missing-app row' { + Mock Get-CIPPAzDataTableEntity { [PSCustomObject]@{ JSON = (@{ Displayname = 'Broken'; Apps = @( + @{ appType = 'chocolateyApp'; appName = ''; config = @{ AssignTo = 'AllDevices' } } + @{ appType = 'StoreApp'; appName = '7zip'; config = @{ ApplicationName = '7-Zip' } } + ) } | ConvertTo-Json -Depth 10) } } + Mock New-CIPPDbRequest { @((@{ displayName = 'nothing'; '@odata.type' = '#microsoft.graph.win32LobApp' } | ConvertTo-Cached)) } + Mock Write-LogMessage { } + $Item = [PSCustomObject]@{ Variables = [PSCustomObject]@{ templateIds = @('t-broken') } } + $Prepared = Get-CIPPBaselineIntuneAppTemplateDeployState -Item $Item -TenantFilter $script:Tenant + @($Prepared.Current.missingApps) | Should -Be @('7-Zip') + Should -Invoke Write-LogMessage -Times 1 -Exactly -ParameterFilter { $message -like '*no name*' } + } + + It 'the executor maps template types to queue types and continues past per-app failures' { + Mock New-CIPPIntuneAppDeployment { if ($AppConfig.Applicationname -eq 'BadApp') { throw 'upload refused' } } + $Current = [PSCustomObject]@{ missingAppObjects = @( + [PSCustomObject]@{ TemplateId = 't-1'; TemplateName = 'T'; AppName = 'BadApp'; AppType = 'chocolateyApp'; Config = [PSCustomObject]@{ AssignTo = 'AllDevices' } } + [PSCustomObject]@{ TemplateId = 't-1'; TemplateName = 'T'; AppName = '7-Zip'; AppType = 'StoreApp'; Config = [PSCustomObject]@{ AssignTo = 'customGroup'; CustomGroup = 'Pilot Group' } } + ) } + Invoke-CIPPBaselineIntuneAppTemplateDeploy -Remediate ([PSCustomObject]@{}) -TenantFilter $script:Tenant -Current $Current + Should -Invoke New-CIPPIntuneAppDeployment -Times 2 -Exactly + Should -Invoke New-CIPPIntuneAppDeployment -Times 1 -Exactly -ParameterFilter { + $AppConfig.type -eq 'WinGet' -and $AppConfig.assignTo -eq 'Pilot Group' + } + } +} diff --git a/Tests/Baselines/BaselineOneOffStandards.Tests.ps1 b/Tests/Baselines/BaselineOneOffStandards.Tests.ps1 new file mode 100644 index 0000000000000..8abc75415bfb9 --- /dev/null +++ b/Tests/Baselines/BaselineOneOffStandards.Tests.ps1 @@ -0,0 +1,292 @@ +# One-off standard conversions (backlog batch 1). Each test pins a normalization or grading +# decision that fails SILENTLY if it regresses - the standard reports Compliant (or permanent +# Drift) and nobody notices. + +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) } + function Get-CIPPDbItem { param($TenantFilter, $Type, [switch]$CountsOnly) } + function New-GraphGetRequest { param($uri, $tenantid, $AsApp) } + function New-GraphPostRequest { param($uri, $tenantid, $type, $body, $AsApp) } + function New-TeamsRequestV2 { param($TenantFilter, $Type, $Action, $Identity, $Parameters, [switch]$NoRead) } + function New-ExoRequest { param($tenantid, $cmdlet, $cmdParams, $useSystemMailbox) } + + . (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 'Test-CIPPBaselineCacheCollected.ps1') + foreach ($Name in @('ExternalMFATrusted', 'IntuneDeviceRetirementDays', 'AppManagementPolicy', 'EnableAppConsentRequests', 'TeamsFederationConfiguration', 'OMEBranding')) { + . (Join-Path $Baselines "Get-CIPPBaseline${Name}State.ps1") + . (Join-Path $Baselines "Invoke-CIPPBaseline${Name}.ps1") + } + + $script:Tenant = 'contoso.onmicrosoft.com' + function ConvertTo-Cached { param([Parameter(ValueFromPipeline = $true)]$InputObject) process { $InputObject | ConvertTo-Json -Depth 20 | ConvertFrom-Json } } + 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-CIPPBaselineExternalMFATrustedState' { + It 'grades the trust switch in BOTH directions' { + # Deliberately NOT trusting external MFA is a valid posture; a one-way grade would + # report a trusting tenant compliant against a distrusting baseline. + Mock New-CIPPDbRequest { @(@{ inboundTrust = @{ isMfaAccepted = $true } } | ConvertTo-Cached) } + $Off = [PSCustomObject]@{ Variables = [PSCustomObject]@{ state = $false } } + $Prepared = Get-CIPPBaselineExternalMFATrustedState -Item $Off -TenantFilter $script:Tenant + (Get-Verdict -Expected $Prepared.Expected -Current $Prepared.Current).Count | Should -BeGreaterThan 0 + $On = [PSCustomObject]@{ Variables = [PSCustomObject]@{ state = $true } } + $Prepared2 = Get-CIPPBaselineExternalMFATrustedState -Item $On -TenantFilter $script:Tenant + (Get-Verdict -Expected $Prepared2.Expected -Current $Prepared2.Current).Count | Should -Be 0 + } + + It 'patches the MERGED inboundTrust, never the lone flag' { + # Graph PATCH replaces the whole complex value - a bare isMfaAccepted body silently + # resets the device-trust flags beside it. + Mock New-GraphGetRequest { [PSCustomObject]@{ inboundTrust = [PSCustomObject]@{ isMfaAccepted = $false; isCompliantDeviceAccepted = $true; isHybridAzureADJoinedDeviceAccepted = $true } } } + Mock New-GraphPostRequest { } + Invoke-CIPPBaselineExternalMFATrusted -Remediate ([PSCustomObject]@{ trusted = $true }) -TenantFilter $script:Tenant -Current $null + Should -Invoke New-GraphPostRequest -Times 1 -Exactly -ParameterFilter { + $type -eq 'PATCH' -and $body -match '"isMfaAccepted":\s*true' -and $body -match 'isCompliantDeviceAccepted' + } + } +} + +Describe 'Get-CIPPBaselineIntuneDeviceRetirementDaysState' { + BeforeAll { $script:DaysItem = [PSCustomObject]@{ Variables = [PSCustomObject]@{ days = 90 } } } + BeforeEach { Mock Get-CIPPDbItem { [PSCustomObject]@{ RowKey = 'ManagedDeviceCleanupRules-Count'; DataCount = 1 } } } + + It 'prefers the all-platforms rule over platform-scoped rules' { + Mock New-CIPPDbRequest { @( + (@{ id = 'r-ios'; deviceCleanupRulePlatformType = 'ios'; deviceInactivityBeforeRetirementInDays = 30 } | ConvertTo-Cached), + (@{ id = 'r-all'; deviceCleanupRulePlatformType = 'all'; deviceInactivityBeforeRetirementInDays = 90 } | ConvertTo-Cached) + ) } + $Prepared = Get-CIPPBaselineIntuneDeviceRetirementDaysState -Item $script:DaysItem -TenantFilter $script:Tenant + $Prepared.Current.ruleId | Should -Be 'r-all' + (Get-Verdict -Expected $Prepared.Expected -Current $Prepared.Current).Count | Should -Be 0 + } + + It 'grades a tenant with no cleanup rule as drift, not No Data' { + Mock New-CIPPDbRequest { @() } + Mock Get-CIPPDbItem { [PSCustomObject]@{ RowKey = 'ManagedDeviceCleanupRules-Count'; DataCount = 0 } } + $Prepared = Get-CIPPBaselineIntuneDeviceRetirementDaysState -Item $script:DaysItem -TenantFilter $script:Tenant + $Prepared.Current | Should -Not -BeNullOrEmpty + $Prepared.Current.ruleId | Should -BeNullOrEmpty + (Get-Verdict -Expected $Prepared.Expected -Current $Prepared.Current).Count | Should -BeGreaterThan 0 + } + + It 'creates when no rule exists and patches the existing rule otherwise' { + Mock New-GraphPostRequest { } + Invoke-CIPPBaselineIntuneDeviceRetirementDays -Remediate ([PSCustomObject]@{ days = 90 }) -TenantFilter $script:Tenant -Current ([PSCustomObject]@{ ruleId = $null }) + Should -Invoke New-GraphPostRequest -Times 1 -Exactly -ParameterFilter { $type -eq 'POST' } + Invoke-CIPPBaselineIntuneDeviceRetirementDays -Remediate ([PSCustomObject]@{ days = 90 }) -TenantFilter $script:Tenant -Current ([PSCustomObject]@{ ruleId = 'r-all' }) + Should -Invoke New-GraphPostRequest -Times 1 -Exactly -ParameterFilter { $type -eq 'PATCH' -and $uri -match "r-all" } + } +} + +Describe 'Get-CIPPBaselineAppManagementPolicyState' { + BeforeAll { + $script:AmpItem = [PSCustomObject]@{ Variables = [PSCustomObject]@{ passwordCredentialsPasswordAddition = 'enabled'; passwordCredentialsCustomPasswordAddition = ''; passwordCredentialsMaxLifetime = ''; keyCredentialsMaxLifetime = '' } } + } + + It 'mirrors password addition onto symmetric key addition, like the classic' { + Mock New-CIPPDbRequest { @(@{ isEnabled = $true; applicationRestrictions = @{ passwordCredentials = @(); keyCredentials = @() }; servicePrincipalRestrictions = @{ passwordCredentials = @(); keyCredentials = @() } } | ConvertTo-Cached) } + $Prepared = Get-CIPPBaselineAppManagementPolicyState -Item $script:AmpItem -TenantFilter $script:Tenant + @($Prepared.Expected.applicationRestrictions.passwordCredentials).restrictionType | Should -Contain 'symmetricKeyAddition' + @($Prepared.Expected.servicePrincipalRestrictions.passwordCredentials).restrictionType | Should -Contain 'passwordAddition' + } + + It 'converts day counts to ISO durations' { + Mock New-CIPPDbRequest { @(@{ isEnabled = $true; applicationRestrictions = @{ passwordCredentials = @(); keyCredentials = @() }; servicePrincipalRestrictions = @{ passwordCredentials = @(); keyCredentials = @() } } | ConvertTo-Cached) } + $Item = [PSCustomObject]@{ Variables = [PSCustomObject]@{ passwordCredentialsMaxLifetime = 30; keyCredentialsMaxLifetime = 365 } } + $Prepared = Get-CIPPBaselineAppManagementPolicyState -Item $Item -TenantFilter $script:Tenant + (@($Prepared.Expected.applicationRestrictions.passwordCredentials) | Where-Object { $_.restrictionType -eq 'passwordLifetime' }).maxLifetime | Should -Be 'P30D' + (@($Prepared.Expected.applicationRestrictions.keyCredentials) | Where-Object { $_.restrictionType -eq 'asymmetricKeyLifetime' }).maxLifetime | Should -Be 'P365D' + } + + It 'reports No Data when nothing is configured, expressing no opinion' { + $Empty = [PSCustomObject]@{ Variables = [PSCustomObject]@{} } + (Get-CIPPBaselineAppManagementPolicyState -Item $Empty -TenantFilter $script:Tenant).Current | Should -BeNullOrEmpty + } +} + +Describe 'Get-CIPPBaselineEnableAppConsentRequestsState' { + It 'tolerates reviewers an operator added by hand' { + # The merge write preserves extra reviewers, so grading them (as the classic''s + # count compare did) would be permanent unfixable drift. + Mock New-CIPPDbRequest { @(@{ isEnabled = $true; reviewers = @( + @{ query = "/beta/roleManagement/directory/roleAssignments?`$filter=roleDefinitionId eq '62e90394-69f5-4237-9190-012177145e10'" }, + @{ query = '/v1.0/users/hand-added-reviewer@contoso.com' } + ) } | ConvertTo-Cached) } + $Item = [PSCustomObject]@{ Variables = [PSCustomObject]@{} } + $Prepared = Get-CIPPBaselineEnableAppConsentRequestsState -Item $Item -TenantFilter $script:Tenant + @($Prepared.Current.missingReviewerRoles).Count | Should -Be 0 + (Get-Verdict -Expected $Prepared.Expected -Current $Prepared.Current).Count | Should -Be 0 + } + + It 'reports drift when the configured role is not among the reviewers' { + Mock New-CIPPDbRequest { @(@{ isEnabled = $true; reviewers = @(@{ query = '/v1.0/users/someone@contoso.com' }) } | ConvertTo-Cached) } + $Item = [PSCustomObject]@{ Variables = [PSCustomObject]@{ ReviewerRoles = [PSCustomObject]@{ label = 'GA'; value = '62e90394-69f5-4237-9190-012177145e10' } } } + $Prepared = Get-CIPPBaselineEnableAppConsentRequestsState -Item $Item -TenantFilter $script:Tenant + $Prepared.Current.missingReviewerRoles | Should -Contain '62e90394-69f5-4237-9190-012177145e10' + } + + It 'merges configured roles into the reviewer list without dropping hand-added ones' { + Mock New-GraphGetRequest { @{ isEnabled = $false; notifyReviewers = $false; remindersEnabled = $false; requestDurationInDays = 0; reviewers = @(@{ query = '/v1.0/users/keepme@contoso.com'; queryType = 'MicrosoftGraph'; queryRoot = 'null' }) } | ConvertTo-Cached } + Mock New-GraphPostRequest { } + Invoke-CIPPBaselineEnableAppConsentRequests -Remediate ([PSCustomObject]@{ reviewerRoles = @() }) -TenantFilter $script:Tenant -Current $null + Should -Invoke New-GraphPostRequest -Times 1 -Exactly -ParameterFilter { + $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' { + BeforeEach { + Mock Get-CIPPDbItem { [PSCustomObject]@{ RowKey = 'CsTenantFederationConfiguration-Count'; DataCount = 1 } } + } + + It 'reads the GET shape: allow-all is an AllowedDomains object with no member list' { + Mock New-CIPPDbRequest { @(@{ AllowTeamsConsumer = $false; AllowTeamsConsumerInbound = $false; AllowFederatedUsers = $true; AllowedDomains = @{}; BlockedDomains = @() } | ConvertTo-Cached) } + $Item = [PSCustomObject]@{ Variables = [PSCustomObject]@{ DomainControl = 'AllowAllExternal'; AllowTeamsConsumer = $false; AllowTeamsConsumerInbound = $false } } + $Prepared = Get-CIPPBaselineTeamsFederationConfigurationState -Item $Item -TenantFilter $script:Tenant + $Prepared.Current.allowedDomains | Should -Be 'AllowAllKnownDomains' + (Get-Verdict -Expected $Prepared.Expected -Current $Prepared.Current).Count | Should -Be 0 + } + + It 'unwraps {Domain} objects and sorts before comparing specific allow lists' { + Mock New-CIPPDbRequest { @(@{ AllowTeamsConsumer = $false; AllowTeamsConsumerInbound = $false; AllowFederatedUsers = $true; AllowedDomains = @{ AllowedDomain = @(@{ Domain = 'b.com' }, @{ Domain = 'a.com' }) }; BlockedDomains = @() } | ConvertTo-Cached) } + $Item = [PSCustomObject]@{ Variables = [PSCustomObject]@{ DomainControl = 'AllowSpecificExternal'; DomainList = 'a.com, b.com'; AllowTeamsConsumer = $false; AllowTeamsConsumerInbound = $false } } + $Prepared = Get-CIPPBaselineTeamsFederationConfigurationState -Item $Item -TenantFilter $script:Tenant + (Get-Verdict -Expected $Prepared.Expected -Current $Prepared.Current).Count | Should -Be 0 + } + + It 'a SINGLE allowed domain still grades as an ARRAY - the if-expression unwrap made it a scalar' { + # One domain drifted forever with visually identical want/got: expected array vs + # current scalar after the assignment unwrapped the one-element pipeline. + Mock New-CIPPDbRequest { @(@{ AllowTeamsConsumer = $false; AllowTeamsConsumerInbound = $false; AllowFederatedUsers = $true; AllowedDomains = @{ AllowedDomain = @(@{ Domain = 'googe.com' }) }; BlockedDomains = @() } | ConvertTo-Cached) } + $Item = [PSCustomObject]@{ Variables = [PSCustomObject]@{ DomainControl = 'AllowSpecificExternal'; DomainList = 'googe.com'; AllowTeamsConsumer = $false; AllowTeamsConsumerInbound = $false } } + $Prepared = Get-CIPPBaselineTeamsFederationConfigurationState -Item $Item -TenantFilter $script:Tenant + ($Prepared.Current.allowedDomains -is [array]) | Should -BeTrue + (Get-Verdict -Expected $Prepared.Expected -Current $Prepared.Current).Count | Should -Be 0 + } + + It 'ignores both domain lists in BlockAllExternal mode, like the classic' { + Mock New-CIPPDbRequest { @(@{ AllowTeamsConsumer = $false; AllowTeamsConsumerInbound = $false; AllowFederatedUsers = $false; AllowedDomains = @{ AllowedDomain = @('stale.com') }; BlockedDomains = @('old.com') } | ConvertTo-Cached) } + $Item = [PSCustomObject]@{ Variables = [PSCustomObject]@{ DomainControl = 'BlockAllExternal'; AllowTeamsConsumer = $false; AllowTeamsConsumerInbound = $false } } + $Prepared = Get-CIPPBaselineTeamsFederationConfigurationState -Item $Item -TenantFilter $script:Tenant + (Get-Verdict -Expected $Prepared.Expected -Current $Prepared.Current).Count | Should -Be 0 + } + + It 'writes through the ConfigAPI with the carried PUT-shaped payload' { + Mock New-TeamsRequestV2 { } + $Current = [PSCustomObject]@{ writePayload = [PSCustomObject]@{ AllowTeamsConsumer = $false; AllowTeamsConsumerInbound = $false; AllowFederatedUsers = $true; AllowedDomains = @{ AllowList = @('a.com') }; BlockedDomains = @() } } + Invoke-CIPPBaselineTeamsFederationConfiguration -Remediate $null -TenantFilter $script:Tenant -Current $Current + Should -Invoke New-TeamsRequestV2 -Times 1 -Exactly -ParameterFilter { + $Action -eq 'Set' -and $NoRead -eq $true -and $Parameters.AllowedDomains.AllowList -contains 'a.com' + } + } +} + +Describe 'Get-CIPPBaselineOMEBrandingState' { + BeforeAll { + $script:OmeConfig = @{ Identity = 'OME Configuration'; BackgroundColor = '#ffffff'; EmailText = 'Secure mail'; IntroductionText = ''; ReadButtonText = ''; PortalText = ''; DisclaimerText = ''; PrivacyStatementUrl = ''; OTPEnabled = $true; SocialIdSignIn = $false } + } + BeforeEach { Mock Get-CIPPDbItem { [PSCustomObject]@{ RowKey = 'ExoOMEConfiguration-Count'; DataCount = 1 } } } + + It 'grades only the fields the baseline configures' { + Mock New-CIPPDbRequest { @($script:OmeConfig | ConvertTo-Cached) } + $Item = [PSCustomObject]@{ Variables = [PSCustomObject]@{ BackgroundColor = '#ffffff' } } + $Prepared = Get-CIPPBaselineOMEBrandingState -Item $Item -TenantFilter $script:Tenant + $Prepared.Expected.PSObject.Properties.Name | Should -Be @('BackgroundColor') + (Get-Verdict -Expected $Prepared.Expected -Current $Prepared.Current).Count | Should -Be 0 + } + + It 'reports drift on a configured field that differs' { + Mock New-CIPPDbRequest { @($script:OmeConfig | ConvertTo-Cached) } + $Item = [PSCustomObject]@{ Variables = [PSCustomObject]@{ BackgroundColor = '#000000' } } + $Prepared = Get-CIPPBaselineOMEBrandingState -Item $Item -TenantFilter $script:Tenant + (Get-Verdict -Expected $Prepared.Expected -Current $Prepared.Current).Count | Should -BeGreaterThan 0 + } + + It 'grades a configured logo as permanent drift, the classic behaviour made visible' { + Mock New-CIPPDbRequest { @($script:OmeConfig | ConvertTo-Cached) } + $Item = [PSCustomObject]@{ Variables = [PSCustomObject]@{ LogoUrl = 'https://example.com/logo.png' } } + $Prepared = Get-CIPPBaselineOMEBrandingState -Item $Item -TenantFilter $script:Tenant + $Prepared.Current.logoApplied | Should -BeFalse + (Get-Verdict -Expected $Prepared.Expected -Current $Prepared.Current).Count | Should -BeGreaterThan 0 + } + + It 'reports No Data when no branding field is configured' { + Mock New-CIPPDbRequest { @($script:OmeConfig | ConvertTo-Cached) } + $Item = [PSCustomObject]@{ Variables = [PSCustomObject]@{} } + (Get-CIPPBaselineOMEBrandingState -Item $Item -TenantFilter $script:Tenant).Current | Should -BeNullOrEmpty + } + + It 'writes only configured fields and survives a failed logo download' { + Mock New-ExoRequest { } + Mock Invoke-WebRequest { throw 'download failed' } + Mock Write-LogMessage { } + $Remediate = [PSCustomObject]@{ backgroundColor = '#000000'; logoUrl = 'https://example.com/logo.png' } + Invoke-CIPPBaselineOMEBranding -Remediate $Remediate -TenantFilter $script:Tenant -Current ([PSCustomObject]@{ omeIdentity = 'OME Configuration' }) + Should -Invoke New-ExoRequest -Times 1 -Exactly -ParameterFilter { + $cmdlet -eq 'Set-OMEConfiguration' -and $cmdParams.BackgroundColor -eq '#000000' -and -not $cmdParams.ContainsKey('Image') -and -not $cmdParams.ContainsKey('EmailText') + } + } +} diff --git a/Tests/Baselines/BaselineOneOffVerify.Tests.ps1 b/Tests/Baselines/BaselineOneOffVerify.Tests.ps1 new file mode 100644 index 0000000000000..1d3f9f95fbde8 --- /dev/null +++ b/Tests/Baselines/BaselineOneOffVerify.Tests.ps1 @@ -0,0 +1,470 @@ +# Post-remediation cache verification. A one-off's inline refresh can capture PRE-write +# state on lag-prone Graph surfaces, and a present-but-stale cache never re-collects, so +# the fixed drift re-detects until the next scheduled collection. These tests pin the fix: +# the engine's GradeOnly pass persists NOTHING, and the oneoff branch re-collects exactly +# once when the refreshed cache still grades drifted. + +BeforeAll { + $script:RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + + function Write-LogMessage { param($API, $tenant, $message, $Sev, $LogData) } + function Set-CippBaselineRunContext { param($RunId) } + function Update-CippQueueEntry { param($RowKey, $Status, $Name) } + function Get-CIPPBaselineDefinition { param($Name) } + function Get-CippTable { param($tablename) @{} } + function ConvertTo-CIPPODataFilterValue { param($Value, $Type) "$Value" } + function Get-CIPPAzDataTableEntity { param($Filter) } + function Remove-CIPPAzDataTableEntity { param($Entity, [switch]$Force) } + function Add-CIPPBaselineHistoryEvent { param($TenantFilter, $Standard, $Mode, $TriggeredBy, $Outcome, $Detail, $RunId, $Remediated) } + function Set-CIPPBaselineResult { param($Result, $Prior, $RunId) } + function Send-CIPPBaselineAlert { param($Result) } + function New-CIPPDbRequest { param($TenantFilter, $Type, $Fields) } + function Get-CIPPTextReplacement { param($TenantFilter, $Text, [switch]$EscapeForJson) $Text } + function Get-CIPPTenantCapabilities { param($TenantFilter) } + function Wait-CIPPBaselineCacheReady { param($TenantFilter, $Definition, $RunId) $false } + function Set-CIPPDBCacheTestCache { param($TenantFilter) } + + . (Join-Path $script:RepoRoot 'Modules/CIPPCore/Public/Get-CIPPIntuneCompareExclusions.ps1') + . (Join-Path $script:RepoRoot 'Modules/CIPPCore/Public/Compare-CIPPIntuneObject.ps1') + . (Join-Path $script:RepoRoot 'Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineStandard.ps1') + . (Join-Path $script:RepoRoot 'Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Baselines/Push-CIPPBaselineStandard.ps1') + + $script:Tenant = 'contoso.onmicrosoft.com' + $script:Definition = [PSCustomObject]@{ + name = 'TestStd'; label = 'Test Standard' + requiredCapabilities = @() + variables = [PSCustomObject]@{} + read = [PSCustomObject]@{ cacheType = 'TestCache' } + expected = [PSCustomObject]@{ enabled = $true } + } + $script:EngineItem = @{ + TenantFilter = $script:Tenant; Standard = 'TestStd'; BaseName = 'TestStd' + Variables = [PSCustomObject]@{}; Tiers = @(); AlertEnabled = $false + } +} + +Describe 'Invoke-CIPPBaselineStandard -GradeOnly' { + BeforeEach { + Mock Get-CIPPBaselineDefinition { $script:Definition } + Mock Get-CIPPAzDataTableEntity { @() } + Mock Set-CIPPBaselineResult { } + Mock Add-CIPPBaselineHistoryEvent { } + Mock Send-CIPPBaselineAlert { } + Mock Remove-CIPPAzDataTableEntity { } + Mock Write-LogMessage { } + } + + It 'returns the verdict and persists NOTHING - no history, no alignment, no alerts, no logs' { + Mock New-CIPPDbRequest { [PSCustomObject]@{ enabled = $true } } + $Verdict = Invoke-CIPPBaselineStandard -Item $script:EngineItem -Mode 'oneoff' -GradeOnly + $Verdict.Compliant | Should -BeTrue + Should -Invoke Set-CIPPBaselineResult -Times 0 -Exactly + Should -Invoke Add-CIPPBaselineHistoryEvent -Times 0 -Exactly + Should -Invoke Send-CIPPBaselineAlert -Times 0 -Exactly + Should -Invoke Write-LogMessage -Times 0 -Exactly + } + + It 'grades a still-stale cache non-compliant with the diff, still writing nothing' { + Mock New-CIPPDbRequest { [PSCustomObject]@{ enabled = $false } } + $Verdict = Invoke-CIPPBaselineStandard -Item $script:EngineItem -Mode 'oneoff' -GradeOnly + $Verdict.Compliant | Should -BeFalse + @($Verdict.Diff).Count | Should -BeGreaterThan 0 + Should -Invoke Set-CIPPBaselineResult -Times 0 -Exactly + } + + It 'grades an EMPTY cache non-compliant so the caller retries honestly' { + Mock New-CIPPDbRequest { @() } + (Invoke-CIPPBaselineStandard -Item $script:EngineItem -Mode 'oneoff' -GradeOnly).Compliant | Should -BeFalse + } + + It 'a verification that CRASHES returns null without overwriting the optimistic row' { + Mock New-CIPPDbRequest { throw 'transient table outage' } + Invoke-CIPPBaselineStandard -Item $script:EngineItem -Mode 'oneoff' -GradeOnly | Should -BeNullOrEmpty + Should -Invoke Set-CIPPBaselineResult -Times 0 -Exactly + } + + It 'control: the same fixture WITHOUT GradeOnly persists the result - quietness is the switch, not the harness' { + Mock New-CIPPDbRequest { [PSCustomObject]@{ enabled = $true } } + $Result = Invoke-CIPPBaselineStandard -Item $script:EngineItem -Mode 'compare' + $Result.Compliant | Should -BeTrue + Should -Invoke Set-CIPPBaselineResult -Times 1 -Exactly + } +} + +Describe 'Invoke-CIPPBaselineStandard render option-unwrap' { + BeforeEach { + Mock Get-CIPPAzDataTableEntity { @() } + Mock Set-CIPPBaselineResult { } + Mock Add-CIPPBaselineHistoryEvent { } + Mock Send-CIPPBaselineAlert { } + Mock Write-LogMessage { } + } + + It 'unwraps option objects and option arrays before splicing them into the spec' { + # A declarative standard with picker variables: the saved values are wrappers + # ({label, value}); the render must splice the VALUES, or the write ships + # '@{label=...}' strings (single) or raw objects (arrays) to the API. + Mock Get-CIPPBaselineDefinition { [PSCustomObject]@{ + name = 'RenderStd'; label = 'Render Standard'; requiredCapabilities = @(); variables = [PSCustomObject]@{} + read = [PSCustomObject]@{ cacheType = 'TestCache' } + expected = [PSCustomObject]@{ picked = '%MyPick%'; recipients = '%MyList%' } + } } + Mock New-CIPPDbRequest { [PSCustomObject]@{ picked = 'one'; recipients = @('a@x.com', 'b@x.com') } } + $Item = @{ + TenantFilter = $script:Tenant; Standard = 'RenderStd'; BaseName = 'RenderStd' + Variables = [PSCustomObject]@{ + MyPick = [PSCustomObject]@{ label = 'Option One'; value = 'one' } + MyList = @([PSCustomObject]@{ label = 'A'; value = 'a@x.com' }, [PSCustomObject]@{ label = 'B'; value = 'b@x.com' }) + } + Tiers = @(); AlertEnabled = $false + } + $Result = Invoke-CIPPBaselineStandard -Item $Item -Mode 'compare' + $Result.ExpectedValue.picked | Should -Be 'one' + @($Result.ExpectedValue.recipients) | Should -Be @('a@x.com', 'b@x.com') + $Result.Compliant | Should -BeTrue + } + + It 'prepare hooks receive UNWRAPPED variables - direct interpolation gets the value, not the wrapper' { + # Hooks read $Item.Variables directly (no render pass); a hook interpolating a + # picker variable raw graded '@{label=...}' against every object and made the + # whole tenant an offender. + function Get-CIPPBaselineHookProbeState { param($Item, $TenantFilter) + $script:HookSawVariable = $Item.Variables.MyPick + @{ Expected = [PSCustomObject]@{ ok = $true }; Current = [PSCustomObject]@{ ok = $true } } + } + Mock Get-CIPPBaselineDefinition { [PSCustomObject]@{ + name = 'HookProbe'; label = 'Hook Probe'; requiredCapabilities = @(); variables = [PSCustomObject]@{} + read = [PSCustomObject]@{ cacheType = 'TestCache' } + prepare = 'Get-CIPPBaselineHookProbeState' + } } + $script:HookSawVariable = $null + $Item = @{ + TenantFilter = $script:Tenant; Standard = 'HookProbe'; BaseName = 'HookProbe' + Variables = @{ MyPick = [ordered]@{ label = 'Option One'; value = 'one' } } # hashtable-shaped, as the durable pipeline delivers + Tiers = @(); AlertEnabled = $false + } + $null = Invoke-CIPPBaselineStandard -Item $Item -Mode 'compare' + $script:HookSawVariable | Should -Be 'one' + } +} + +Describe 'Invoke-CIPPBaselineGraphBulkSweep batch ids' { + BeforeAll { + . (Join-Path $script:RepoRoot 'Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineGraphBulkSweep.ps1') + function New-GraphBulkRequest { param($tenantid, $Requests, $scope, $asapp, $Version) } + } + + It 'every batch request carries a non-empty sequential id - Graph rejects empty ids outright' { + # "$($Index++)" emits NOTHING in PowerShell, which shipped every request with an + # empty id and failed every sweep with 'Id property cannot be empty'. + $script:CapturedRequests = $null + Mock New-GraphBulkRequest { + $script:CapturedRequests = @($Requests) + @($Requests | ForEach-Object { [PSCustomObject]@{ id = $_.id; status = 204 } }) + } + $Remediate = [PSCustomObject]@{ writes = @([PSCustomObject]@{ method = 'PATCH'; uri = 'users/%id%'; body = [PSCustomObject]@{ accountEnabled = $false } }) } + $Current = [PSCustomObject]@{ targets = @([PSCustomObject]@{ id = 'user-1' }, [PSCustomObject]@{ id = 'user-2' }) } + Invoke-CIPPBaselineGraphBulkSweep -Remediate $Remediate -TenantFilter $script:Tenant -Current $Current + @($script:CapturedRequests | ForEach-Object { "$($_.id)" }) | Should -Be @('0', '1') + @($script:CapturedRequests)[1].url | Should -Be '/users/user-2' + } +} + +Describe 'Convert-CIPPBaselineResolvedEntity identity labels' { + BeforeAll { + . (Join-Path $script:RepoRoot 'Modules/CIPPCore/Public/Baselines/Convert-CIPPBaselineResolvedEntity.ps1') + $script:InstanceDefs = @([PSCustomObject]@{ name = 'CATemplate'; label = 'Conditional Access Template'; instanceIdentity = 'caTemplate'; remediate = [PSCustomObject]@{ executor = 'CATemplate' } }) + } + + It 'unwraps an option-object identity from the inheritance instead of stringifying it' { + # The stored inheritance carries the picker's {label, value} object; the label + # suffix must be the value, never '@{label=...; value=...}'. + $Entity = [PSCustomObject]@{ + PartitionKey = 'contoso.onmicrosoft.com'; RowKey = 'CATemplate~abc'; StandardName = 'CATemplate#abc' + ExpectedValue = $null; Inheritance = (@(@{ effective = $true; value = @{ caTemplate = @{ label = 'CA003-Policy'; value = 'guid-1' } } }) | ConvertTo-Json -Depth 10) + Status = 'Drift' + } + $Row = Convert-CIPPBaselineResolvedEntity -Entity $Entity -Definitions $script:InstanceDefs + $Row.standardLabel | Should -Be 'Conditional Access Template - guid-1' + $Row.standardLabel | Should -Not -Match '@\{' + } +} + +Describe 'Get-CIPPBaselineDisableInactiveUsersState exclusions' { + BeforeAll { + function Get-CIPPBaselineCacheRows { param($TenantFilter, $Type, $CollectorType, $CollectorArgs) } + function Test-CIPPBaselineCacheCollected { param($TenantFilter, $Type) $true } + function New-GraphGetRequest { param($uri, $tenantid, $AsApp, $scope) } + . (Join-Path $script:RepoRoot 'Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineDisableInactiveUsersState.ps1') + } + + It 'excluded accounts are never offenders - a breakglass account must not be sweep-disabled' { + $Old = (Get-Date).AddDays(-400).ToUniversalTime().ToString('o') + Mock New-CIPPDbRequest { @( + [PSCustomObject]@{ id = 'u1'; userPrincipalName = 'breakglass@contoso.com'; userType = 'Member'; accountEnabled = $true; onPremisesSyncEnabled = $false; createdDateTime = $Old; signInActivity = [PSCustomObject]@{ lastSuccessfulSignInDateTime = $Old } } + [PSCustomObject]@{ id = 'u2'; userPrincipalName = 'stale@contoso.com'; userType = 'Member'; accountEnabled = $true; onPremisesSyncEnabled = $false; createdDateTime = $Old; signInActivity = [PSCustomObject]@{ lastSuccessfulSignInDateTime = $Old } } + ) } + Mock New-GraphGetRequest { @() } + $Item = [PSCustomObject]@{ Variables = [PSCustomObject]@{ days = 180; excludedUsers = @([PSCustomObject]@{ value = 'breakglass@contoso.com' }) } } + $Prepared = Get-CIPPBaselineDisableInactiveUsersState -Item $Item -TenantFilter 'contoso.onmicrosoft.com' + @($Prepared.Current.offenders) | Should -Be @('stale@contoso.com') + @($Prepared.Current.targets | ForEach-Object { $_.id }) | Should -Be @('u2') + } +} + +Describe 'Invoke-CIPPBaselineExoPolicyRule extraPolicyParams' { + BeforeAll { + function New-ExoRequest { param($tenantid, $cmdlet, $cmdParams, $useSystemMailbox) } + . (Join-Path $script:RepoRoot 'Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineExoPolicyRule.ps1') + } + + It 'merges hook-derived policy params over the rendered spec - grade and write share one derivation' { + Mock New-ExoRequest { } + $Remediate = [PSCustomObject]@{ + policyCmdlet = 'HostedContentFilterPolicy'; ruleCmdlet = 'HostedContentFilterRule' + policyParams = [PSCustomObject]@{ SpamAction = 'Quarantine'; BulkThreshold = 7 } + ruleParams = [PSCustomObject]@{ Priority = 0 } + } + $Current = [PSCustomObject]@{ + policyName = 'CIPP Spam Policy'; policyExists = $true; ruleName = 'CIPP Spam Policy'; ruleExists = $true + ruleLinkedPolicy = 'CIPP Spam Policy'; acceptedDomains = @('contoso.com'); skipRule = $false + extraPolicyParams = [PSCustomObject]@{ MarkAsSpamFramesInHtml = 'On'; BulkThreshold = 9 } + } + Invoke-CIPPBaselineExoPolicyRule -Remediate $Remediate -TenantFilter 'contoso.onmicrosoft.com' -Current $Current + Should -Invoke New-ExoRequest -Times 1 -Exactly -ParameterFilter { + $cmdlet -eq 'Set-HostedContentFilterPolicy' -and + $cmdParams.MarkAsSpamFramesInHtml -eq 'On' -and + $cmdParams.BulkThreshold -eq 9 -and + $cmdParams.SpamAction -eq 'Quarantine' + } + } +} + +Describe 'Invoke-CIPPBaselineEnableFIDO2 passkey profile normalization' { + BeforeAll { + function New-GraphGetRequest { param($uri, $tenantid, $AsApp) } + function New-GraphPostRequest { param($uri, $tenantid, $type, $body, $AsApp, $ContentType) } + . (Join-Path $script:RepoRoot 'Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineEnableFIDO2.ps1') + } + + It 'gives profiles missing keyRestrictions the neutral shape before the PATCH - Graph validates the whole config' { + Mock New-GraphGetRequest { [PSCustomObject]@{ + state = 'disabled'; isAttestationEnforced = $false; isSelfServiceRegistrationAllowed = $false + passkeyProfiles = @( + [PSCustomObject]@{ displayName = 'Legacy'; }, + [PSCustomObject]@{ displayName = 'Modern'; keyRestrictions = [PSCustomObject]@{ isEnforced = $true; enforcementType = 'allow'; aaGuids = @('g1') } } + ) + } } + Mock New-GraphPostRequest { } + Invoke-CIPPBaselineEnableFIDO2 -Remediate ([PSCustomObject]@{}) -TenantFilter 'contoso.onmicrosoft.com' -Current $null + Should -Invoke New-GraphPostRequest -Times 1 -Exactly -ParameterFilter { + $type -eq 'PATCH' -and $AsApp -eq $true -and + ($body | ConvertFrom-Json).state -eq 'enabled' -and + @(($body | ConvertFrom-Json).passkeyProfiles)[0].keyRestrictions.isEnforced -eq $false -and + @(($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' { + BeforeAll { + function Get-CIPPDbItem { param($TenantFilter, $Type, [switch]$CountsOnly) } + function Get-CIPPBaselineWorkItems { param($TenantFilter) } + . (Join-Path $script:RepoRoot 'Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineDetectCADriftState.ps1') + } + BeforeEach { + Mock Get-CIPPAzDataTableEntity { @() } + Mock Get-CIPPDbItem { [PSCustomObject]@{ RowKey = 'X-Count'; DataCount = 2 } } + Mock New-CIPPDbRequest { @( + [PSCustomObject]@{ displayName = '[SharePoint admin center]Use app-enforced Restrictions for browser access - 2026/08/17'; state = 'enabled'; id = 'sp-1' } + [PSCustomObject]@{ displayName = 'Rogue Admin Policy'; state = 'enabled'; id = 'rogue-1' } + ) } + } + + It 'does not flag [SharePoint admin center] policies when an unmanaged-device access standard applies' { + # unmanagedSync/OWAAttachmentRestrictions turn on app-enforced restrictions, and + # SharePoint auto-creates these CA policies - a managed side-effect, not drift. + Mock Get-CIPPBaselineWorkItems { @([PSCustomObject]@{ BaseName = 'unmanagedSync'; Variables = [PSCustomObject]@{} }) } + $Prepared = Get-CIPPBaselineDetectCADriftState -Item ([PSCustomObject]@{ Variables = [PSCustomObject]@{} }) -TenantFilter 'contoso.onmicrosoft.com' + @($Prepared.Current.PSObject.Properties.Name) | Should -Not -Contain '[SharePoint admin center]Use app-enforced Restrictions for browser access - 2026/08/17' + @($Prepared.Current.PSObject.Properties.Name) | Should -Contain 'Rogue Admin Policy' + } + + It 'still flags [SharePoint admin center] policies when NO access standard applies - then somebody clicked the portal' { + Mock Get-CIPPBaselineWorkItems { @() } + $Prepared = Get-CIPPBaselineDetectCADriftState -Item ([PSCustomObject]@{ Variables = [PSCustomObject]@{} }) -TenantFilter 'contoso.onmicrosoft.com' + @($Prepared.Current.PSObject.Properties.Name) | Should -Contain '[SharePoint admin center]Use app-enforced Restrictions for browser access - 2026/08/17' + } +} + +Describe 'Push-CIPPBaselineStandard oneoff verification' { + BeforeEach { + $script:GradeCalls = 0 + Mock Set-CippBaselineRunContext { } + Mock Set-CIPPDBCacheTestCache { } + Mock Start-Sleep { } + Mock Write-LogMessage { } + $script:PushItem = @{ + RunId = 'run-1'; Mode = 'oneoff'; TriggeredBy = 'operator@contoso.com' + Item = @{ TenantFilter = $script:Tenant; Standard = 'TestStd'; BaseName = 'TestStd' } + } + } + + It 'fresh on the first grade: one refresh, one grade, no sleep, no warning' { + Mock Invoke-CIPPBaselineStandard { + if ($GradeOnly) { $script:GradeCalls++; return [PSCustomObject]@{ Compliant = $true } } + [PSCustomObject]@{ Remediated = $true; CacheType = @('TestCache') } + } + Push-CIPPBaselineStandard -Item $script:PushItem + Should -Invoke Set-CIPPDBCacheTestCache -Times 1 -Exactly + $script:GradeCalls | Should -Be 1 + Should -Invoke Start-Sleep -Times 0 -Exactly + Should -Invoke Write-LogMessage -Times 0 -Exactly -ParameterFilter { $Sev -eq 'Warning' } + } + + It 'stale then fresh: waits for propagation, collects a SECOND time, ends quiet' { + # The whole point of the fix - the first refresh captured pre-write state, the + # retry after the backoff captures the real one. + Mock Invoke-CIPPBaselineStandard { + if ($GradeOnly) { $script:GradeCalls++; return [PSCustomObject]@{ Compliant = ($script:GradeCalls -ge 2) } } + [PSCustomObject]@{ Remediated = $true; CacheType = @('TestCache') } + } + Push-CIPPBaselineStandard -Item $script:PushItem + Should -Invoke Set-CIPPDBCacheTestCache -Times 2 -Exactly + Should -Invoke Start-Sleep -Times 1 -Exactly + $script:GradeCalls | Should -Be 2 + Should -Invoke Write-LogMessage -Times 0 -Exactly -ParameterFilter { $Sev -eq 'Warning' } + } + + It 'still stale after BOTH retries: warns and STOPS - two growing backoffs, never a loop' { + Mock Invoke-CIPPBaselineStandard { + if ($GradeOnly) { $script:GradeCalls++; return [PSCustomObject]@{ Compliant = $false } } + [PSCustomObject]@{ Remediated = $true; CacheType = @('TestCache') } + } + Push-CIPPBaselineStandard -Item $script:PushItem + Should -Invoke Set-CIPPDBCacheTestCache -Times 3 -Exactly + $script:GradeCalls | Should -Be 3 + Should -Invoke Start-Sleep -Times 1 -Exactly -ParameterFilter { $Seconds -eq 30 } + Should -Invoke Write-LogMessage -Times 1 -Exactly -ParameterFilter { $Sev -eq 'Warning' -and $message -like '*still grades*' } + } + + It 'scheduled runs are untouched: impact records out, no inline refresh, no verification' { + Mock Invoke-CIPPBaselineStandard { + if ($GradeOnly) { $script:GradeCalls++; return [PSCustomObject]@{ Compliant = $true } } + [PSCustomObject]@{ Remediated = $true; CacheType = @('TestCache') } + } + $script:PushItem.Mode = 'run' + $Records = @(Push-CIPPBaselineStandard -Item $script:PushItem) + $Records.Count | Should -Be 1 + $Records[0].CacheType | Should -Be 'TestCache' + Should -Invoke Set-CIPPDBCacheTestCache -Times 0 -Exactly + $script:GradeCalls | Should -Be 0 + } +} + +Describe 'Compare-CIPPIntuneObject type tolerance' { + # The alignment cache stores values through a JSON round-trip, which collapses a + # single-element array to its scalar and widens int to long. The comparator's type + # gate reported those as drift with IDENTICAL display values on both sides + # (SharePointMassDeletionAlert NotifyUser on prod). Wrapper and width differences + # are not value differences; real mismatches still report. + + It 'treats a single-element array and its equal scalar as compliant' { + $Ref = [PSCustomObject]@{ NotifyUser = @('bla@bla.com') } + $Dif = [PSCustomObject]@{ NotifyUser = 'bla@bla.com' } + @(Compare-CIPPIntuneObject -ReferenceObject $Ref -DifferenceObject $Dif | Where-Object { $_ }) | Should -BeNullOrEmpty + } + + It 'treats a scalar expected against a single-element array current as compliant' { + $Ref = [PSCustomObject]@{ NotifyUser = 'bla@bla.com' } + $Dif = [PSCustomObject]@{ NotifyUser = @('bla@bla.com') } + @(Compare-CIPPIntuneObject -ReferenceObject $Ref -DifferenceObject $Dif | Where-Object { $_ }) | Should -BeNullOrEmpty + } + + It 'still reports a single-element array against a DIFFERENT scalar' { + $Ref = [PSCustomObject]@{ NotifyUser = @('a@b.com') } + $Dif = [PSCustomObject]@{ NotifyUser = 'c@d.com' } + $Diff = @(Compare-CIPPIntuneObject -ReferenceObject $Ref -DifferenceObject $Dif | Where-Object { $_ }) + $Diff.Count | Should -Be 1 + $Diff[0].Property | Should -Be 'NotifyUser' + } + + It 'reports a multi-element array against a scalar as a real difference' { + $Ref = [PSCustomObject]@{ NotifyUser = @('a@b.com', 'c@d.com') } + $Dif = [PSCustomObject]@{ NotifyUser = 'a@b.com' } + $Diff = @(Compare-CIPPIntuneObject -ReferenceObject $Ref -DifferenceObject $Dif | Where-Object { $_ }) + $Diff.Count | Should -Be 1 + $Diff[0].ExpectedValue | Should -Be 'a@b.com, c@d.com' + } + + It 'treats int and long of the same value as compliant' { + $Ref = [PSCustomObject]@{ timeWindow = [int]60 } + $Dif = [PSCustomObject]@{ timeWindow = [long]60 } + @(Compare-CIPPIntuneObject -ReferenceObject $Ref -DifferenceObject $Dif | Where-Object { $_ }) | Should -BeNullOrEmpty + } + + It 'still reports differing numbers across integer widths' { + $Ref = [PSCustomObject]@{ timeWindow = [int]60 } + $Dif = [PSCustomObject]@{ timeWindow = [long]90 } + $Diff = @(Compare-CIPPIntuneObject -ReferenceObject $Ref -DifferenceObject $Dif | Where-Object { $_ }) + $Diff.Count | Should -Be 1 + } + + It 'still reports a genuine type mismatch' { + $Ref = [PSCustomObject]@{ enabled = 'true' } + $Dif = [PSCustomObject]@{ enabled = $true } + $Diff = @(Compare-CIPPIntuneObject -ReferenceObject $Ref -DifferenceObject $Dif | Where-Object { $_ }) + $Diff.Count | Should -Be 1 + } + + It 'still reports a number against a numeric STRING - only number-vs-number coerces' { + $Ref = [PSCustomObject]@{ timeWindow = [int]60 } + $Dif = [PSCustomObject]@{ timeWindow = '60' } + $Diff = @(Compare-CIPPIntuneObject -ReferenceObject $Ref -DifferenceObject $Dif | Where-Object { $_ }) + $Diff.Count | Should -Be 1 + } +} diff --git a/Tests/Baselines/BaselinePrepareHooks.Tests.ps1 b/Tests/Baselines/BaselinePrepareHooks.Tests.ps1 new file mode 100644 index 0000000000000..10be0306a5ef3 --- /dev/null +++ b/Tests/Baselines/BaselinePrepareHooks.Tests.ps1 @@ -0,0 +1,388 @@ +# 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-CIPPBaselineCacheRows.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 + } +} + +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' + } +} + +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 + } +} + +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 + } + + 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' { + # 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' + } +} diff --git a/Tests/Baselines/BaselineSharePointBatch.Tests.ps1 b/Tests/Baselines/BaselineSharePointBatch.Tests.ps1 new file mode 100644 index 0000000000000..2ba93cd8e13c9 --- /dev/null +++ b/Tests/Baselines/BaselineSharePointBatch.Tests.ps1 @@ -0,0 +1,293 @@ +# Backlog batch 4: the SharePoint/misc cluster. Tests pin the decisions that fail silently - +# extension normalization and replace semantics, side-selective domain grading, the SPO +# version-policy validation gate and -1 sentinels, only-configured contact grading, the +# two-surface photo policy, and secure score's newest-update-wins effective state. + +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) } + function Get-CIPPDbItem { param($TenantFilter, $Type, [switch]$CountsOnly) } + function New-ExoRequest { param($tenantid, $cmdlet, $cmdParams, $useSystemMailbox) } + function New-GraphPostRequest { param($tenantid, $uri, $type, $body, $AsApp, $ContentType) } + function New-GraphGetRequest { param($uri, $tenantid, $AsApp) } + function New-GraphBulkRequest { param($tenantid, $Requests) } + function Get-CIPPSPOTenant { param($TenantFilter) } + function Set-CIPPSPOTenant { [CmdletBinding()] param([Parameter(ValueFromPipeline = $true)]$InputObject, $Properties, $MethodName, $MethodParameters) process { } } + function Set-CIPPSPOSite { param($TenantFilter, $SiteUrl, $Properties) } + function Get-CIPPTextReplacement { param($TenantFilter, $Text) $Text } + function Get-NormalizedError { param($Message) "$Message" } + + . (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 'Test-CIPPBaselineCacheCollected.ps1') + foreach ($Name in @('ExcludedfileExt', 'sharingDomainRestriction', 'SPDirectSharing', 'SPOVersionControl', + 'MailContacts', 'ProfilePhotos', 'SecureScoreRemediation')) { + . (Join-Path $Baselines "Get-CIPPBaseline${Name}State.ps1") + . (Join-Path $Baselines "Invoke-CIPPBaseline${Name}.ps1") + } + + $script:Tenant = 'contoso.onmicrosoft.com' + function ConvertTo-Cached { param([Parameter(ValueFromPipeline = $true)]$InputObject) process { $InputObject | ConvertTo-Json -Depth 20 | ConvertFrom-Json } } + 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 { $_ }) + } + Mock Get-CIPPDbItem { [PSCustomObject]@{ RowKey = 'X-Count'; DataCount = 1 } } +} + +Describe 'Get-CIPPBaselineExcludedfileExtState' { + It 'normalizes bare extensions to the *. prefix and grades the set order-insensitively' { + Mock New-CIPPDbRequest { @(@{ excludedFileExtensionsForSyncApp = @('*.bat', '*.exe') } | ConvertTo-Cached) } + $Item = [PSCustomObject]@{ Variables = [PSCustomObject]@{ ext = 'exe, bat' } } + $Prepared = Get-CIPPBaselineExcludedfileExtState -Item $Item -TenantFilter $script:Tenant + (Get-Verdict -Expected $Prepared.Expected -Current $Prepared.Current).Count | Should -Be 0 + } + + It 'grades a tenant extension OUTSIDE the baseline as drift - replace semantics, not subset' { + # The classic rewrote the whole list; an extension the operator removed from the + # baseline must come off the tenant, so extra tenant entries are drift. + Mock New-CIPPDbRequest { @(@{ excludedFileExtensionsForSyncApp = @('*.bat', '*.exe', '*.js') } | ConvertTo-Cached) } + $Item = [PSCustomObject]@{ Variables = [PSCustomObject]@{ ext = 'exe,bat' } } + $Prepared = Get-CIPPBaselineExcludedfileExtState -Item $Item -TenantFilter $script:Tenant + (Get-Verdict -Expected $Prepared.Expected -Current $Prepared.Current).Count | Should -BeGreaterThan 0 + } + + It 'writes the FULL normalized list app-only in one PATCH' { + Mock New-GraphPostRequest { } + Invoke-CIPPBaselineExcludedfileExt -Remediate ([PSCustomObject]@{ ext = 'exe, bat' }) -TenantFilter $script:Tenant -Current $null + Should -Invoke New-GraphPostRequest -Times 1 -Exactly -ParameterFilter { + $type -eq 'PATCH' -and $AsApp -eq $true -and $uri -like '*admin/sharepoint/settings' -and + (@(($body | ConvertFrom-Json).excludedFileExtensionsForSyncApp) -join ',') -eq '*.bat,*.exe' + } + } +} + +Describe 'Get-CIPPBaselinesharingDomainRestrictionState' { + It 'mode none grades the mode alone and flags a tenant restriction as drift' { + Mock New-CIPPDbRequest { @(@{ sharingDomainRestrictionMode = 'allowList'; sharingAllowedDomainList = @('a.com') } | ConvertTo-Cached) } + $Item = [PSCustomObject]@{ Variables = [PSCustomObject]@{ Mode = [PSCustomObject]@{ value = 'none' } } } + $Prepared = Get-CIPPBaselinesharingDomainRestrictionState -Item $Item -TenantFilter $script:Tenant + $Prepared.Expected.PSObject.Properties.Name | Should -Not -Contain 'restrictedDomains' + (Get-Verdict -Expected $Prepared.Expected -Current $Prepared.Current).Count | Should -BeGreaterThan 0 + } + + It 'grades the allow list order-insensitively and never grades the off-side block list' { + Mock New-CIPPDbRequest { @(@{ sharingDomainRestrictionMode = 'allowList'; sharingAllowedDomainList = @('a.com', 'b.com'); sharingBlockedDomainList = @('evil.com') } | ConvertTo-Cached) } + $Item = [PSCustomObject]@{ Variables = [PSCustomObject]@{ Mode = [PSCustomObject]@{ value = 'allowList' }; Domains = 'b.com, a.com' } } + $Prepared = Get-CIPPBaselinesharingDomainRestrictionState -Item $Item -TenantFilter $script:Tenant + (Get-Verdict -Expected $Prepared.Expected -Current $Prepared.Current).Count | Should -Be 0 + } + + It 'writes the mode plus ONLY the matching side''s list - for BOTH list modes' { + Mock New-GraphPostRequest { } + Invoke-CIPPBaselinesharingDomainRestriction -Remediate ([PSCustomObject]@{ mode = 'blockList'; domains = 'evil.com' }) -TenantFilter $script:Tenant -Current $null + Should -Invoke New-GraphPostRequest -Times 1 -Exactly -ParameterFilter { + $Parsed = $body | ConvertFrom-Json + $AsApp -eq $true -and $Parsed.sharingDomainRestrictionMode -eq 'blockList' -and + $Parsed.sharingBlockedDomainList -contains 'evil.com' -and -not $Parsed.PSObject.Properties['sharingAllowedDomainList'] + } + Invoke-CIPPBaselinesharingDomainRestriction -Remediate ([PSCustomObject]@{ mode = 'allowList'; domains = 'partner.com' }) -TenantFilter $script:Tenant -Current $null + Should -Invoke New-GraphPostRequest -Times 1 -Exactly -ParameterFilter { + $Parsed = $body | ConvertFrom-Json + $Parsed.sharingDomainRestrictionMode -eq 'allowList' -and + $Parsed.sharingAllowedDomainList -contains 'partner.com' -and -not $Parsed.PSObject.Properties['sharingBlockedDomainList'] + } + } + + It 'refuses a list mode with no domains rather than writing an empty restriction' { + Mock New-GraphPostRequest { } + Invoke-CIPPBaselinesharingDomainRestriction -Remediate ([PSCustomObject]@{ mode = 'allowList'; domains = '' }) -TenantFilter $script:Tenant -Current $null + Should -Invoke New-GraphPostRequest -Times 0 -Exactly + } +} + +Describe 'Get-CIPPBaselineSPDirectSharingState' { + It 'accepts the numeric API vintage: DefaultSharingLinkType 1 is Direct' { + Mock New-CIPPDbRequest { @(@{ DefaultSharingLinkType = 1 } | ConvertTo-Cached) } + $Prepared = Get-CIPPBaselineSPDirectSharingState -Item ([PSCustomObject]@{ Variables = [PSCustomObject]@{} }) -TenantFilter $script:Tenant + (Get-Verdict -Expected $Prepared.Expected -Current $Prepared.Current).Count | Should -Be 0 + } + + It 'reads the SPO tenant LIVE for a fresh CSOM identity before the write' { + # The cached identity goes stale and the SOAP endpoint rejects it - the write must + # never lean on the cache row. + Mock Get-CIPPSPOTenant { [PSCustomObject]@{ _ObjectIdentity_ = 'fresh'; TenantFilter = $script:Tenant; DefaultSharingLinkType = 2 } } + Mock Set-CIPPSPOTenant { } + Invoke-CIPPBaselineSPDirectSharing -Remediate ([PSCustomObject]@{}) -TenantFilter $script:Tenant -Current $null + Should -Invoke Get-CIPPSPOTenant -Times 1 -Exactly + Should -Invoke Set-CIPPSPOTenant -Times 1 -Exactly -ParameterFilter { $Properties.DefaultSharingLinkType -eq 1 } + } +} + +Describe 'Get-CIPPBaselineSPOVersionControlState' { + It 'auto-trim on grades the trim flag ALONE - SharePoint manages the limits itself' { + Mock New-CIPPDbRequest { @(@{ EnableAutoExpirationVersionTrim = $true; MajorVersionLimit = 500; ExpireVersionsAfterDays = 0 } | ConvertTo-Cached) } + $Item = [PSCustomObject]@{ Variables = [PSCustomObject]@{ EnableAutoTrim = $true; MajorVersionLimit = 50; ExpireVersionsAfterDays = 0 } } + $Prepared = Get-CIPPBaselineSPOVersionControlState -Item $Item -TenantFilter $script:Tenant + $Prepared.Expected.PSObject.Properties.Name | Should -Not -Contain 'majorVersionLimit' + (Get-Verdict -Expected $Prepared.Expected -Current $Prepared.Current).Count | Should -Be 0 + } + + It 'returns No Data for an expiry in the 1-29 gap SharePoint would refuse to store' { + Mock New-CIPPDbRequest { @(@{ EnableAutoExpirationVersionTrim = $false; MajorVersionLimit = 50; ExpireVersionsAfterDays = 0 } | ConvertTo-Cached) } + $Item = [PSCustomObject]@{ Variables = [PSCustomObject]@{ EnableAutoTrim = $false; MajorVersionLimit = 50; ExpireVersionsAfterDays = 14 } } + (Get-CIPPBaselineSPOVersionControlState -Item $Item -TenantFilter $script:Tenant).Current | Should -BeNullOrEmpty + } + + It 'manual mode grades the flag, the limit and the expiry together' { + Mock New-CIPPDbRequest { @(@{ EnableAutoExpirationVersionTrim = $false; MajorVersionLimit = 100; ExpireVersionsAfterDays = 0 } | ConvertTo-Cached) } + $Item = [PSCustomObject]@{ Variables = [PSCustomObject]@{ EnableAutoTrim = $false; MajorVersionLimit = 50; ExpireVersionsAfterDays = 0 } } + $Prepared = Get-CIPPBaselineSPOVersionControlState -Item $Item -TenantFilter $script:Tenant + (Get-Verdict -Expected $Prepared.Expected -Current $Prepared.Current).Count | Should -BeGreaterThan 0 + } + + It 'auto-trim writes the -1 sentinels through SetFileVersionPolicy' { + Mock Get-CIPPSPOTenant { [PSCustomObject]@{ _ObjectIdentity_ = 'fresh'; TenantFilter = $script:Tenant } } + Mock Set-CIPPSPOTenant { } + Invoke-CIPPBaselineSPOVersionControl -Remediate ([PSCustomObject]@{ enableAutoTrim = $true }) -TenantFilter $script:Tenant -Current $null + Should -Invoke Set-CIPPSPOTenant -Times 1 -Exactly -ParameterFilter { + $MethodName -eq 'SetFileVersionPolicy' -and @($MethodParameters)[1].Value -eq -1 -and @($MethodParameters)[2].Value -eq -1 + } + } + + It 'the existing-sites fan-out continues past a failing site' { + Mock Get-CIPPSPOTenant { [PSCustomObject]@{ _ObjectIdentity_ = 'fresh'; TenantFilter = $script:Tenant } } + Mock Set-CIPPSPOTenant { } + Mock New-GraphGetRequest { @([PSCustomObject]@{ webUrl = 'https://c.sharepoint.com/sites/bad' }, [PSCustomObject]@{ webUrl = 'https://c.sharepoint.com/sites/good' }) } + Mock Set-CIPPSPOSite { if ($SiteUrl -like '*bad') { throw 'site locked' } } + Invoke-CIPPBaselineSPOVersionControl -Remediate ([PSCustomObject]@{ enableAutoTrim = $true; applyToExistingSites = $true }) -TenantFilter $script:Tenant -Current $null + Should -Invoke Set-CIPPSPOSite -Times 2 -Exactly + Should -Invoke Set-CIPPSPOSite -Times 1 -Exactly -ParameterFilter { $SiteUrl -like '*good' -and $Properties.InheritVersionPolicyFromTenant -eq $true } + } +} + +Describe 'Get-CIPPBaselineMailContactsState' { + BeforeAll { + $script:Org = @{ id = 'org-1'; marketingNotificationEmails = @('news@vendor.com', 'marketing@contoso.com') + technicalNotificationMails = @('security@contoso.com', 'tech@contoso.com'); privacyProfile = @{ contactEmail = 'privacy@contoso.com' } } + } + + It 'grades only the configured contacts - empty fields express no opinion' { + Mock New-CIPPDbRequest { @($script:Org | ConvertTo-Cached) } + $Item = [PSCustomObject]@{ Variables = [PSCustomObject]@{ MarketingContact = 'marketing@contoso.com' } } + $Prepared = Get-CIPPBaselineMailContactsState -Item $Item -TenantFilter $script:Tenant + $Prepared.Expected.PSObject.Properties.Name | Should -Be @('marketingContactPresent') + # Marketing is CONTAINS: the vendor address on the tenant list is fine. + (Get-Verdict -Expected $Prepared.Expected -Current $Prepared.Current).Count | Should -Be 0 + } + + It 'grades security+technical as a sorted set against the technical notification list' { + Mock New-CIPPDbRequest { @($script:Org | ConvertTo-Cached) } + $Item = [PSCustomObject]@{ Variables = [PSCustomObject]@{ TechContact = 'tech@contoso.com'; SecurityContact = 'security@contoso.com'; GeneralContact = 'privacy@contoso.com' } } + $Prepared = Get-CIPPBaselineMailContactsState -Item $Item -TenantFilter $script:Tenant + (Get-Verdict -Expected $Prepared.Expected -Current $Prepared.Current).Count | Should -Be 0 + } + + It 'writes only the configured members and dedups a shared security/tech address' { + Mock New-GraphPostRequest { } + $Remediate = [PSCustomObject]@{ securityContact = 'it@contoso.com'; techContact = 'it@contoso.com' } + Invoke-CIPPBaselineMailContacts -Remediate $Remediate -TenantFilter $script:Tenant -Current ([PSCustomObject]@{ organizationId = 'org-1' }) + Should -Invoke New-GraphPostRequest -Times 1 -Exactly -ParameterFilter { + $Parsed = $body | ConvertFrom-Json + $AsApp -eq $true -and $uri -like '*organization/org-1' -and @($Parsed.technicalNotificationMails).Count -eq 1 -and + -not $Parsed.PSObject.Properties['privacyProfile'] -and -not $Parsed.PSObject.Properties['marketingNotificationEmails'] + } + } +} + +Describe 'Get-CIPPBaselineProfilePhotosState' { + It 'disabled demands BOTH admin role ids - one alone leaves the policy incorrect' { + Mock New-CIPPDbRequest { + if ($Type -eq 'PhotoUpdateSettings') { @(@{ source = 'cloud'; allowedRoles = @('62e90394-69f5-4237-9190-012177145e10') } | ConvertTo-Cached) } + else { @(@{ Identity = 'OwaMailboxPolicy-Default'; SetPhotoEnabled = $false } | ConvertTo-Cached) } + } + $Item = [PSCustomObject]@{ Variables = [PSCustomObject]@{ state = [PSCustomObject]@{ value = 'disabled' } } } + $Prepared = Get-CIPPBaselineProfilePhotosState -Item $Item -TenantFilter $script:Tenant + $Prepared.Current.photoUpdatePolicyCorrect | Should -BeFalse + (Get-Verdict -Expected $Prepared.Expected -Current $Prepared.Current).Count | Should -BeGreaterThan 0 + } + + It 'enabled grades BOTH surfaces: empty Graph roles AND the default OWA policy flag' { + Mock New-CIPPDbRequest { + if ($Type -eq 'PhotoUpdateSettings') { @(@{ source = 'cloud'; allowedRoles = @() } | ConvertTo-Cached) } + else { @(@{ Identity = 'OwaMailboxPolicy-Default'; SetPhotoEnabled = $true } | ConvertTo-Cached) } + } + $Item = [PSCustomObject]@{ Variables = [PSCustomObject]@{ state = [PSCustomObject]@{ value = 'enabled' } } } + $Prepared = Get-CIPPBaselineProfilePhotosState -Item $Item -TenantFilter $script:Tenant + (Get-Verdict -Expected $Prepared.Expected -Current $Prepared.Current).Count | Should -Be 0 + } + + It 'enable DELETEs the photo policy back to default; disable PATCHes the admin roles - both app-only' { + Mock New-ExoRequest { } + Mock New-GraphPostRequest { } + $Current = [PSCustomObject]@{ owaPolicyIdentity = 'OwaMailboxPolicy-Default' } + Invoke-CIPPBaselineProfilePhotos -Remediate ([PSCustomObject]@{ state = 'enabled' }) -TenantFilter $script:Tenant -Current $Current + Should -Invoke New-GraphPostRequest -Times 1 -Exactly -ParameterFilter { $type -eq 'DELETE' -and $AsApp -eq $true } + Invoke-CIPPBaselineProfilePhotos -Remediate ([PSCustomObject]@{ state = 'disabled' }) -TenantFilter $script:Tenant -Current $Current + Should -Invoke New-GraphPostRequest -Times 1 -Exactly -ParameterFilter { + $type -eq 'PATCH' -and $AsApp -eq $true -and ($body | ConvertFrom-Json).allowedRoles.Count -eq 2 + } + Should -Invoke New-ExoRequest -Times 2 -Exactly -ParameterFilter { $cmdlet -eq 'Set-OwaMailboxPolicy' -and $cmdParams.Identity -eq 'OwaMailboxPolicy-Default' } + } +} + +Describe 'Get-CIPPBaselineSecureScoreRemediationState' { + It 'the effective state is the NEWEST controlStateUpdates entry, not the first' { + Mock New-CIPPDbRequest { @(@{ id = 'AdminMFAV2'; controlStateUpdates = @( + @{ state = 'ignored'; updatedDateTime = '2024-01-01T00:00:00Z' } + @{ state = 'default'; updatedDateTime = '2025-06-01T00:00:00Z' } + ) } | ConvertTo-Cached) } + $Item = [PSCustomObject]@{ Variables = [PSCustomObject]@{ Default = @([PSCustomObject]@{ value = 'AdminMFAV2' }) } } + $Prepared = Get-CIPPBaselineSecureScoreRemediationState -Item $Item -TenantFilter $script:Tenant + (Get-Verdict -Expected $Prepared.Expected -Current $Prepared.Current).Count | Should -Be 0 + } + + It 'no state updates means default - an ignored-list control with none is drift' { + Mock New-CIPPDbRequest { @(@{ id = 'AdminMFAV2'; controlStateUpdates = @() } | ConvertTo-Cached) } + $Item = [PSCustomObject]@{ Variables = [PSCustomObject]@{ Ignored = @('AdminMFAV2') } } + $Prepared = Get-CIPPBaselineSecureScoreRemediationState -Item $Item -TenantFilter $script:Tenant + (Get-Verdict -Expected $Prepared.Expected -Current $Prepared.Current).Count | Should -BeGreaterThan 0 + $Prepared.Current.driftedControls[0].State | Should -Be 'ignored' + } + + It 'the Reviewed list grades against the reviewed state - the fourth list the classic carried' { + Mock New-CIPPDbRequest { @(@{ id = 'AdminMFAV2'; controlStateUpdates = @(@{ state = 'Reviewed'; updatedDateTime = '2025-01-01T00:00:00Z' }) } | ConvertTo-Cached) } + $Item = [PSCustomObject]@{ Variables = [PSCustomObject]@{ Reviewed = @([PSCustomObject]@{ value = 'AdminMFAV2' }) } } + $Prepared = Get-CIPPBaselineSecureScoreRemediationState -Item $Item -TenantFilter $script:Tenant + (Get-Verdict -Expected $Prepared.Expected -Current $Prepared.Current).Count | Should -Be 0 + Mock New-CIPPDbRequest { @(@{ id = 'AdminMFAV2'; controlStateUpdates = @() } | ConvertTo-Cached) } + $Prepared2 = Get-CIPPBaselineSecureScoreRemediationState -Item $Item -TenantFilter $script:Tenant + $Prepared2.Current.driftedControls[0].State | Should -Be 'reviewed' + } + + It 'never grades controls outside the configured lists' { + Mock New-CIPPDbRequest { @(@{ id = 'SomeOtherControl'; controlStateUpdates = @(@{ state = 'thirdParty'; updatedDateTime = '2025-01-01T00:00:00Z' }) } | ConvertTo-Cached) } + $Item = [PSCustomObject]@{ Variables = [PSCustomObject]@{ Ignored = @('AdminMFAV2') } } + $Prepared = Get-CIPPBaselineSecureScoreRemediationState -Item $Item -TenantFilter $script:Tenant + @($Prepared.Current.controlsOutOfState) | Should -Not -Contain 'SomeOtherControl' + } + + It 'skips Defender scid_ controls and bulk-patches the rest with the SecureScore vendor block' { + Mock New-GraphBulkRequest { @([PSCustomObject]@{ id = '1'; status = 200 }) } + $Current = [PSCustomObject]@{ driftedControls = @( + [PSCustomObject]@{ Control = 'scid_2060'; State = 'ignored'; Reason = 'Ignored' } + [PSCustomObject]@{ Control = 'AdminMFAV2'; State = 'ignored'; Reason = 'Ignored' } + ) } + Invoke-CIPPBaselineSecureScoreRemediation -Remediate ([PSCustomObject]@{}) -TenantFilter $script:Tenant -Current $Current + Should -Invoke New-GraphBulkRequest -Times 1 -Exactly -ParameterFilter { + @($Requests).Count -eq 1 -and @($Requests)[0].url -like '*AdminMFAV2' -and @($Requests)[0].body.vendorInformation.provider -eq 'SecureScore' + } + } + + It 'throws only when EVERY control write fails - partial success is success' { + Mock New-GraphBulkRequest { @([PSCustomObject]@{ id = '1'; status = 400; body = @{ error = @{ message = 'nope' } } }) } + $Current = [PSCustomObject]@{ driftedControls = @([PSCustomObject]@{ Control = 'AdminMFAV2'; State = 'ignored'; Reason = 'Ignored' }) } + { Invoke-CIPPBaselineSecureScoreRemediation -Remediate ([PSCustomObject]@{}) -TenantFilter $script:Tenant -Current $Current } | Should -Throw + } +} diff --git a/Tests/Baselines/BaselineTemplateFamilies.Tests.ps1 b/Tests/Baselines/BaselineTemplateFamilies.Tests.ps1 new file mode 100644 index 0000000000000..c2d5a6b8f77e7 --- /dev/null +++ b/Tests/Baselines/BaselineTemplateFamilies.Tests.ps1 @@ -0,0 +1,385 @@ +# The template families under the instance model: the baseline stores one instance per +# selected template (multiple + instanceIdentity), so every hook resolves ONE reference. +# Each family still resolves its own templates from its own PartitionKey and grades on its +# own terms, and that is not stylistic: three of the eleven template families store rows +# under a partition whose name does NOT match the standard name ('ExConnectorTemplate', +# 'ContactTemplate', 'IntuneReusableSettingTemplate'). A shared resolver keyed on the +# standard name finds nothing, reports every template missing, and remediation then deploys +# duplicates. These tests pin the per-family behaviour a shared helper would erase. +# +# Fixtures go through ConvertFrom-Json on purpose: that is how New-CIPPDbRequest returns +# cached rows, and a PowerShell literal would carry Int32 where production carries Int64. + +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) } + function Get-CIPPDbItem { param($TenantFilter, $Type, [switch]$CountsOnly) } + function Get-CippTable { param($tablename) @{ TableName = $tablename } } + function ConvertTo-CIPPODataFilterValue { param($Value) "$Value" } + function Get-CIPPAzDataTableEntity { param($TableName, $Filter) } + # The real one is [CmdletBinding()], which is what lets the hook pass -ErrorAction Stop. + function Compare-CIPPSensitiveInfoType { [CmdletBinding()] param($TenantFilter, $Template) } + function Set-CIPPSensitivityLabel { param($TenantFilter, $Template, $APIName, $Headers) } + function Set-CIPPRetentionCompliancePolicy { param($TenantFilter, $Template, $APIName, $Headers) } + function Set-CIPPSensitiveInfoType { param($TenantFilter, $Template, $APIName, $Headers) } + + . (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 'Test-CIPPBaselineCacheCollected.ps1') + . (Join-Path $Baselines 'Get-CIPPBaselineSensitivityLabelTemplateState.ps1') + . (Join-Path $Baselines 'Get-CIPPBaselineRetentionCompliancePolicyTemplateState.ps1') + . (Join-Path $Baselines 'Get-CIPPBaselineSensitiveInfoTypeTemplateState.ps1') + . (Join-Path $Baselines 'Get-CIPPBaselineTransportRuleTemplateState.ps1') + . (Join-Path $Baselines 'Invoke-CIPPBaselineSensitivityLabelTemplate.ps1') + . (Join-Path $Baselines 'Invoke-CIPPBaselineRetentionCompliancePolicyTemplate.ps1') + . (Join-Path $Baselines 'Invoke-CIPPBaselineSensitiveInfoTypeTemplate.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-CIPPBaselineSensitivityLabelTemplateState' { + # A label created outside CIPP routinely carries a GUID-ish Name with the human name + # only in DisplayName. Matching on Name alone reports a DEPLOYED label as missing, and + # because this family writes unconditionally, remediation would then redeploy it on + # every single run. + BeforeAll { + $script:Item = [PSCustomObject]@{ Variables = [PSCustomObject]@{ sensitivityLabelTemplate = 'tpl-1' } } + } + BeforeEach { + Mock Get-CIPPDbItem { [PSCustomObject]@{ RowKey = 'ExoLabels-Count'; DataCount = 1 } } + Mock Get-CIPPAzDataTableEntity { [PSCustomObject]@{ RowKey = 'tpl-1'; JSON = '{"Name":"Confidential"}' } } + } + + It 'matches a deployed label on DisplayName when its Name differs' { + Mock New-CIPPDbRequest { @(@{ Name = '9f1c-guid-ish'; DisplayName = 'Confidential' } | ConvertTo-Cached) } + $Prepared = Get-CIPPBaselineSensitivityLabelTemplateState -Item $script:Item -TenantFilter $script:Tenant + @($Prepared.Current.missingLabels).Count | Should -Be 0 + (Get-Verdict -Expected $Prepared.Expected -Current $Prepared.Current).Count | Should -Be 0 + } + + It 'matches a deployed label on Name' { + Mock New-CIPPDbRequest { @(@{ Name = 'Confidential'; DisplayName = 'Something else' } | ConvertTo-Cached) } + @((Get-CIPPBaselineSensitivityLabelTemplateState -Item $script:Item -TenantFilter $script:Tenant).Current.missingLabels).Count | Should -Be 0 + } + + It 'reports a label the tenant does not have as drift' { + Mock New-CIPPDbRequest { @(@{ Name = 'Public'; DisplayName = 'Public' } | ConvertTo-Cached) } + $Prepared = Get-CIPPBaselineSensitivityLabelTemplateState -Item $script:Item -TenantFilter $script:Tenant + $Prepared.Current.missingLabels | Should -Contain 'Confidential' + (Get-Verdict -Expected $Prepared.Expected -Current $Prepared.Current).Count | Should -BeGreaterThan 0 + } + + It 'reports drift, not No Data, when the tenant genuinely has no labels at all' { + # Collected-and-empty is a real answer here: no labels means the label is missing. + Mock New-CIPPDbRequest { @() } + $Prepared = Get-CIPPBaselineSensitivityLabelTemplateState -Item $script:Item -TenantFilter $script:Tenant + $Prepared.Current | Should -Not -BeNullOrEmpty + $Prepared.Current.missingLabels | Should -Contain 'Confidential' + } + + It 'reports unknown when the label cache has never been collected' { + Mock New-CIPPDbRequest { @() } + Mock Get-CIPPDbItem { $null } + (Get-CIPPBaselineSensitivityLabelTemplateState -Item $script:Item -TenantFilter $script:Tenant).Current | Should -BeNullOrEmpty + } + + It 'reports unknown when the selected template no longer resolves' { + # A deleted template is not a compliant tenant - the instance cannot evaluate. + Mock New-CIPPDbRequest { @(@{ Name = 'Public'; DisplayName = 'Public' } | ConvertTo-Cached) } + Mock Get-CIPPAzDataTableEntity { $null } + (Get-CIPPBaselineSensitivityLabelTemplateState -Item $script:Item -TenantFilter $script:Tenant).Current | Should -BeNullOrEmpty + } + + It 'resolves the picker whether it stored a plain id or a label/value pair' { + Mock New-CIPPDbRequest { @(@{ Name = 'Public'; DisplayName = 'Public' } | ConvertTo-Cached) } + $Paired = [PSCustomObject]@{ Variables = [PSCustomObject]@{ sensitivityLabelTemplate = [PSCustomObject]@{ label = 'Confidential'; value = 'tpl-1' } } } + (Get-CIPPBaselineSensitivityLabelTemplateState -Item $Paired -TenantFilter $script:Tenant).Current.missingLabels | Should -Contain 'Confidential' + } + + It 'carries the template body for the executor, which rewrites unconditionally' { + Mock New-CIPPDbRequest { @(@{ Name = 'Confidential'; DisplayName = 'Confidential' } | ConvertTo-Cached) } + $Prepared = Get-CIPPBaselineSensitivityLabelTemplateState -Item $script:Item -TenantFilter $script:Tenant + @($Prepared.Current.templateBodies).Count | Should -Be 1 + } +} + +Describe 'Get-CIPPBaselineRetentionCompliancePolicyTemplateState' { + # Deliberately asymmetric with the label family: retention policies carry no display + # name, and the classic matched on Name alone. + BeforeAll { + $script:RetentionItem = [PSCustomObject]@{ Variables = [PSCustomObject]@{ retentionCompliancePolicyTemplate = 'tpl-r' } } + } + BeforeEach { + Mock Get-CIPPDbItem { [PSCustomObject]@{ RowKey = 'ComplianceRetentionPolicies-Count'; DataCount = 1 } } + Mock Get-CIPPAzDataTableEntity { [PSCustomObject]@{ RowKey = 'tpl-r'; JSON = '{"Name":"7 Year Retention"}' } } + } + + It 'is compliant when the policy is deployed' { + Mock New-CIPPDbRequest { @(@{ Name = '7 Year Retention' } | ConvertTo-Cached) } + $Prepared = Get-CIPPBaselineRetentionCompliancePolicyTemplateState -Item $script:RetentionItem -TenantFilter $script:Tenant + (Get-Verdict -Expected $Prepared.Expected -Current $Prepared.Current).Count | Should -Be 0 + } + + It 'reports a policy the tenant does not have as drift' { + Mock New-CIPPDbRequest { @(@{ Name = 'Something Else' } | ConvertTo-Cached) } + $Prepared = Get-CIPPBaselineRetentionCompliancePolicyTemplateState -Item $script:RetentionItem -TenantFilter $script:Tenant + $Prepared.Current.missingPolicies | Should -Contain '7 Year Retention' + (Get-Verdict -Expected $Prepared.Expected -Current $Prepared.Current).Count | Should -BeGreaterThan 0 + } + + It 'reports unknown when the retention cache has never been collected' { + Mock New-CIPPDbRequest { @() } + Mock Get-CIPPDbItem { $null } + (Get-CIPPBaselineRetentionCompliancePolicyTemplateState -Item $script:RetentionItem -TenantFilter $script:Tenant).Current | Should -BeNullOrEmpty + } + + It 'reports unknown when the selected template no longer resolves' { + # A deleted template is not a compliant tenant. Grading an empty template as + # compliant would report success for a standard that is deploying nothing. + Mock New-CIPPDbRequest { @(@{ Name = '7 Year Retention' } | ConvertTo-Cached) } + Mock Get-CIPPAzDataTableEntity { $null } + (Get-CIPPBaselineRetentionCompliancePolicyTemplateState -Item $script:RetentionItem -TenantFilter $script:Tenant).Current | Should -BeNullOrEmpty + } +} + +Describe 'Get-CIPPBaselineSensitiveInfoTypeTemplateState' { + # The only template family that diffs rather than checking presence, and the only one + # whose non-compliant set and remediable set are DIFFERENT sets. + BeforeAll { + $script:SitItem = [PSCustomObject]@{ Variables = [PSCustomObject]@{ sensitiveInfoTypeTemplate = 'tpl-s' } } + } + BeforeEach { + Mock Get-CIPPAzDataTableEntity { [PSCustomObject]@{ RowKey = 'tpl-s'; JSON = '{"Name":"Employee ID"}' } } + } + + It 'treats an in-sync SIT as compliant' { + Mock Compare-CIPPSensitiveInfoType { [PSCustomObject]@{ Name = 'Employee ID'; State = 'InSync'; Differences = @() } } + $Prepared = Get-CIPPBaselineSensitiveInfoTypeTemplateState -Item $script:SitItem -TenantFilter $script:Tenant + (Get-Verdict -Expected $Prepared.Expected -Current $Prepared.Current).Count | Should -Be 0 + } + + It 'treats a built-in Microsoft SIT as compliant, not as a permanent failure' { + # A built-in cannot be modified, so reporting it non-compliant would fail forever + # and no amount of remediation could ever resolve it. + Mock Compare-CIPPSensitiveInfoType { [PSCustomObject]@{ Name = 'Employee ID'; State = 'BuiltIn'; Differences = @() } } + $Prepared = Get-CIPPBaselineSensitiveInfoTypeTemplateState -Item $script:SitItem -TenantFilter $script:Tenant + (Get-Verdict -Expected $Prepared.Expected -Current $Prepared.Current).Count | Should -Be 0 + @($Prepared.Current.remediableTemplates).Count | Should -Be 0 + } + + It 'reports a drifted SIT and hands it to the executor' { + Mock Compare-CIPPSensitiveInfoType { [PSCustomObject]@{ Name = 'Employee ID'; State = 'Drift'; Differences = @([PSCustomObject]@{ Scope = 'Entity'; Field = 'confidence' }) } } + $Prepared = Get-CIPPBaselineSensitiveInfoTypeTemplateState -Item $script:SitItem -TenantFilter $script:Tenant + (Get-Verdict -Expected $Prepared.Expected -Current $Prepared.Current).Count | Should -BeGreaterThan 0 + @($Prepared.Current.remediableTemplates).Count | Should -Be 1 + } + + It 'reports a missing SIT and hands it to the executor' { + Mock Compare-CIPPSensitiveInfoType { [PSCustomObject]@{ Name = 'Employee ID'; State = 'Missing'; Differences = @() } } + $Prepared = Get-CIPPBaselineSensitiveInfoTypeTemplateState -Item $script:SitItem -TenantFilter $script:Tenant + (Get-Verdict -Expected $Prepared.Expected -Current $Prepared.Current).Count | Should -BeGreaterThan 0 + @($Prepared.Current.remediableTemplates).Count | Should -Be 1 + } + + It 'reports an invalid template but never hands it to the executor' { + # Invalid is a broken TEMPLATE - neither Pattern nor rule pack - so deploying fails. + Mock Compare-CIPPSensitiveInfoType { [PSCustomObject]@{ Name = 'Employee ID'; State = 'Invalid'; Differences = @([PSCustomObject]@{ Scope = 'Template'; Field = 'source' }) } } + $Prepared = Get-CIPPBaselineSensitiveInfoTypeTemplateState -Item $script:SitItem -TenantFilter $script:Tenant + (Get-Verdict -Expected $Prepared.Expected -Current $Prepared.Current).Count | Should -BeGreaterThan 0 + @($Prepared.Current.remediableTemplates).Count | Should -Be 0 + } + + It 'reports unknown when the tenant read fails, rather than reporting the SIT broken' { + # A failed read is not drift. Grading it as drift would hand the executor a rewrite + # on the strength of an outage. + Mock Compare-CIPPSensitiveInfoType { throw 'compliance endpoint unavailable' } + (Get-CIPPBaselineSensitiveInfoTypeTemplateState -Item $script:SitItem -TenantFilter $script:Tenant).Current | Should -BeNullOrEmpty + } + + It 'reports unknown when the selected template no longer resolves' { + Mock Get-CIPPAzDataTableEntity { $null } + (Get-CIPPBaselineSensitiveInfoTypeTemplateState -Item $script:SitItem -TenantFilter $script:Tenant).Current | Should -BeNullOrEmpty + } +} + +Describe 'Get-CIPPBaselineTransportRuleTemplateState' { + # Presence by name against Identity, DisplayName AND Name - Exchange returns the rule + # under different ones depending on how it was created. + BeforeAll { + $script:TransportItem = [PSCustomObject]@{ Variables = [PSCustomObject]@{ transportRuleTemplate = 'tpl-t' } } + } + BeforeEach { + Mock Get-CIPPDbItem { [PSCustomObject]@{ RowKey = 'ExoTransportRules-Count'; DataCount = 1 } } + Mock Get-CIPPAzDataTableEntity { [PSCustomObject]@{ RowKey = 'tpl-t'; JSON = '{"name":"Block Autoforward","FromScope":"InOrganization"}' } } + } + + It 'is compliant when the rule exists under any of Identity, DisplayName or Name' { + Mock New-CIPPDbRequest { @(@{ Identity = 'some-guid'; DisplayName = 'Block Autoforward'; Name = 'other' } | ConvertTo-Cached) } + $Prepared = Get-CIPPBaselineTransportRuleTemplateState -Item $script:TransportItem -TenantFilter $script:Tenant + (Get-Verdict -Expected $Prepared.Expected -Current $Prepared.Current).Count | Should -Be 0 + } + + It 'reports a rule the tenant does not have as drift' { + Mock New-CIPPDbRequest { @(@{ Identity = 'x'; DisplayName = 'Another rule'; Name = 'Another rule' } | ConvertTo-Cached) } + $Prepared = Get-CIPPBaselineTransportRuleTemplateState -Item $script:TransportItem -TenantFilter $script:Tenant + $Prepared.Current.missingTransportRules | Should -Contain 'Block Autoforward' + (Get-Verdict -Expected $Prepared.Expected -Current $Prepared.Current).Count | Should -BeGreaterThan 0 + } + + It 'reports unknown when the selected template no longer resolves' { + # The classic continued past an unresolvable template and read as compliant. Under + # the instance model the template IS the instance - a deleted template means the + # standard cannot evaluate, and compliant-by-deletion would be a lie. + Mock New-CIPPDbRequest { @(@{ Identity = 'x'; DisplayName = 'y'; Name = 'y' } | ConvertTo-Cached) } + Mock Get-CIPPAzDataTableEntity { $null } + (Get-CIPPBaselineTransportRuleTemplateState -Item $script:TransportItem -TenantFilter $script:Tenant).Current | Should -BeNullOrEmpty + } + + It 'reports unknown when the transport rule cache has never been collected' { + Mock New-CIPPDbRequest { @() } + Mock Get-CIPPDbItem { $null } + (Get-CIPPBaselineTransportRuleTemplateState -Item $script:TransportItem -TenantFilter $script:Tenant).Current | Should -BeNullOrEmpty + } + + It 'carries the rule body for the executor' { + Mock New-CIPPDbRequest { @(@{ Identity = 'x'; DisplayName = 'y'; Name = 'y' } | ConvertTo-Cached) } + $Prepared = Get-CIPPBaselineTransportRuleTemplateState -Item $script:TransportItem -TenantFilter $script:Tenant + @($Prepared.Current.ruleBodies).Count | Should -Be 1 + $Prepared.Current.ruleBodies[0].FromScope | Should -Be 'InOrganization' + } +} + +Describe 'Template family executors' { + # The label and retention families write unconditionally (checkBeforeRun:false) because + # their compare only grades presence - the rewrite is what repairs setting-level drift + # they cannot see. The SIT family is the opposite: it writes only what it proved needs + # writing. + It 'rewrites the selected label even when it is already present' { + Mock Set-CIPPSensitivityLabel { } + $Current = [PSCustomObject]@{ missingLabels = @(); templateBodies = @([PSCustomObject]@{ Name = 'A' }) } + Invoke-CIPPBaselineSensitivityLabelTemplate -Remediate $null -TenantFilter $script:Tenant -Current $Current + Should -Invoke Set-CIPPSensitivityLabel -Times 1 -Exactly + } + + It 'rewrites the selected retention policy even when it is already present' { + Mock Set-CIPPRetentionCompliancePolicy { } + $Current = [PSCustomObject]@{ missingPolicies = @(); templateBodies = @([PSCustomObject]@{ Name = 'A' }) } + Invoke-CIPPBaselineRetentionCompliancePolicyTemplate -Remediate $null -TenantFilter $script:Tenant -Current $Current + Should -Invoke Set-CIPPRetentionCompliancePolicy -Times 1 -Exactly + } + + It 'writes only the SITs the hook marked remediable' { + Mock Set-CIPPSensitiveInfoType { 'Updated Employee ID' } + $Current = [PSCustomObject]@{ + nonCompliantSensitiveInfoTypes = @([PSCustomObject]@{ Name = 'Broken'; State = 'Invalid' }) + remediableTemplates = @() + } + Invoke-CIPPBaselineSensitiveInfoTypeTemplate -Remediate $null -TenantFilter $script:Tenant -Current $Current + Should -Invoke Set-CIPPSensitiveInfoType -Times 0 -Exactly + } + + It 'logs a SIT deploy that did not report Created or Updated as an error' { + # Set-CIPPSensitiveInfoType reports failure in its return string rather than by + # throwing, so a failed deploy is silent unless the result is inspected. + Mock Set-CIPPSensitiveInfoType { 'Failed to deploy Employee ID: rule pack rejected' } + Mock Write-LogMessage { } + $Current = [PSCustomObject]@{ remediableTemplates = @([PSCustomObject]@{ Name = 'Employee ID' }) } + Invoke-CIPPBaselineSensitiveInfoTypeTemplate -Remediate $null -TenantFilter $script:Tenant -Current $Current + Should -Invoke Write-LogMessage -Times 1 -Exactly -ParameterFilter { $Sev -eq 'Error' } + } + + It 'does nothing at all when there is nothing to deploy' { + Mock Set-CIPPSensitivityLabel { } + Invoke-CIPPBaselineSensitivityLabelTemplate -Remediate $null -TenantFilter $script:Tenant -Current ([PSCustomObject]@{ templateBodies = @() }) + Should -Invoke Set-CIPPSensitivityLabel -Times 0 -Exactly + } +} + +Describe 'Instance row labeling' { + # Ten instances of one standard are ten rows sharing one definition label - the suffix + # is what tells them apart. CA/Intune surface it as ExpectedValue.displayName; the + # presence-shaped families carry no name in Expected, so it comes from the identity + # variable on the effective Inheritance entry, resolved through the DECLARED partition + # and name field. The executor-name default would look in the wrong partition for every + # family whose partition does not match its executor. + BeforeAll { + . (Join-Path $script:RepoRoot 'Modules/CIPPCore/Public/Baselines/Convert-CIPPBaselineResolvedEntity.ps1') + $script:CaDefinition = [PSCustomObject]@{ + name = 'ConditionalAccessTemplate'; label = 'Conditional Access Template' + instanceIdentity = 'caTemplate' + remediate = [PSCustomObject]@{ executor = 'CATemplate' } + } + # Transport is the fixture ON PURPOSE: its partition ('TransportTemplate') differs + # from its executor ('TransportRuleTemplate'), so a resolver that falls back to the + # executor-name convention queries the wrong partition and these tests fail. A + # family whose two names coincide could never detect that regression. + $script:TransportDefinition = [PSCustomObject]@{ + name = 'TransportRuleTemplate'; label = 'Transport Rule Template' + instanceIdentity = 'transportRuleTemplate' + identity = [PSCustomObject]@{ partition = 'TransportTemplate'; nameField = 'name' } + remediate = [PSCustomObject]@{ executor = 'TransportRuleTemplate' } + } + $script:Lookup = { + param($Partition, $Id, $NameField) + $script:LookupCalls.Add(@{ Partition = $Partition; Id = $Id; NameField = $NameField }) + if ($Partition -eq 'TransportTemplate' -and $Id -eq 'tpl-t') { 'Block Autoforward' } + elseif ($Partition -eq 'CATemplate' -and $Id -eq 'ca-guid') { 'Block legacy auth' } + } + } + BeforeEach { $script:LookupCalls = [System.Collections.Generic.List[object]]::new() } + + It 'labels a CA-style row from ExpectedValue.displayName, via the executor partition' { + $Entity = [PSCustomObject]@{ + PartitionKey = 'contoso.onmicrosoft.com'; StandardName = 'ConditionalAccessTemplate#ab12cd34' + ExpectedValue = '{"displayName":"ca-guid","state":"enabled"}' + } + $Row = Convert-CIPPBaselineResolvedEntity -Entity $Entity -Definitions @($script:CaDefinition) -ResolveTemplateName $script:Lookup + $Row.standardLabel | Should -Be 'Conditional Access Template - Block legacy auth' + $script:LookupCalls[0].Partition | Should -Be 'CATemplate' + } + + It 'labels a presence-shaped row from the Inheritance identity variable, via the declared partition' { + $Entity = [PSCustomObject]@{ + PartitionKey = 'contoso.onmicrosoft.com'; StandardName = 'TransportRuleTemplate#ab12cd34' + ExpectedValue = '{"deployedTransportRules":[],"missingTransportRules":[]}' + Inheritance = '[{"templateName":"Third baseline","effective":true,"value":{"transportRuleTemplate":{"label":"Block Autoforward","value":"tpl-t"}}}]' + } + $Row = Convert-CIPPBaselineResolvedEntity -Entity $Entity -Definitions @($script:TransportDefinition) -ResolveTemplateName $script:Lookup + $Row.standardLabel | Should -Be 'Transport Rule Template - Block Autoforward' + $script:LookupCalls[0].Partition | Should -Be 'TransportTemplate' + $script:LookupCalls[0].NameField | Should -Be 'name' + } + + It 'unwraps a plain-string identity variable the same way' { + $Entity = [PSCustomObject]@{ + PartitionKey = 'contoso.onmicrosoft.com'; StandardName = 'TransportRuleTemplate#ab12cd34' + ExpectedValue = '{"deployedTransportRules":[],"missingTransportRules":[]}' + Inheritance = '[{"templateName":"Third baseline","effective":true,"value":{"transportRuleTemplate":"tpl-t"}}]' + } + $Row = Convert-CIPPBaselineResolvedEntity -Entity $Entity -Definitions @($script:TransportDefinition) -ResolveTemplateName $script:Lookup + $Row.standardLabel | Should -Be 'Transport Rule Template - Block Autoforward' + } + + It 'falls back to the definition label when nothing identifies the instance' { + $Entity = [PSCustomObject]@{ + PartitionKey = 'contoso.onmicrosoft.com'; StandardName = 'TransportRuleTemplate#ab12cd34' + ExpectedValue = '{"deployedTransportRules":[],"missingTransportRules":[]}' + } + $Row = Convert-CIPPBaselineResolvedEntity -Entity $Entity -Definitions @($script:TransportDefinition) -ResolveTemplateName $script:Lookup + $Row.standardLabel | Should -Be 'Transport Rule Template' + } +} diff --git a/Tests/Baselines/BaselineTemplateFamilies2.Tests.ps1 b/Tests/Baselines/BaselineTemplateFamilies2.Tests.ps1 new file mode 100644 index 0000000000000..7bc5aa9def8b8 --- /dev/null +++ b/Tests/Baselines/BaselineTemplateFamilies2.Tests.ps1 @@ -0,0 +1,401 @@ +# The second tranche of template families, all under the instance model. As with the first +# tranche, each family resolves its own partition and grades on its own terms; these tests +# pin the decisions that fail SILENTLY - a wrong cache, an ungraded direction, a compare +# that never fires - where the standard reports Compliant forever and nobody notices. + +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) } + function Get-CIPPDbItem { param($TenantFilter, $Type, [switch]$CountsOnly) } + function Get-CippTable { param($tablename) @{ TableName = $tablename } } + function ConvertTo-CIPPODataFilterValue { param($Value) "$Value" } + function Get-CIPPAzDataTableEntity { param($TableName, $Filter) } + function Convert-QuarantinePermissionsValue { [CmdletBinding()] param($InputObject) } + function New-CIPPAssignmentFilter { param($FilterObject, $TenantFilter, $APIName) } + function New-CIPPGroup { param($GroupObject, $TenantFilter, $APIName) } + function New-GraphPostRequest { param($uri, $tenantid, $type, $body) } + function New-ExoRequest { param($tenantid, $cmdlet, $cmdParams, $useSystemMailbox, $Select, $Compliance, $AsApp) } + function Get-CIPPTextReplacement { param($Text, $TenantFilter) $Text } + function Test-CIPPStandardLicense { param($StandardName, $TenantFilter, $Preset, [switch]$SkipLog) $true } + function Set-CIPPQuarantinePolicy { param($identity, $action, $EndUserQuarantinePermissions, $ESNEnabled, $IncludeMessagesFromBlockedSenderAddress, $tenantFilter, $APIName) } + + . (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 'Test-CIPPBaselineCacheCollected.ps1') + foreach ($Hook in @('AssignmentFilterTemplate', 'ReusableSettingsTemplate', 'GroupTemplate', 'ExchangeConnectorTemplate', + 'DeployContactTemplates', 'TenantAllowBlockListTemplate', 'QuarantineTemplate', 'SafeLinksTemplatePolicy')) { + . (Join-Path $Baselines "Get-CIPPBaseline${Hook}State.ps1") + . (Join-Path $Baselines "Invoke-CIPPBaseline${Hook}.ps1") + } + + $script:Tenant = 'contoso.onmicrosoft.com' + + function ConvertTo-Cached { param([Parameter(ValueFromPipeline = $true)]$InputObject) process { $InputObject | ConvertTo-Json -Depth 20 | ConvertFrom-Json } } + + 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-CIPPBaselineAssignmentFilterTemplateState' { + BeforeAll { + $script:AfItem = [PSCustomObject]@{ Variables = [PSCustomObject]@{ assignmentFilterTemplate = 'tpl-af' } } + } + BeforeEach { + Mock Get-CIPPDbItem { [PSCustomObject]@{ RowKey = 'IntuneAssignmentFilters-Count'; DataCount = 1 } } + Mock Get-CIPPAzDataTableEntity { [PSCustomObject]@{ RowKey = 'tpl-af'; JSON = '{"displayName":"Corp Devices","description":"d","platform":"windows10AndLater","rule":"(device.model -eq \"X\")","assignmentFilterManagementType":"devices"}' } } + } + + It 'is compliant when the filter matches on every graded field' { + Mock New-CIPPDbRequest { @(@{ id = 'f1'; displayName = 'Corp Devices'; description = 'd'; platform = 'windows10AndLater'; rule = '(device.model -eq "X")'; assignmentFilterManagementType = 'devices' } | ConvertTo-Cached) } + $Prepared = Get-CIPPBaselineAssignmentFilterTemplateState -Item $script:AfItem -TenantFilter $script:Tenant + (Get-Verdict -Expected $Prepared.Expected -Current $Prepared.Current).Count | Should -Be 0 + } + + It 'reports drift when the RULE differs, not just when the filter is missing' { + # The classic aggregate compare could never see this - its Where-Object tested a + # property against itself, so a deployed-but-wrong filter always read compliant. + Mock New-CIPPDbRequest { @(@{ id = 'f1'; displayName = 'Corp Devices'; description = 'd'; platform = 'windows10AndLater'; rule = '(device.model -eq "Y")'; assignmentFilterManagementType = 'devices' } | ConvertTo-Cached) } + $Prepared = Get-CIPPBaselineAssignmentFilterTemplateState -Item $script:AfItem -TenantFilter $script:Tenant + (Get-Verdict -Expected $Prepared.Expected -Current $Prepared.Current).Count | Should -BeGreaterThan 0 + } + + It 'grades a missing filter as one presence row, not one drift row per field' { + Mock New-CIPPDbRequest { @(@{ id = 'f2'; displayName = 'Other' } | ConvertTo-Cached) } + $Prepared = Get-CIPPBaselineAssignmentFilterTemplateState -Item $script:AfItem -TenantFilter $script:Tenant + $Prepared.Expected.PSObject.Properties.Name | Should -Be @('deployed') + $Prepared.Current.deployed | Should -BeFalse + } + + It 'patches only the fields that drifted' { + Mock New-GraphPostRequest { } + $Current = [PSCustomObject]@{ + deployed = $true; description = 'd'; platform = 'windows10AndLater'; rule = 'OLD'; assignmentFilterManagementType = 'devices' + templateBody = [PSCustomObject]@{ displayName = 'Corp Devices'; description = 'd'; platform = 'windows10AndLater'; rule = 'NEW'; assignmentFilterManagementType = 'devices' } + existingId = 'f1' + } + Invoke-CIPPBaselineAssignmentFilterTemplate -Remediate $null -TenantFilter $script:Tenant -Current $Current + Should -Invoke New-GraphPostRequest -Times 1 -Exactly -ParameterFilter { $type -eq 'PATCH' -and $body -match '"rule"' -and $body -notmatch '"platform"' } + } + + It 'creates through the shared path when the filter is missing' { + Mock New-CIPPAssignmentFilter { [PSCustomObject]@{ Success = $true } } + $Current = [PSCustomObject]@{ deployed = $false; templateBody = [PSCustomObject]@{ displayName = 'Corp Devices' }; existingId = $null } + Invoke-CIPPBaselineAssignmentFilterTemplate -Remediate $null -TenantFilter $script:Tenant -Current $Current + Should -Invoke New-CIPPAssignmentFilter -Times 1 -Exactly + } +} + +Describe 'Get-CIPPBaselineReusableSettingsTemplateState' { + BeforeAll { + $script:RsItem = [PSCustomObject]@{ Variables = [PSCustomObject]@{ reusableSettingsTemplate = 'tpl-rs' } } + $script:RsBody = '{"displayName":"Cert Path","description":"d","settingDefinitionId":"def_1","settingInstance":{"settingDefinitionId":"def_1","simpleSettingValue":{"value":"C:\\pki"}}}' + } + BeforeEach { + Mock Get-CIPPDbItem { [PSCustomObject]@{ RowKey = 'IntuneReusableSettings-Count'; DataCount = 1 } } + Mock Get-CIPPAzDataTableEntity { [PSCustomObject]@{ RowKey = 'tpl-rs'; JSON = ('{"DisplayName":"Cert Path","RawJSON":' + ($script:RsBody | ConvertTo-Json) + '}') } } + } + + It 'is compliant when the deployed setting matches, tolerating explicit nulls' { + # The existing row carries explicit nulls the template omits - Graph does this + # constantly - including one INSIDE the settingInstance tree. The comparer treats + # null, empty and absent as equal, which is what keeps this from reading as + # permanent drift; this fixture pins that tolerance. + Mock New-CIPPDbRequest { @(@{ id = 'rs1'; displayName = 'Cert Path'; description = 'd'; settingDefinitionId = 'def_1'; applicableTo = $null; settingInstance = @{ settingDefinitionId = 'def_1'; settingInstanceTemplateReference = $null; simpleSettingValue = @{ value = 'C:\pki' } } } | ConvertTo-Cached) } + $Prepared = Get-CIPPBaselineReusableSettingsTemplateState -Item $script:RsItem -TenantFilter $script:Tenant + @($Prepared.Current.drift).Count | Should -Be 0 + (Get-Verdict -Expected $Prepared.Expected -Current $Prepared.Current).Count | Should -Be 0 + } + + It 'reports drift when the setting value differs inside the settingInstance tree' { + Mock New-CIPPDbRequest { @(@{ id = 'rs1'; displayName = 'Cert Path'; description = 'd'; settingDefinitionId = 'def_1'; settingInstance = @{ settingDefinitionId = 'def_1'; simpleSettingValue = @{ value = 'D:\other' } } } | ConvertTo-Cached) } + $Prepared = Get-CIPPBaselineReusableSettingsTemplateState -Item $script:RsItem -TenantFilter $script:Tenant + @($Prepared.Current.drift).Count | Should -BeGreaterThan 0 + (Get-Verdict -Expected $Prepared.Expected -Current $Prepared.Current).Count | Should -BeGreaterThan 0 + } + + It 'reports a missing setting as drift' { + Mock New-CIPPDbRequest { @(@{ id = 'rs2'; displayName = 'Other' } | ConvertTo-Cached) } + $Prepared = Get-CIPPBaselineReusableSettingsTemplateState -Item $script:RsItem -TenantFilter $script:Tenant + $Prepared.Current.deployed | Should -BeFalse + (Get-Verdict -Expected $Prepared.Expected -Current $Prepared.Current).Count | Should -BeGreaterThan 0 + } + + It 'PUTs over the existing object and POSTs a new one' { + Mock New-GraphPostRequest { } + Invoke-CIPPBaselineReusableSettingsTemplate -Remediate $null -TenantFilter $script:Tenant -Current ([PSCustomObject]@{ rawJSON = '{"a":1}'; existingId = 'rs1' }) + Should -Invoke New-GraphPostRequest -Times 1 -Exactly -ParameterFilter { $type -eq 'PUT' -and $uri -match 'rs1' } + Invoke-CIPPBaselineReusableSettingsTemplate -Remediate $null -TenantFilter $script:Tenant -Current ([PSCustomObject]@{ rawJSON = '{"a":1}'; existingId = $null }) + Should -Invoke New-GraphPostRequest -Times 1 -Exactly -ParameterFilter { $type -eq 'POST' } + } +} + +Describe 'Get-CIPPBaselineGroupTemplateState' { + BeforeEach { + Mock Get-CIPPDbItem { [PSCustomObject]@{ RowKey = 'Groups-Count'; DataCount = 1 } } + } + + It 'checks a dynamic distribution template against Exchange, never against Graph groups' { + # A DDG lives in Exchange only. Checked against the Groups cache it would read + # missing forever and be re-created on every remediation run. + Mock Get-CIPPAzDataTableEntity { [PSCustomObject]@{ RowKey = 'tpl-g'; JSON = '{"displayName":"All Sales","groupType":"dynamicDistribution","membershipRules":"Department -eq ''Sales''"}' } } + Mock New-CIPPDbRequest { + if ($Type -eq 'ExoDynamicDistributionGroup') { @(@{ Name = 'All Sales'; Identity = 'All Sales'; RecipientFilter = "Department -eq 'Sales'" } | ConvertTo-Cached) } + else { @() } + } + $Item = [PSCustomObject]@{ Variables = [PSCustomObject]@{ groupTemplate = 'tpl-g' } } + (Get-CIPPBaselineGroupTemplateState -Item $Item -TenantFilter $script:Tenant).Current.deployed | Should -BeTrue + } + + It 'checks every other group type against the Groups cache' { + Mock Get-CIPPAzDataTableEntity { [PSCustomObject]@{ RowKey = 'tpl-g'; JSON = '{"displayName":"Sec Group","groupType":"security"}' } } + Mock New-CIPPDbRequest { + if ($Type -eq 'Groups') { @(@{ id = 'g1'; displayName = 'Sec Group' } | ConvertTo-Cached) } else { @() } + } + $Item = [PSCustomObject]@{ Variables = [PSCustomObject]@{ groupTemplate = 'tpl-g' } } + (Get-CIPPBaselineGroupTemplateState -Item $Item -TenantFilter $script:Tenant).Current.deployed | Should -BeTrue + } + + It 'reports a missing group as drift' { + Mock Get-CIPPAzDataTableEntity { [PSCustomObject]@{ RowKey = 'tpl-g'; JSON = '{"displayName":"Sec Group","groupType":"security"}' } } + Mock New-CIPPDbRequest { @(@{ id = 'g2'; displayName = 'Other' } | ConvertTo-Cached) } + $Item = [PSCustomObject]@{ Variables = [PSCustomObject]@{ groupTemplate = 'tpl-g' } } + $Prepared = Get-CIPPBaselineGroupTemplateState -Item $Item -TenantFilter $script:Tenant + (Get-Verdict -Expected $Prepared.Expected -Current $Prepared.Current).Count | Should -BeGreaterThan 0 + } +} + +Describe 'Get-CIPPBaselineExchangeConnectorTemplateState' { + # The template's direction is a column on the ENTITY, not a field in its JSON, and it + # picks the cache. An outbound template checked against the inbound cache reads missing + # forever and gets re-created on every remediation run. + BeforeEach { + Mock Get-CIPPDbItem { [PSCustomObject]@{ RowKey = 'ExoOutboundConnector-Count'; DataCount = 1 } } + Mock Get-CIPPAzDataTableEntity { [PSCustomObject]@{ RowKey = 'tpl-c'; direction = 'Outbound'; JSON = '{"name":"To Partner","SmartHosts":["mail.partner.com"]}' } } + } + + It 'consults the cache matching the template direction' { + Mock New-CIPPDbRequest { + if ($Type -eq 'ExoOutboundConnector') { @(@{ Identity = 'To Partner' } | ConvertTo-Cached) } else { @() } + } + $Item = [PSCustomObject]@{ Variables = [PSCustomObject]@{ exConnectorTemplate = 'tpl-c' } } + (Get-CIPPBaselineExchangeConnectorTemplateState -Item $Item -TenantFilter $script:Tenant).Current.deployed | Should -BeTrue + } + + It 'reports a missing connector as drift' { + Mock New-CIPPDbRequest { @(@{ Identity = 'Different' } | ConvertTo-Cached) } + $Item = [PSCustomObject]@{ Variables = [PSCustomObject]@{ exConnectorTemplate = 'tpl-c' } } + $Prepared = Get-CIPPBaselineExchangeConnectorTemplateState -Item $Item -TenantFilter $script:Tenant + (Get-Verdict -Expected $Prepared.Expected -Current $Prepared.Current).Count | Should -BeGreaterThan 0 + } + + It 'reports No Data when the entity has no usable direction' { + Mock Get-CIPPAzDataTableEntity { [PSCustomObject]@{ RowKey = 'tpl-c'; JSON = '{"name":"To Partner"}' } } + $Item = [PSCustomObject]@{ Variables = [PSCustomObject]@{ exConnectorTemplate = 'tpl-c' } } + (Get-CIPPBaselineExchangeConnectorTemplateState -Item $Item -TenantFilter $script:Tenant).Current | Should -BeNullOrEmpty + } + + It 'rewrites via Set- with the tenant identity when deployed, New- otherwise' { + Mock New-ExoRequest { } + $Body = [PSCustomObject]@{ name = 'To Partner' } + Invoke-CIPPBaselineExchangeConnectorTemplate -Remediate $null -TenantFilter $script:Tenant -Current ([PSCustomObject]@{ deployed = $true; connectorBody = $Body; direction = 'outbound'; existingIdentity = 'To Partner' }) + Should -Invoke New-ExoRequest -Times 1 -Exactly -ParameterFilter { $cmdlet -eq 'Set-outboundconnector' } + Invoke-CIPPBaselineExchangeConnectorTemplate -Remediate $null -TenantFilter $script:Tenant -Current ([PSCustomObject]@{ deployed = $false; connectorBody = ([PSCustomObject]@{ name = 'To Partner' }); direction = 'outbound'; existingIdentity = '' }) + Should -Invoke New-ExoRequest -Times 1 -Exactly -ParameterFilter { $cmdlet -eq 'New-outboundconnector' } + } +} + +Describe 'Get-CIPPBaselineDeployContactTemplatesState' { + BeforeAll { + $script:CtItem = [PSCustomObject]@{ Variables = [PSCustomObject]@{ contactTemplate = 'tpl-ct' } } + $script:Deployed = @{ Identity = 'Support'; DisplayName = 'Support'; ExternalEmailAddress = 'support@partner.com'; MailTip = ''; HiddenFromAddressListsEnabled = $false; FirstName = ''; LastName = ''; Company = 'ACME'; StateOrProvince = ''; StreetAddress = ''; Phone = ''; WebPage = ''; Title = ''; City = ''; PostalCode = ''; CountryOrRegion = ''; MobilePhone = '' } + } + BeforeEach { + Mock Get-CIPPDbItem { [PSCustomObject]@{ RowKey = 'ExoMailContacts-Count'; DataCount = 1 } } + Mock Get-CIPPAzDataTableEntity { [PSCustomObject]@{ RowKey = 'tpl-ct'; JSON = '{"displayName":"Support","email":"Support@Partner.com","companyName":"ACME"}' } } + } + + It 'compares email case-insensitively against the lowered cache value' { + Mock New-CIPPDbRequest { @($script:Deployed | ConvertTo-Cached) } + $Prepared = Get-CIPPBaselineDeployContactTemplatesState -Item $script:CtItem -TenantFilter $script:Tenant + @($Prepared.Current.drift).Count | Should -Be 0 + (Get-Verdict -Expected $Prepared.Expected -Current $Prepared.Current).Count | Should -Be 0 + } + + It 'does not grade fields the template leaves empty' { + # The tenant has a City the template never specified - grading it would strip + # operator data on remediation. + $WithCity = $script:Deployed.Clone(); $WithCity.City = 'Amsterdam' + Mock New-CIPPDbRequest { @($WithCity | ConvertTo-Cached) } + @((Get-CIPPBaselineDeployContactTemplatesState -Item $script:CtItem -TenantFilter $script:Tenant).Current.drift).Count | Should -Be 0 + } + + It 'grades hidefromGAL in BOTH directions, unlike the string fields' { + # The tenant hides the contact but the template says visible: boolean fields are + # enforced even when the template value is falsy. + $Hidden = $script:Deployed.Clone(); $Hidden.HiddenFromAddressListsEnabled = $true + Mock New-CIPPDbRequest { @($Hidden | ConvertTo-Cached) } + (Get-CIPPBaselineDeployContactTemplatesState -Item $script:CtItem -TenantFilter $script:Tenant).Current.drift | Should -Contain 'hidefromGAL' + } + + It 'reports drift when a specified field differs' { + $Wrong = $script:Deployed.Clone(); $Wrong.Company = 'Umbrella' + Mock New-CIPPDbRequest { @($Wrong | ConvertTo-Cached) } + (Get-CIPPBaselineDeployContactTemplatesState -Item $script:CtItem -TenantFilter $script:Tenant).Current.drift | Should -Contain 'companyName' + } + + It 'reports No Data for a template with an invalid email, and never deploys it' { + Mock Get-CIPPAzDataTableEntity { [PSCustomObject]@{ RowKey = 'tpl-ct'; JSON = '{"displayName":"Support","email":"not-an-email"}' } } + Mock New-CIPPDbRequest { @($script:Deployed | ConvertTo-Cached) } + (Get-CIPPBaselineDeployContactTemplatesState -Item $script:CtItem -TenantFilter $script:Tenant).Current | Should -BeNullOrEmpty + } +} + +Describe 'Get-CIPPBaselineTenantAllowBlockListTemplateState' { + BeforeAll { + $script:TablItem = [PSCustomObject]@{ Variables = [PSCustomObject]@{ tenantAllowBlockListTemplate = 'tpl-tabl' } } + } + BeforeEach { + Mock Get-CIPPDbItem { [PSCustomObject]@{ RowKey = 'ExoTenantAllowBlockList-Count'; DataCount = 1 } } + Mock Get-CIPPAzDataTableEntity { [PSCustomObject]@{ RowKey = 'tpl-tabl'; JSON = '{"templateName":"Bad senders","listType":"Sender","listMethod":"Block","entries":"evil@bad.com, worse@bad.com","notes":"cipp"}' } } + } + + It 'is additive: entries an operator added by hand are never drift' { + Mock New-CIPPDbRequest { @( + (@{ ListType = 'Sender'; Value = 'evil@bad.com' } | ConvertTo-Cached), + (@{ ListType = 'Sender'; Value = 'worse@bad.com' } | ConvertTo-Cached), + (@{ ListType = 'Sender'; Value = 'operator-added@other.com' } | ConvertTo-Cached) + ) } + $Prepared = Get-CIPPBaselineTenantAllowBlockListTemplateState -Item $script:TablItem -TenantFilter $script:Tenant + @($Prepared.Current.missingEntries).Count | Should -Be 0 + } + + It 'only counts entries of the SAME list type as present' { + # The same value on the Url list must not satisfy a Sender template. + Mock New-CIPPDbRequest { @(@{ ListType = 'Url'; Value = 'evil@bad.com' } | ConvertTo-Cached) } + $Prepared = Get-CIPPBaselineTenantAllowBlockListTemplateState -Item $script:TablItem -TenantFilter $script:Tenant + $Prepared.Current.missingEntries | Should -Contain 'evil@bad.com' + } + + It 'submits only the missing entries, under the dynamic list-method switch' { + Mock New-ExoRequest { } + $Current = [PSCustomObject]@{ + missingEntries = @('worse@bad.com') + templateBody = [PSCustomObject]@{ templateName = 'Bad senders'; listType = 'Sender'; listMethod = 'Block'; notes = 'cipp'; NoExpiration = $true } + } + Invoke-CIPPBaselineTenantAllowBlockListTemplate -Remediate $null -TenantFilter $script:Tenant -Current $Current + Should -Invoke New-ExoRequest -Times 1 -Exactly -ParameterFilter { + $cmdParams.Entries.Count -eq 1 -and $cmdParams.Entries[0] -eq 'worse@bad.com' -and $cmdParams.Block -eq $true -and $cmdParams.NoExpiration -eq $true + } + } + + It 'refuses an invalid list method rather than submitting a broken batch' { + $Current = [PSCustomObject]@{ + missingEntries = @('x@y.com') + templateBody = [PSCustomObject]@{ templateName = 'T'; listType = 'Sender'; listMethod = 'Destroy' } + } + { Invoke-CIPPBaselineTenantAllowBlockListTemplate -Remediate $null -TenantFilter $script:Tenant -Current $Current } | Should -Throw + } +} + +Describe 'Get-CIPPBaselineQuarantineTemplateState' { + BeforeAll { + $script:QItem = [PSCustomObject]@{ + Variables = [PSCustomObject]@{ + displayName = 'CIPP Quarantine'; ESNEnabled = $true; ReleaseAction = 'PermissionToRequestRelease' + IncludeMessagesFromBlockedSenderAddress = $false + PermissionToDelete = $true; PermissionToPreview = $true; PermissionToBlockSender = $false; PermissionToAllowSender = $false + } + } + $script:QPolicy = @{ Name = 'CIPP Quarantine'; Guid = 'aaaa1111-0000-0000-0000-000000000001'; ESNEnabled = $true; IncludeMessagesFromBlockedSenderAddress = $false; EndUserQuarantinePermissions = 'encoded' } + } + BeforeEach { + Mock Get-CIPPDbItem { [PSCustomObject]@{ RowKey = 'ExoQuarantinePolicy-Count'; DataCount = 1 } } + Mock Convert-QuarantinePermissionsValue { @{ PermissionToViewHeader = $false; PermissionToDownload = $false; PermissionToBlockSender = $false; PermissionToDelete = $true; PermissionToPreview = $true; PermissionToRelease = $false; PermissionToRequestRelease = $true; PermissionToAllowSender = $false } } + } + + It 'is compliant when every graded field matches' { + Mock New-CIPPDbRequest { @($script:QPolicy | ConvertTo-Cached) } + $Prepared = Get-CIPPBaselineQuarantineTemplateState -Item $script:QItem -TenantFilter $script:Tenant + (Get-Verdict -Expected $Prepared.Expected -Current $Prepared.Current).Count | Should -Be 0 + } + + It 'catches two permissions swapped between keys, which the classic value-multiset compare could not' { + Mock Convert-QuarantinePermissionsValue { @{ PermissionToViewHeader = $false; PermissionToDownload = $false; PermissionToBlockSender = $true; PermissionToDelete = $false; PermissionToPreview = $true; PermissionToRelease = $false; PermissionToRequestRelease = $true; PermissionToAllowSender = $false } } + Mock New-CIPPDbRequest { @($script:QPolicy | ConvertTo-Cached) } + $Prepared = Get-CIPPBaselineQuarantineTemplateState -Item $script:QItem -TenantFilter $script:Tenant + (Get-Verdict -Expected $Prepared.Expected -Current $Prepared.Current).Count | Should -BeGreaterThan 0 + } + + It 'never adopts a built-in policy: the all-zeros Guid is excluded' { + $BuiltIn = $script:QPolicy.Clone(); $BuiltIn.Guid = '00000000-0000-0000-0000-000000000000' + Mock New-CIPPDbRequest { @($BuiltIn | ConvertTo-Cached) } + $Prepared = Get-CIPPBaselineQuarantineTemplateState -Item $script:QItem -TenantFilter $script:Tenant + $Prepared.Current.deployed | Should -BeFalse + } + + It 'maps the release action to exactly one of the two release permissions on the write' { + Mock Set-CIPPQuarantinePolicy { } + $Remediate = [PSCustomObject]@{ esnEnabled = $true; releaseAction = 'PermissionToRelease'; includeMessagesFromBlockedSenderAddress = $false; permissionToDelete = $false; permissionToPreview = $false; permissionToBlockSender = $false; permissionToAllowSender = $false } + Invoke-CIPPBaselineQuarantineTemplate -Remediate $Remediate -TenantFilter $script:Tenant -Current ([PSCustomObject]@{ deployed = $false; policyName = 'CIPP Quarantine' }) + Should -Invoke Set-CIPPQuarantinePolicy -Times 1 -Exactly -ParameterFilter { + $action -eq 'Create' -and $EndUserQuarantinePermissions.PermissionToRelease -eq $true -and $EndUserQuarantinePermissions.PermissionToRequestRelease -eq $false + } + } +} + +Describe 'Get-CIPPBaselineSafeLinksTemplatePolicyState' { + BeforeAll { + $script:SlItem = [PSCustomObject]@{ Variables = [PSCustomObject]@{ safeLinksTemplate = 'tpl-sl' } } + } + BeforeEach { + Mock Get-CIPPDbItem { [PSCustomObject]@{ RowKey = 'ExoSafeLinksPolicies-Count'; DataCount = 1 } } + Mock Get-CIPPAzDataTableEntity { [PSCustomObject]@{ RowKey = 'tpl-sl'; JSON = '{"TemplateName":"Default SL","Name":"CIPP SafeLinks","EnableSafeLinksForEmail":true,"State":"Enabled"}' } } + } + + It 'reports drift when the policy exists but its RULE is missing' { + # A policy without a rule protects nobody. Grading the pair as one bool would let + # the half-deployed state read compliant. + Mock New-CIPPDbRequest { + if ($Type -eq 'ExoSafeLinksPolicies') { @(@{ Name = 'CIPP SafeLinks' } | ConvertTo-Cached) } else { @() } + } + $Prepared = Get-CIPPBaselineSafeLinksTemplatePolicyState -Item $script:SlItem -TenantFilter $script:Tenant + $Prepared.Current.policyDeployed | Should -BeTrue + $Prepared.Current.ruleDeployed | Should -BeFalse + (Get-Verdict -Expected $Prepared.Expected -Current $Prepared.Current).Count | Should -BeGreaterThan 0 + } + + It 'derives the rule name from the policy name when the template names none' { + Mock New-CIPPDbRequest { @() } + Mock Get-CIPPDbItem { [PSCustomObject]@{ RowKey = 'ExoSafeLinksPolicies-Count'; DataCount = 0 } } + (Get-CIPPBaselineSafeLinksTemplatePolicyState -Item $script:SlItem -TenantFilter $script:Tenant).Current.ruleName | Should -Be 'CIPP SafeLinks_Rule' + } + + It 'binds the rule to its policy only on create, never on update' { + # SafeLinksPolicy is not a settable property on Set-SafeLinksRule - passing it + # fails the whole write. + Mock New-ExoRequest { } + $Template = [PSCustomObject]@{ Name = 'CIPP SafeLinks'; EnableSafeLinksForEmail = $true } + $Current = [PSCustomObject]@{ policyDeployed = $true; ruleDeployed = $true; templateBody = $Template; policyName = 'CIPP SafeLinks'; ruleName = 'CIPP SafeLinks_Rule' } + Invoke-CIPPBaselineSafeLinksTemplatePolicy -Remediate $null -TenantFilter $script:Tenant -Current $Current + Should -Invoke New-ExoRequest -ParameterFilter { $cmdlet -eq 'Set-SafeLinksRule' -and -not $cmdParams.ContainsKey('SafeLinksPolicy') } + $Current2 = [PSCustomObject]@{ policyDeployed = $true; ruleDeployed = $false; templateBody = $Template; policyName = 'CIPP SafeLinks'; ruleName = 'CIPP SafeLinks_Rule' } + Invoke-CIPPBaselineSafeLinksTemplatePolicy -Remediate $null -TenantFilter $script:Tenant -Current $Current2 + Should -Invoke New-ExoRequest -ParameterFilter { $cmdlet -eq 'New-SafeLinksRule' -and $cmdParams.SafeLinksPolicy -eq 'CIPP SafeLinks' } + } + + It 'applies the rule state the template expresses' { + Mock New-ExoRequest { } + $Template = [PSCustomObject]@{ Name = 'CIPP SafeLinks'; State = 'Enabled' } + $Current = [PSCustomObject]@{ policyDeployed = $true; ruleDeployed = $true; templateBody = $Template; policyName = 'CIPP SafeLinks'; ruleName = 'CIPP SafeLinks_Rule' } + Invoke-CIPPBaselineSafeLinksTemplatePolicy -Remediate $null -TenantFilter $script:Tenant -Current $Current + Should -Invoke New-ExoRequest -Times 1 -Exactly -ParameterFilter { $cmdlet -eq 'Enable-SafeLinksRule' } + } +} diff --git a/Tests/DBCache/Set-CIPPDBCacheDomainAnalyser.Tests.ps1 b/Tests/DBCache/Set-CIPPDBCacheDomainAnalyser.Tests.ps1 new file mode 100644 index 0000000000000..b05a6c8b3aee4 --- /dev/null +++ b/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/Tests/DBCache/SharePointSharingLinks.Resume.Tests.ps1 b/Tests/DBCache/SharePointSharingLinks.Resume.Tests.ps1 index f2a70e567d9f6..3ab76fd014d1f 100644 --- a/Tests/DBCache/SharePointSharingLinks.Resume.Tests.ps1 +++ b/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,135 @@ 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 'The request has been throttled' } + + Push-DBCacheSharePointSiteSharingLinks -Item (New-SiteItem -ScanId $ScanId) + + $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' + } + + 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 '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) + $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 -Be 'https://graph.microsoft.com/beta/drives/b!driveone/root/delta?token=fresh' + $DriveState.DeltaLink | Should -BeLike '*token=fresh' $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 'full scan with dropped permission reads' { + It 'keeps existing rows and defers the sweep when batch reads are throttled away' { + $ScanId = 'scan-drop-1' + Initialize-TestScan -ScanId $ScanId -TotalSites 1 + Add-CacheRow -RowKey 'SharePointSharingLinks-b!driveone_01SURVIVOR_permOld' + # Every permission read comes back throttled inside the batch. + 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 +393,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 +406,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 +416,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 +430,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 +443,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 +456,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 +482,137 @@ 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' } - } - Context 'superseded scans' { - It 'exits without scanning or touching the counter when a newer scan owns the state' { - Initialize-TestScan -ScanId 'scan-new' -TotalSites 5 + 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 'scan-old') + Push-DBCacheSharePointSiteSharingLinks -Item (New-SiteItem -ScanId $ScanId -IsPersonalSite $true) + $DriveTask = @($script:Orchestrations[0].Batch)[0] + Push-DBCacheSharePointSiteSharingLinks -Item $DriveTask - $script:GraphGetCalls.Count | Should -Be 0 - ([int](Get-TestScanRow).PendingSites) | Should -Be 5 + # 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 'completion counter under contention' { - It 'counts a site exactly once however many times its task is dispatched' { - $ScanId = 'scan-dup-1' - Initialize-TestScan -ScanId $ScanId -TotalSites 2 + 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" + } + } - # 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) + It 'requeues from the checkpoint instead of failing the drive' { + $ScanId = 'scan-throttle-1' + Initialize-TestScan -ScanId $ScanId -TotalSites 1 - ([int](Get-TestScanRow).PendingSites) | Should -Be 1 - Get-CacheRowKeys | Should -Not -Contain 'SharePointSharingLinks-Count' + 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 'retries a lost ETag race instead of silently dropping the decrement' { - $ScanId = 'scan-race-1' + It 'fails the drive normally once the requeue budget is spent' { + $ScanId = 'scan-throttle-2' 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) + 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 - ([int](Get-TestScanRow).PendingSites) | Should -Be 0 - # Pending reached zero, so finalisation ran and wrote the count row. + # 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 'site failure' { - It 'records the failed site and still decrements the counter' { - $ScanId = 'scan-fail-1' + 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 + Get-StateRowKeys | Should -Not -Contain 'done-contoso.sharepoint.com,site1,web1' + } + + It 'counts a site exactly once however many times its tasks are dispatched' { + $ScanId = 'scan-dup-1' Initialize-TestScan -ScanId $ScanId -TotalSites 2 - $script:GraphGetHandler = { param($Uri) throw 'drives listing failed' } - 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) - $Scan = Get-TestScanRow - ([int]$Scan.PendingSites) | Should -Be 1 - @($Scan.FailedSites | ConvertFrom-Json) | Should -Contain 'contoso.sharepoint.com,site1,web1' + @((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' } } 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 +639,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 +651,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' diff --git a/Tests/Endpoint/Invoke-AddAutopilotConfig.Tests.ps1 b/Tests/Endpoint/Invoke-AddAutopilotConfig.Tests.ps1 new file mode 100644 index 0000000000000..96c61099bea41 --- /dev/null +++ b/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/Tests/Endpoint/Invoke-EditGroup.Tests.ps1 b/Tests/Endpoint/Invoke-EditGroup.Tests.ps1 index cfdd3d265ee12..81ab2e7d26927 100644 --- a/Tests/Endpoint/Invoke-EditGroup.Tests.ps1 +++ b/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/Tests/Endpoint/Invoke-ExecAssignAutopilotProfile.Tests.ps1 b/Tests/Endpoint/Invoke-ExecAssignAutopilotProfile.Tests.ps1 new file mode 100644 index 0000000000000..bae9922f23ca0 --- /dev/null +++ b/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/Tests/Endpoint/Invoke-ExecCustomRole.Tests.ps1 b/Tests/Endpoint/Invoke-ExecCustomRole.Tests.ps1 new file mode 100644 index 0000000000000..fc64da0604393 --- /dev/null +++ b/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 + } +} diff --git a/Tests/Endpoint/Invoke-ExecGroupMembers.Tests.ps1 b/Tests/Endpoint/Invoke-ExecGroupMembers.Tests.ps1 new file mode 100644 index 0000000000000..d9a0de7598809 --- /dev/null +++ b/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/Tests/Endpoint/Invoke-ExecOffboardUser.Tests.ps1 b/Tests/Endpoint/Invoke-ExecOffboardUser.Tests.ps1 index 772f6ebaafade..29c067ff56ee7 100644 --- a/Tests/Endpoint/Invoke-ExecOffboardUser.Tests.ps1 +++ b/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/Tests/Endpoint/Invoke-ExecRefreshMyAccess.Tests.ps1 b/Tests/Endpoint/Invoke-ExecRefreshMyAccess.Tests.ps1 new file mode 100644 index 0000000000000..9e66df0c79f21 --- /dev/null +++ b/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/Tests/Endpoint/Invoke-ExecRemoveSnooze.Tests.ps1 b/Tests/Endpoint/Invoke-ExecRemoveSnooze.Tests.ps1 new file mode 100644 index 0000000000000..4594eb305e1c1 --- /dev/null +++ b/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/Tests/Endpoint/Invoke-ExecScheduleAuditExclusionVacation.Tests.ps1 b/Tests/Endpoint/Invoke-ExecScheduleAuditExclusionVacation.Tests.ps1 new file mode 100644 index 0000000000000..fb3a8a9d1d2db --- /dev/null +++ b/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/Tests/Endpoint/Invoke-ExecSnoozeAlert.Tests.ps1 b/Tests/Endpoint/Invoke-ExecSnoozeAlert.Tests.ps1 new file mode 100644 index 0000000000000..7dd55877194ad --- /dev/null +++ b/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/Tests/Endpoint/Invoke-ListCVEManagement.Tests.ps1 b/Tests/Endpoint/Invoke-ListCVEManagement.Tests.ps1 index cd5bf6e1adc76..b5ff772519827 100644 --- a/Tests/Endpoint/Invoke-ListCVEManagement.Tests.ps1 +++ b/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/Tests/Endpoint/Invoke-ListDirectoryObjects.Tests.ps1 b/Tests/Endpoint/Invoke-ListDirectoryObjects.Tests.ps1 new file mode 100644 index 0000000000000..d5e0c930a56c8 --- /dev/null +++ b/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' + } + } +} diff --git a/Tests/Endpoint/Invoke-ListGuestUsers.Tests.ps1 b/Tests/Endpoint/Invoke-ListGuestUsers.Tests.ps1 new file mode 100644 index 0000000000000..7c2346f68df16 --- /dev/null +++ b/Tests/Endpoint/Invoke-ListGuestUsers.Tests.ps1 @@ -0,0 +1,280 @@ +# Pester tests for Invoke-ListGuestUsers +# Validates lifecycle status classification, the sign-in date selection, the staleDays +# 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. + $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 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]$Merged + } + } +} + +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' } + } + + 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/Tests/Endpoint/Invoke-ListIRMConfiguration.Tests.ps1 b/Tests/Endpoint/Invoke-ListIRMConfiguration.Tests.ps1 new file mode 100644 index 0000000000000..2e1d19120c955 --- /dev/null +++ b/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/Tests/Endpoint/Invoke-ListUserSettings.OffboardingOOO.Tests.ps1 b/Tests/Endpoint/Invoke-ListUserSettings.OffboardingOOO.Tests.ps1 new file mode 100644 index 0000000000000..534f2c094978b --- /dev/null +++ b/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/Tests/Extensions/New-HaloPSATicket.Tests.ps1 b/Tests/Extensions/New-HaloPSATicket.Tests.ps1 new file mode 100644 index 0000000000000..089917cd505d9 --- /dev/null +++ b/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/Tests/GraphHelper/Get-CippSamPermissions.ManifestTimestamp.Tests.ps1 b/Tests/GraphHelper/Get-CippSamPermissions.ManifestTimestamp.Tests.ps1 new file mode 100644 index 0000000000000..eb1935fe8c6c5 --- /dev/null +++ b/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') + } +} diff --git a/Tests/Private/Add-CIPPGroupMember.Tests.ps1 b/Tests/Private/Add-CIPPGroupMember.Tests.ps1 index efc29d4d0d2c2..8b76beacdd9bd 100644 --- a/Tests/Private/Add-CIPPGroupMember.Tests.ps1 +++ b/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/Tests/Private/Add-CIPPGroupOwner.Tests.ps1 b/Tests/Private/Add-CIPPGroupOwner.Tests.ps1 new file mode 100644 index 0000000000000..12d6704ce0468 --- /dev/null +++ b/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/Tests/Private/Get-CIPPGroupType.Tests.ps1 b/Tests/Private/Get-CIPPGroupType.Tests.ps1 new file mode 100644 index 0000000000000..66b17d4feef6b --- /dev/null +++ b/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/Tests/Private/Get-CIPPRolePermissions.Tests.ps1 b/Tests/Private/Get-CIPPRolePermissions.Tests.ps1 new file mode 100644 index 0000000000000..aeb30192bba3c --- /dev/null +++ b/Tests/Private/Get-CIPPRolePermissions.Tests.ps1 @@ -0,0 +1,199 @@ +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') + } + + 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 { + [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, filters to the universe, and surfaces the implied .Read' { + $Result = Get-CIPPRolePermissions -RoleName 'legacy' + # 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' + } + } + + 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/Tests/Private/Invoke-CIPPOffboardingJob.OOO.Tests.ps1 b/Tests/Private/Invoke-CIPPOffboardingJob.OOO.Tests.ps1 new file mode 100644 index 0000000000000..f50315b861415 --- /dev/null +++ b/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/Tests/Private/New-CIPPCAPolicy.LocationVariables.Tests.ps1 b/Tests/Private/New-CIPPCAPolicy.LocationVariables.Tests.ps1 new file mode 100644 index 0000000000000..fa4afc002549a --- /dev/null +++ b/Tests/Private/New-CIPPCAPolicy.LocationVariables.Tests.ps1 @@ -0,0 +1,150 @@ +# Pester tests for New-CIPPCAPolicy custom-variable resolution on the named-location path. +# A template may carry %tokens% in LocationInfo and conditions.locations (per-tenant custom +# variables). These pin that tokens are resolved BEFORE the named-location existence check and +# the displayName->id mapping: an existing location must be matched (not duplicated on every +# deploy), and the policy body must reference the location's GUID, never the raw token or the +# resolved display name (Graph rejects a display name where it expects an id with error 1040). + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + + # Real dependencies - their behaviour is part of the path under test. + . (Join-Path $RepoRoot 'Modules/CIPPCore/Public/Functions/Format-CIPPCAPolicy.ps1') + . (Join-Path $RepoRoot 'Modules/CIPPCore/Public/Functions/Test-IsGuid.ps1') + . (Join-Path $RepoRoot 'Modules/CIPPCore/Public/Tools/Remove-ODataProperties.ps1') + + # Variable substitution stub: the tenant's custom-variable map, applied the way the real + # Get-CIPPTextReplacement does (token -> literal value), minus the Azure Table lookups. + function Get-CIPPTextReplacement { + [CmdletBinding()] param($TenantFilter, $Text, [switch]$EscapeForJson) + if ($Text -isnot [string]) { return , $Text } + $Map = @{ + 'CA000_Location_Exclusion_DisplayName' = 'SLY_Lupfig-03' + 'CA000_Location_Exclusion_IP' = '203.0.113.0/24' + } + foreach ($Key in $Map.Keys) { + $Text = $Text -replace [regex]::Escape("%$Key%"), $Map[$Key] + } + return $Text + } + + function New-GraphPOSTRequest { + [CmdletBinding()] param($uri, $tenantid, $type, $body, $asApp, $ScheduleRetry) + # The real request layer substitutes variables in the body just before sending + # (New-GraphPOSTRequest.ps1:40) - mirrored here so what "Graph" sees is faithful. + $body = Get-CIPPTextReplacement -TenantFilter $tenantid -Text $body -EscapeForJson + $script:GraphWrites.Add(@{ Uri = $uri; Type = $type; Body = $body }) + if ($uri -eq 'https://graph.microsoft.com/beta/identity/conditionalAccess/namedLocations') { + $Parsed = $body | ConvertFrom-Json + return [pscustomobject]@{ id = '22222222-2222-2222-2222-222222222222'; displayName = $Parsed.displayName } + } + return [pscustomobject]@{ id = 'created-policy-id' } + } + function New-GraphGETRequest { + [CmdletBinding()] param($uri, $tenantid, $asApp, [switch]$ComplexFilter) + if ($uri -match 'namedLocations/(?[^/?]+)$') { + return [pscustomobject]@{ id = $Matches.id } + } + throw "Unexpected live Graph GET in test: $uri" + } + function New-GraphBulkRequest { [CmdletBinding()] param($Requests, $tenantid, $asapp) @() } + function Write-LogMessage { [CmdletBinding()] param($API, $tenant, $TenantFilter, $Headers, $message, $sev, $LogData) } + function Get-CippException { [CmdletBinding()] param($Exception) [pscustomobject]@{ NormalizedError = "$Exception" } } + function Get-CIPPTable { [CmdletBinding()] param($tablename) @{} } + function Get-CIPPAzDataTableEntity { [CmdletBinding()] param($filter) @() } + function Start-Sleep { [CmdletBinding()] param($Seconds) } + + . (Join-Path $RepoRoot 'Modules/CIPPCore/Public/New-CIPPCAPolicy.ps1') + + $script:TemplateJSON = @' +{ + "displayName": "TESTING_CA000-Global-Baseline-Require MFA for All Users", + "state": "disabled", + "conditions": { + "users": { "includeUsers": [ "All" ] }, + "applications": { "includeApplications": [ "All" ] }, + "clientAppTypes": [ "all" ], + "locations": { + "includeLocations": [ "All" ], + "excludeLocations": [ "%CA000_Location_Exclusion_DisplayName%" ] + } + }, + "grantControls": { "operator": "OR", "builtInControls": [ "mfa" ] }, + "LocationInfo": [ + { + "@odata.type": "#microsoft.graph.ipNamedLocation", + "displayName": "%CA000_Location_Exclusion_DisplayName%", + "isTrusted": false, + "ipRanges": [ + { "@odata.type": "#microsoft.graph.iPv4CidrRange", "cidrAddress": "%CA000_Location_Exclusion_IP%" } + ] + } + ], + "GUID": "d4a3ad81-26df-49f9-83a1-5807b32d11a8", + "isSynced": false +} +'@ + + function Invoke-Deploy ($Locations) { + $script:GraphWrites = [System.Collections.Generic.List[object]]::new() + $null = New-CIPPCAPolicy -RawJSON $script:TemplateJSON -TenantFilter 'customer.example.ch' ` + -State 'disabled' -Overwrite $true -ReplacePattern 'none' ` + -PreloadedCAPolicies @([pscustomobject]@{ id = 'other-id'; displayName = 'Unrelated policy' }) ` + -PreloadedLocations $Locations + $PolicyWrite = $script:GraphWrites | Where-Object { $_.Uri -eq 'https://graph.microsoft.com/beta/identity/conditionalAccess/policies' } | Select-Object -Last 1 + return @{ Writes = $script:GraphWrites; PolicyBody = ($PolicyWrite.Body | ConvertFrom-Json) } + } +} + +Describe 'New-CIPPCAPolicy with custom variables in the named-location path' { + + Context 'the resolved location already exists in the tenant' { + BeforeAll { + $script:Existing = [pscustomobject]@{ id = '11111111-1111-1111-1111-111111111111'; displayName = 'SLY_Lupfig-03' } + $script:Result = Invoke-Deploy @($script:Existing) + } + + It 'does not create a duplicate named location' { + $Creates = @($script:Result.Writes | Where-Object { + $_.Uri -eq 'https://graph.microsoft.com/beta/identity/conditionalAccess/namedLocations' -and $_.Type -eq 'POST' + }) + $Creates.Count | Should -Be 0 + } + + It 'updates the existing location with the resolved variable values' { + $Patch = @($script:Result.Writes | Where-Object { $_.Uri -match 'namedLocations/11111111' -and $_.Type -eq 'PATCH' }) + $Patch.Count | Should -Be 1 + $PatchBody = $Patch[0].Body | ConvertFrom-Json + $PatchBody.displayName | Should -Be 'SLY_Lupfig-03' + $PatchBody.ipRanges[0].cidrAddress | Should -Be '203.0.113.0/24' + } + + It 'sends the existing location GUID in excludeLocations, not the token or the name' { + @($script:Result.PolicyBody.conditions.locations.excludeLocations) | Should -Be @('11111111-1111-1111-1111-111111111111') + } + + It 'leaves no unresolved tokens or helper blocks in the policy body' { + $Raw = ($script:Result.Writes | Where-Object { $_.Uri -match '/policies$' } | Select-Object -Last 1).Body + $Raw | Should -Not -Match '%CA000' + $Raw | Should -Not -Match 'LocationInfo' + } + } + + Context 'the resolved location does not exist yet' { + BeforeAll { + $script:Result = Invoke-Deploy @([pscustomobject]@{ id = '99999999-9999-9999-9999-999999999999'; displayName = 'SomeOtherLocation' }) + } + + It 'creates the location under its resolved display name' { + $Creates = @($script:Result.Writes | Where-Object { + $_.Uri -eq 'https://graph.microsoft.com/beta/identity/conditionalAccess/namedLocations' -and $_.Type -eq 'POST' + }) + $Creates.Count | Should -Be 1 + ($Creates[0].Body | ConvertFrom-Json).displayName | Should -Be 'SLY_Lupfig-03' + } + + It 'sends the new location GUID in excludeLocations' { + @($script:Result.PolicyBody.conditions.locations.excludeLocations) | Should -Be @('22222222-2222-2222-2222-222222222222') + } + } +} diff --git a/Tests/Private/Read-CippMimeMessage.Tests.ps1 b/Tests/Private/Read-CippMimeMessage.Tests.ps1 new file mode 100644 index 0000000000000..88ad7547486db --- /dev/null +++ b/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/Tests/Private/Remove-CIPPGroupMember.Tests.ps1 b/Tests/Private/Remove-CIPPGroupMember.Tests.ps1 index 50d1d86f051ac..688571e401110 100644 --- a/Tests/Private/Remove-CIPPGroupMember.Tests.ps1 +++ b/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/Tests/Private/Remove-CIPPGroupOwner.Tests.ps1 b/Tests/Private/Remove-CIPPGroupOwner.Tests.ps1 new file mode 100644 index 0000000000000..0eb98fae89e9b --- /dev/null +++ b/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/Tests/Private/Resolve-CIPPDirectoryId.Tests.ps1 b/Tests/Private/Resolve-CIPPDirectoryId.Tests.ps1 new file mode 100644 index 0000000000000..866b35b18c633 --- /dev/null +++ b/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' + } +} diff --git a/Tests/Private/Resolve-CippImpersonation.Tests.ps1 b/Tests/Private/Resolve-CippImpersonation.Tests.ps1 new file mode 100644 index 0000000000000..18a5a8a0c4851 --- /dev/null +++ b/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/Tests/Private/Select-CippAllowedTenantData.Tests.ps1 b/Tests/Private/Select-CippAllowedTenantData.Tests.ps1 index b8f9da7dd9dc4..ae7a8d1c1b4d3 100644 --- a/Tests/Private/Select-CippAllowedTenantData.Tests.ps1 +++ b/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 } } } diff --git a/Tests/Private/Set-CIPPAuthenticationPolicy.Tests.ps1 b/Tests/Private/Set-CIPPAuthenticationPolicy.Tests.ps1 index c5402c179623e..8448d5155ddda 100644 --- a/Tests/Private/Set-CIPPAuthenticationPolicy.Tests.ps1 +++ b/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' diff --git a/Tests/Private/Set-CIPPDBCacheIntunePolicies.Tests.ps1 b/Tests/Private/Set-CIPPDBCacheIntunePolicies.Tests.ps1 index 5a279a85c868d..7266d1c3298ef 100644 --- a/Tests/Private/Set-CIPPDBCacheIntunePolicies.Tests.ps1 +++ b/Tests/Private/Set-CIPPDBCacheIntunePolicies.Tests.ps1 @@ -170,6 +170,7 @@ Describe 'Set-CIPPDBCacheIntunePolicies' { (Get-CIPPIntunePolicyListDefinitions).Id 'WindowsAutopilotDeploymentProfiles' 'DeviceEnrollmentConfigurations' + 'AppleUserInitiatedEnrollmentProfiles' 'DeviceManagementScripts' 'MobileApps' ) diff --git a/Tests/Private/Set-CIPPDefaultAPDeploymentProfile.Tests.ps1 b/Tests/Private/Set-CIPPDefaultAPDeploymentProfile.Tests.ps1 new file mode 100644 index 0000000000000..d09aef13801e0 --- /dev/null +++ b/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/Tests/Private/Set-CIPPIntunePolicy.AppProtection.Tests.ps1 b/Tests/Private/Set-CIPPIntunePolicy.AppProtection.Tests.ps1 index 30ce1896d94c2..c60a8f2c8f145 100644 --- a/Tests/Private/Set-CIPPIntunePolicy.AppProtection.Tests.ps1 +++ b/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) } diff --git a/Tests/Private/Set-CIPPMailboxAccess.Tests.ps1 b/Tests/Private/Set-CIPPMailboxAccess.Tests.ps1 index d837ba181dabe..e817c487d02c9 100644 --- a/Tests/Private/Set-CIPPMailboxAccess.Tests.ps1 +++ b/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/Tests/Private/Set-CIPPRequirePasswordChange.Tests.ps1 b/Tests/Private/Set-CIPPRequirePasswordChange.Tests.ps1 new file mode 100644 index 0000000000000..82f9176ec6c3d --- /dev/null +++ b/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/Tests/Private/Test-CIPPHtmlIsEmpty.Tests.ps1 b/Tests/Private/Test-CIPPHtmlIsEmpty.Tests.ps1 new file mode 100644 index 0000000000000..730290306dc31 --- /dev/null +++ b/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/Tests/Private/Test-CIPPOffboardingRequest.OOO.Tests.ps1 b/Tests/Private/Test-CIPPOffboardingRequest.OOO.Tests.ps1 new file mode 100644 index 0000000000000..668c6d51be1b7 --- /dev/null +++ b/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/Tests/Standards/Invoke-CIPPStandardConditionalAccessTemplate.Tests.ps1 b/Tests/Standards/Invoke-CIPPStandardConditionalAccessTemplate.Tests.ps1 index 3adb9856889fc..ffdc7f6f5e017 100644 --- a/Tests/Standards/Invoke-CIPPStandardConditionalAccessTemplate.Tests.ps1 +++ b/Tests/Standards/Invoke-CIPPStandardConditionalAccessTemplate.Tests.ps1 @@ -25,6 +25,7 @@ BeforeAll { function Write-LogMessage { [CmdletBinding()] param($API, $tenant, $Tenant2, $message, $sev, $headers) } function Set-CIPPStandardsCompareField { [CmdletBinding()] param($FieldName, $FieldValue, $CurrentValue, $ExpectedValue, $Tenant, [bool]$LicenseAvailable = $true) } function Get-NormalizedError { [CmdletBinding()] param($Message) $Message } + function Get-CIPPTextReplacement { [CmdletBinding()] param($TenantFilter, $Text, [switch]$EscapeForJson) $Text } . $StandardPath diff --git a/Tests/Standards/Invoke-CIPPStandardGroupTemplate.Tests.ps1 b/Tests/Standards/Invoke-CIPPStandardGroupTemplate.Tests.ps1 new file mode 100644 index 0000000000000..0d57c7b166ba2 --- /dev/null +++ b/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' + } + } +} diff --git a/Tests/Standards/Invoke-CIPPStandardMessageEncryption.Tests.ps1 b/Tests/Standards/Invoke-CIPPStandardMessageEncryption.Tests.ps1 new file mode 100644 index 0000000000000..42048c1489beb --- /dev/null +++ b/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/Tests/Standards/Invoke-CIPPStandardPhishProtection.Tests.ps1 b/Tests/Standards/Invoke-CIPPStandardPhishProtection.Tests.ps1 new file mode 100644 index 0000000000000..6a58f860abe53 --- /dev/null +++ b/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*' + } + } +} diff --git a/Tests/Standards/Invoke-CIPPStandardUserSubmissions.Tests.ps1 b/Tests/Standards/Invoke-CIPPStandardUserSubmissions.Tests.ps1 new file mode 100644 index 0000000000000..fa625aa7d5b9f --- /dev/null +++ b/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' + } +} diff --git a/Tests/Webhooks/Invoke-CIPPWebhookProcessing.Tests.ps1 b/Tests/Webhooks/Invoke-CIPPWebhookProcessing.Tests.ps1 new file mode 100644 index 0000000000000..8f6451488b87d --- /dev/null +++ b/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/Tests/Webhooks/Push-AuditLogDownloadV2.Tests.ps1 b/Tests/Webhooks/Push-AuditLogDownloadV2.Tests.ps1 index 4bdf680d12e29..a32185b2cb943 100644 --- a/Tests/Webhooks/Push-AuditLogDownloadV2.Tests.ps1 +++ b/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/Tests/Webhooks/Push-AuditLogProcessingBatchV2.Tests.ps1 b/Tests/Webhooks/Push-AuditLogProcessingBatchV2.Tests.ps1 new file mode 100644 index 0000000000000..e2287075ce58c --- /dev/null +++ b/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/Tests/Webhooks/Push-AuditLogTenantProcessV2.Tests.ps1 b/Tests/Webhooks/Push-AuditLogTenantProcessV2.Tests.ps1 index 6bb87ac4c243c..770e893a87d73 100644 --- a/Tests/Webhooks/Push-AuditLogTenantProcessV2.Tests.ps1 +++ b/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/Tests/Webhooks/Test-CIPPAuditLogRules.Tests.ps1 b/Tests/Webhooks/Test-CIPPAuditLogRules.Tests.ps1 index 707ef2a6d3b3e..05382be2e0600 100644 --- a/Tests/Webhooks/Test-CIPPAuditLogRules.Tests.ps1 +++ b/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' { @@ -355,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/version_latest.txt b/version_latest.txt index 8cfd6c02cfaae..e3cbcda79583f 100644 --- a/version_latest.txt +++ b/version_latest.txt @@ -1 +1 @@ -10.8.5 \ No newline at end of file +10.9.0 \ No newline at end of file