diff --git a/Config/BaselineStandards/SharePoint Standards/SPDisableCustomScripts.json b/Config/BaselineStandards/SharePoint Standards/SPDisableCustomScripts.json index fb5a8e4792164..e2de4c364f9c3 100644 --- a/Config/BaselineStandards/SharePoint Standards/SPDisableCustomScripts.json +++ b/Config/BaselineStandards/SharePoint Standards/SPDisableCustomScripts.json @@ -9,7 +9,7 @@ "docsDescription": "Disables the ability to add and run custom scripts on SharePoint and OneDrive sites at the tenant level. When custom scripts are allowed, governance cannot be enforced, and the capabilities of inserted code cannot be scoped or blocked. Microsoft recommends using the SharePoint Framework instead of custom scripts.", "impactColour": "danger", "addedDate": "2026-04-28", - "powershellEquivalent": "Set-SPOTenant -CustomScriptsRestrictMode $true", + "powershellEquivalent": "Portal only", "recommendedBy": [ "CIPP" ], diff --git a/Config/ConversionTable.csv b/Config/ConversionTable.csv index 2d0f3cadd3984..cdd72dd19b8fb 100644 --- a/Config/ConversionTable.csv +++ b/Config/ConversionTable.csv @@ -4785,6 +4785,7 @@ Office 365 E5,ENTERPRISEPREMIUM,c7df2760-2c81-4ef7-b578-5b5392b571df,Deskless,8c Office 365 E5,ENTERPRISEPREMIUM,c7df2760-2c81-4ef7-b578-5b5392b571df,STREAM_O365_E5,6c6042f5-6f01-4d67-b8c1-eb99d36eed3e,Microsoft Stream for O365 E5 SKU Office 365 E5,ENTERPRISEPREMIUM,c7df2760-2c81-4ef7-b578-5b5392b571df,TEAMS1,57ff2da0-773e-42df-b2af-ffb7a2317929,Microsoft Teams Office 365 E5,ENTERPRISEPREMIUM,c7df2760-2c81-4ef7-b578-5b5392b571df,RECORDS_MANAGEMENT,65cc641f-cccd-4643-97e0-a17e3045e541,Microsoft Records Management +Office 365 E5,ENTERPRISEPREMIUM,c7df2760-2c81-4ef7-b578-5b5392b571df,MICROSOFT_TEAMS_EVENTS,29c62f1c-8ffc-4304-9cb9-398a6aa1852b,Microsoft Teams Events Office 365 E5 EEA (no Teams),Office_365_w/o_Teams_Bundle_E5,cf50bae9-29e8-4775-b07c-56ee10e3776d,DYN365_CDS_O365_P3,28b0fa46-c39a-4188-89e2-58e979a6b014,Common Data Service Office 365 E5 EEA (no Teams),Office_365_w/o_Teams_Bundle_E5,cf50bae9-29e8-4775-b07c-56ee10e3776d,POWER_VIRTUAL_AGENTS_O365_P3,ded3d325-1bdc-453e-8432-5bac26d7a014,Power Virtual Agents for Office 365 Office 365 E5 EEA (no Teams),Office_365_w/o_Teams_Bundle_E5,cf50bae9-29e8-4775-b07c-56ee10e3776d,BI_AZURE_P2,70d33638-9c74-4d01-bfd3-562de28bd4ba,Power BI Pro @@ -5989,3 +5990,10 @@ 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/openapi.json b/Config/openapi.json index 93b7120c5186e..2f995bd299344 100644 --- a/Config/openapi.json +++ b/Config/openapi.json @@ -14525,7 +14525,7 @@ "tags": [ "Identity > Administration > Users" ], - "description": "Returns the business email compromise assessment for a user: recent sign-ins, mailbox rules, added applications and password changes. If no cached result exists the check is queued as a background job and the response reports it as waiting, so poll rather than expecting results on the first call. Pass overwrite=true to force a fresh run.", + "description": "Returns the business email compromise assessment for a user: sign-ins with a location analysis against the user's assigned usage location, mailbox rules and rule changes, trusted/blocked sender changes, OneDrive and SharePoint sharing link activity, added applications matched against the known-malicious catalog, MFA methods, Intune devices, sent mail, and tenant-wide password changes. If no cached result exists the check is queued as a background job and the response reports it as waiting, so poll rather than expecting results on the first call. Pass overwrite=true to force a fresh run.", "parameters": [ { "name": "GUID", @@ -24809,6 +24809,10 @@ "CanViewPrivateItems": { "type": "string" }, + "DisplayName": { + "type": "string", + "description": "TargetUser may be a recipient id, so log the display name the caller saw" + }, "FolderName": { "type": "string" }, @@ -25263,6 +25267,10 @@ "CanViewPrivateItems": { "type": "string" }, + "DisplayName": { + "type": "string", + "description": "TargetUser may be a recipient id, so log the display name the caller saw" + }, "FolderName": { "type": "string" }, @@ -38654,6 +38662,9 @@ }, "User": { "x-cipp-field-source": "backend" + }, + "UserId": { + "x-cipp-field-source": "backend" } } } @@ -39789,6 +39800,9 @@ }, "User": { "x-cipp-field-source": "backend" + }, + "UserId": { + "x-cipp-field-source": "backend" } } } @@ -54989,7 +55003,7 @@ "tags": [ "Tenant > Standards" ], - "description": "Lists tenant alignment data showing how well tenants conform to their assigned standards templates.", + "description": "Lists tenant alignment data showing how well tenants conform to their assigned standards templates.\n\nPass summary=true for the estate roll-up only: per-tenant averages collapsed into score\nbuckets, the overall average, the lowest-scoring tenants and the pending-deviation totals.\nThe row list is one entry per tenant per standard, so an estate-wide caller that only\nrenders those aggregates would otherwise pull tenants x standards rows to compute a\nhandful of numbers.", "parameters": [ { "name": "granular", @@ -54998,6 +55012,14 @@ "schema": { "type": "boolean" } + }, + { + "name": "summary", + "in": "query", + "required": false, + "schema": { + "type": "boolean" + } } ], "responses": { @@ -55823,6 +55845,14 @@ "type": "string" } }, + { + "name": "countsOnly", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, { "name": "includeCounts", "in": "query", @@ -58032,11 +58062,67 @@ "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.", + "description": "Derived from the Microsoft Graph entity it queries, and the fields written into the storage table it reads. 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": { + "addIns": { + "type": "array", + "x-cipp-field-source": "graph-entity" + }, + "api": { + "type": "object", + "x-cipp-field-source": "graph-entity" + }, + "appId": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "applicationTemplateId": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, "AppName": { "x-cipp-field-source": "storage" }, + "appRoles": { + "type": "array", + "x-cipp-field-source": "graph-entity" + }, + "authenticationBehaviors": { + "type": "object", + "x-cipp-field-source": "graph-entity" + }, + "certification": { + "type": "object", + "x-cipp-field-source": "graph-entity" + }, + "createdByAppId": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "createdDateTime": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "defaultRedirectUri": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "deletedDateTime": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "description": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "disabledByMicrosoftStatus": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "displayName": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, "Enabled": { "x-cipp-field-source": "storage" }, @@ -58044,16 +58130,96 @@ "type": "string", "x-cipp-field-source": "storage" }, + "groupMembershipClaims": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "id": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "identifierUris": { + "type": "array", + "x-cipp-field-source": "graph-entity" + }, + "info": { + "type": "object", + "x-cipp-field-source": "graph-entity" + }, "IPRange": { "x-cipp-field-source": "storage" }, + "isDeviceOnlyAuthSupported": { + "type": "boolean", + "x-cipp-field-source": "graph-entity" + }, + "isDisabled": { + "type": "boolean", + "x-cipp-field-source": "graph-entity" + }, + "isFallbackPublicClient": { + "type": "boolean", + "x-cipp-field-source": "graph-entity" + }, + "keyCredentials": { + "type": "array", + "x-cipp-field-source": "graph-entity" + }, + "logo": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "managerApplications": { + "type": "array", + "x-cipp-field-source": "graph-entity" + }, "MCPAllowed": { "type": "boolean", "x-cipp-field-source": "storage" }, + "nativeAuthenticationApisEnabled": { + "type": "object", + "x-cipp-field-source": "graph-entity" + }, + "notes": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "oauth2RequirePostResponse": { + "type": "boolean", + "x-cipp-field-source": "graph-entity" + }, + "optionalClaims": { + "type": "object", + "x-cipp-field-source": "graph-entity" + }, + "parentalControlSettings": { + "type": "object", + "x-cipp-field-source": "graph-entity" + }, "PartitionKey": { "x-cipp-field-source": "storage" }, + "passwordCredentials": { + "type": "array", + "x-cipp-field-source": "graph-entity" + }, + "publicClient": { + "type": "object", + "x-cipp-field-source": "graph-entity" + }, + "publisherDomain": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "requestSignatureVerification": { + "type": "object", + "x-cipp-field-source": "graph-entity" + }, + "requiredResourceAccess": { + "type": "array", + "x-cipp-field-source": "graph-entity" + }, "Role": { "type": "string", "x-cipp-field-source": "storage" @@ -58061,9 +58227,49 @@ "RowKey": { "x-cipp-field-source": "storage" }, + "samlMetadataUrl": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "serviceManagementReference": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "servicePrincipalLockConfiguration": { + "type": "object", + "x-cipp-field-source": "graph-entity" + }, + "signInAudience": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "spa": { + "type": "object", + "x-cipp-field-source": "graph-entity" + }, + "tags": { + "type": "array", + "x-cipp-field-source": "graph-entity" + }, "Timestamp": { "type": "string", "x-cipp-field-source": "storage" + }, + "tokenEncryptionKeyId": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "uniqueName": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "verifiedPublisher": { + "type": "object", + "x-cipp-field-source": "graph-entity" + }, + "web": { + "type": "object", + "x-cipp-field-source": "graph-entity" } } } diff --git a/Config/standards.json b/Config/standards.json index f5571101c8bd7..62a8932f623d6 100644 --- a/Config/standards.json +++ b/Config/standards.json @@ -4,7 +4,7 @@ "cat": "Copilot (M365) Standards", "tag": [], "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.", - "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. 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. The exact accepted value per setting is a string (commonly \"1\"/\"0\") and should be validated against a Copilot-licensed tenant.", + "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. 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.", "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.", "addedComponent": [ { @@ -39,8 +39,8 @@ "name": "standards.CopilotSettings.imageGeneration", "options": [ { "label": "Do not configure", "value": "donotconfigure" }, - { "label": "Enabled", "value": "1" }, - { "label": "Disabled", "value": "0" } + { "label": "Disabled", "value": "1" }, + { "label": "Enabled", "value": "0" } ] }, { @@ -51,11 +51,11 @@ "name": "standards.CopilotSettings.allowWebSearch", "options": [ { "label": "Do not configure", "value": "donotconfigure" }, - { "label": "Enabled in Microsoft 365 Copilot and Microsoft 365 Copilot Chat", "value": "2" }, + { "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": "0" + "value": "2" } ] }, @@ -7597,7 +7597,7 @@ "impact": "High Impact", "impactColour": "danger", "addedDate": "2026-04-28", - "powershellEquivalent": "Set-SPOTenant -CustomScriptsRestrictMode $true", + "powershellEquivalent": "Portal only", "recommendedBy": ["CIPP"], "requiredCapabilities": [ "SHAREPOINTWAC", diff --git a/Modules/AzBobbyTables/3.6.2/AzBobbyTables.PS.dll b/Modules/AzBobbyTables/3.6.2/AzBobbyTables.PS.dll index 7fa6ef290c144..9bcca4d40c687 100644 Binary files a/Modules/AzBobbyTables/3.6.2/AzBobbyTables.PS.dll and b/Modules/AzBobbyTables/3.6.2/AzBobbyTables.PS.dll differ diff --git a/Modules/AzBobbyTables/3.6.2/dependencies/AzBobbyTables.Core.dll b/Modules/AzBobbyTables/3.6.2/dependencies/AzBobbyTables.Core.dll index 6ff38bce3e839..61d7298961052 100644 Binary files a/Modules/AzBobbyTables/3.6.2/dependencies/AzBobbyTables.Core.dll and b/Modules/AzBobbyTables/3.6.2/dependencies/AzBobbyTables.Core.dll differ diff --git a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/BEC/Push-BECRun.ps1 b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/BEC/Push-BECRun.ps1 index 1708de199a5b5..d4f9a8b2f814c 100644 --- a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/BEC/Push-BECRun.ps1 +++ b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/BEC/Push-BECRun.ps1 @@ -1,4 +1,4 @@ -function Push-BECRun { +function Push-BECRun { <# .FUNCTIONALITY Entrypoint @@ -17,8 +17,16 @@ function Push-BECRun { Write-Information "Working on $UserName" try { - $startDate = (Get-Date).AddDays(-7).ToUniversalTime() - $endDate = (Get-Date) + $startDate = (Get-Date).ToUniversalTime().AddDays(-7) + $endDate = (Get-Date).ToUniversalTime() + + # conditionalAccessStatus is 'success'/'notApplied'/'failure'; errorCode 0 is a successful + # sign-in. Shared by every sign-in projection below. + $SignInStatus = { if ($_.conditionalAccessStatus -in @('success', 'notApplied') -and $_.status.errorCode -eq 0) { 'Success' } else { 'Failed' } } + # ISO 8601 so the frontend table formatter and new Date() can both parse it - Out-String + # renders a locale string neither understands + $SignInDate = { if ($_.createdDateTime) { ([datetime]$_.createdDateTime).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') } else { $null } } + Write-Information 'Getting audit logs' try { $auditLog = (New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-AdminAuditLogConfig').UnifiedAuditLogIngestionEnabled @@ -32,8 +40,6 @@ function Push-BECRun { 'UpdateCalendarDelegation', 'AddFolderPermissions' ) - $startDate = (Get-Date).AddDays(-7) - $endDate = (Get-Date) $SearchParam = @{ SessionCommand = 'ReturnLargeSet' Operations = $operations @@ -57,11 +63,13 @@ function Push-BECRun { Write-Information 'Getting last sign-in' try { $URI = "https://graph.microsoft.com/beta/auditLogs/signIns?`$filter=(userId eq '$SuspectUser')&`$top=1&`$orderby=createdDateTime desc" - $LastSignIn = New-GraphGetRequest -uri $URI -tenantid $TenantFilter -noPagination $true -verbose | Select-Object @{ Name = 'CreatedDateTime'; Expression = { $(($_.createdDateTime | Out-String) -replace '\r\n') } }, + $LastSignIn = New-GraphGetRequest -uri $URI -tenantid $TenantFilter -noPagination $true -verbose | Select-Object @{ Name = 'CreatedDateTime'; Expression = $SignInDate }, id, @{ Name = 'AppDisplayName'; Expression = { $_.resourceDisplayName } }, - @{ Name = 'Status'; Expression = { if (($_.conditionalAccessStatus -eq 'Success' -or 'Not Applied') -and $_.status.errorCode -eq 0) { 'Success' } else { 'Failed' } } }, - @{ Name = 'IPAddress'; Expression = { $_.ipAddress } } + @{ Name = 'Status'; Expression = $SignInStatus }, + @{ Name = 'IPAddress'; Expression = { $_.ipAddress } }, + @{ Name = 'Country'; Expression = { $_.location.countryOrRegion } }, + @{ Name = 'City'; Expression = { $_.location.city } } } catch { $LastSignIn = [PSCustomObject]@{ AppDisplayName = 'Unknown - could not retrieve information. No access to sign-in logs' @@ -70,6 +78,24 @@ function Push-BECRun { Status = 'Could not retrieve additional details' } } + Write-Information 'Getting suspect user sign-ins' + $SuspectUserSignInsError = $null + try { + $URI = "https://graph.microsoft.com/beta/auditLogs/signIns?`$filter=(userId eq '$SuspectUser')&`$top=50&`$orderby=createdDateTime desc" + $SuspectUserSignIns = @(New-GraphGetRequest -uri $URI -tenantid $TenantFilter -noPagination $true | Select-Object @{ Name = 'CreatedDateTime'; Expression = $SignInDate }, + id, + @{ Name = 'AppDisplayName'; Expression = { $_.resourceDisplayName } }, + @{ Name = 'ClientAppUsed'; Expression = { $_.clientAppUsed } }, + @{ Name = 'Status'; Expression = $SignInStatus }, + @{ Name = 'IPAddress'; Expression = { $_.ipAddress } }, + @{ Name = 'Country'; Expression = { $_.location.countryOrRegion } }, + @{ Name = 'City'; Expression = { $_.location.city } }) + } catch { + $SuspectUserSignIns = @() + $CippSignInError = Get-CippException -Exception $_ + $SuspectUserSignInsError = "Could not retrieve sign-in logs: $($CippSignInError.NormalizedError)" + Write-LogMessage -API 'BECRun' -message "Failed to retrieve sign-ins for $($UserName): $($CippSignInError.NormalizedError)" -tenant $TenantFilter -sev Warning -LogData $CippSignInError + } Write-Information 'Getting user devices' #List all users devices $Bytes = [System.Text.Encoding]::UTF8.GetBytes($SuspectUser) @@ -81,17 +107,24 @@ function Push-BECRun { } try { + # for the target-mailbox heuristic below: canonical ObjectIds carry the alias, not the UPN + $UserLocalPart = ($UserName -split '@')[0] $PermissionsLog = ($7DaysLog | Where-Object -Property Operations -In 'Remove-MailboxPermission', 'Add-MailboxPermission', 'UpdateCalendarDelegation', 'AddFolderPermissions' ).AuditData | ConvertFrom-Json -ErrorAction Stop | ForEach-Object { $perms = if ($_.Parameters) { $_.Parameters | ForEach-Object { if ($_.Name -eq 'AccessRights') { $_.Value } } } else { $_.item.ParentFolder.MemberRights } $objectID = if ($_.ObjectID) { $_.ObjectID } else { $($_.MailboxOwnerUPN) + $_.item.ParentFolder.Path } + # this is a tenant-wide search; flag the rows that concern the investigated mailbox + # so the threat score can weight them above unrelated tenant churn + $IdentityParam = if ($_.Parameters) { ($_.Parameters | Where-Object { $_.Name -eq 'Identity' }).Value } + $TargetCandidates = @($objectID, $IdentityParam, $_.MailboxOwnerUPN) -join ' ' [pscustomobject]@{ - Operation = $_.Operation - UserKey = $_.UserKey - ObjectId = $objectId - Permissions = $perms + Operation = $_.Operation + UserKey = $_.UserKey + ObjectId = $objectId + Permissions = $perms + TargetsSuspect = ($TargetCandidates -like "*$UserName*" -or ($UserLocalPart -and $TargetCandidates -like "*$UserLocalPart*")) } } } catch { @@ -108,19 +141,29 @@ function Push-BECRun { sessionid = (Get-Random -Minimum 10000 -Maximum 99999) startDate = $startDate endDate = $endDate - UserIds = $UserName + # Must be an array: New-ExoRequest JSON-serializes cmdParams, and a bare + # string binds to Search-UnifiedAuditLog's String[] UserIds as a scalar, + # which EXO rejects with an argument transformation error. + UserIds = @($UserName) } - (New-ExoRequest -tenantid $TenantFilter -cmdlet 'Search-UnifiedAuditLog' -cmdParams $RuleSearchParam -Anchor $UserName).AuditData | ConvertFrom-Json -ErrorAction Stop | - Where-Object { $_.UserId -eq $UserName -or $_.MailboxOwnerUPN -eq $UserName -or $_.ObjectId -like "*$UserName*" } | ForEach-Object { - $RuleName = ($_.Parameters | Where-Object { $_.Name -eq 'Name' }).Value ?? $_.ObjectId - [pscustomobject]@{ - Operation = $_.Operation - UserKey = $_.UserId - RuleName = $RuleName - Parameters = ($_.Parameters | Where-Object { $_ -and $_.Name -notin 'Identity', 'Name' } | ForEach-Object { "$($_.Name)=$($_.Value)" }) -join '; ' - Date = $_.CreationTime + # A search with no hits returns no AuditData at all, and piping that null into + # ConvertFrom-Json throws - which would report every clean user as a failure. + $RuleAuditData = (New-ExoRequest -tenantid $TenantFilter -cmdlet 'Search-UnifiedAuditLog' -cmdParams $RuleSearchParam -Anchor $UserName).AuditData + if (-not $RuleAuditData) { @() } else { + $RuleAuditData | ConvertFrom-Json -ErrorAction Stop | + Where-Object { $_.UserId -eq $UserName -or $_.MailboxOwnerUPN -eq $UserName -or $_.ObjectId -like "*$UserName*" } | ForEach-Object { + $RuleName = ($_.Parameters | Where-Object { $_.Name -eq 'Name' }).Value ?? $_.ObjectId + [pscustomobject]@{ + Operation = $_.Operation + UserKey = $_.UserId + RuleName = $RuleName + Parameters = ($_.Parameters | Where-Object { $_ -and $_.Name -notin 'Identity', 'Name' } | ForEach-Object { "$($_.Name)=$($_.Value)" }) -join '; ' + Date = $_.CreationTime + # admin-cmdlet records carry ClientIP, mailbox-sync records (UpdateInboxRules) ClientIPAddress + ClientIP = $_.ClientIP ?? $_.ClientIPAddress + } } - } + } } } catch { $RuleChangesLog = @() @@ -143,6 +186,91 @@ function Push-BECRun { $RecentRuleNames = @($RuleChangesLog | Where-Object { $_.Operation -in 'New-InboxRule', 'Set-InboxRule' } | ForEach-Object { ($_.RuleName -split '\\')[-1] }) $RulesLog = @($RulesLog | Where-Object { $_ } | Select-Object *, @{ Name = 'RecentlyChanged'; Expression = { $_.Name -in $RecentRuleNames } }) + Write-Information 'Getting trusted and blocked senders' + $SafelistError = $null + try { + $JunkConfig = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-MailboxJunkEmailConfiguration' -cmdParams @{ Identity = $UserName } -Anchor $UserName + $TrustedSenders = @($JunkConfig.TrustedSendersAndDomains | Where-Object { $_ }) + $BlockedSenders = @($JunkConfig.BlockedSendersAndDomains | Where-Object { $_ }) + } catch { + $TrustedSenders = @() + $BlockedSenders = @() + $CippSafelistError = Get-CippException -Exception $_ + $SafelistError = "Could not retrieve the trusted/blocked senders list: $($CippSafelistError.NormalizedError)" + Write-LogMessage -API 'BECRun' -message "Failed to retrieve junk email configuration for $($UserName): $($CippSafelistError.NormalizedError)" -tenant $TenantFilter -sev Warning -LogData $CippSafelistError + } + + Write-Information 'Getting safelist changes' + try { + $SafelistChanges = if ($auditLog -eq $false) { @() } else { + $SafelistSearchParam = @{ + SessionCommand = 'ReturnLargeSet' + Operations = @('Set-MailboxJunkEmailConfiguration') + sessionid = (Get-Random -Minimum 10000 -Maximum 99999) + startDate = $startDate + endDate = $endDate + # array for the same String[] binding reason as the rule search above + UserIds = @($UserName) + } + $SafelistAuditData = (New-ExoRequest -tenantid $TenantFilter -cmdlet 'Search-UnifiedAuditLog' -cmdParams $SafelistSearchParam -Anchor $UserName).AuditData + if (-not $SafelistAuditData) { @() } else { + @($SafelistAuditData | ConvertFrom-Json -ErrorAction Stop | ForEach-Object { + $TrustedValue = ($_.Parameters | Where-Object { $_.Name -eq 'TrustedSendersAndDomains' }).Value + $BlockedValue = ($_.Parameters | Where-Object { $_.Name -eq 'BlockedSendersAndDomains' }).Value + [pscustomobject]@{ + Operation = $_.Operation + UserKey = $_.UserId + Date = $_.CreationTime + ClientIP = $_.ClientIP ?? $_.ClientIPAddress + # the audit record carries the full new list, not a delta + Trusted = if ($TrustedValue) { @(($TrustedValue -split ';').Trim() | Where-Object { $_ }) } else { $null } + Blocked = if ($BlockedValue) { @(($BlockedValue -split ';').Trim() | Where-Object { $_ }) } else { $null } + } + }) + } + } + } catch { + $SafelistChanges = @() + $CippSafelistChangeError = Get-CippException -Exception $_ + Write-LogMessage -API 'BECRun' -message "Failed to retrieve safelist changes for $($UserName): $($CippSafelistChangeError.NormalizedError)" -tenant $TenantFilter -sev Warning -LogData $CippSafelistChangeError + } + + Write-Information 'Getting sharing link activity' + try { + $SharingChanges = if ($auditLog -eq $false) { @() } else { + $SharingSearchParam = @{ + SessionCommand = 'ReturnLargeSet' + # link creation/changes only - AnonymousLinkUsed and access events are usage, not exposure changes + Operations = @('SharingSet', 'SharingInvitationCreated', 'AnonymousLinkCreated', 'AnonymousLinkUpdated', 'SecureLinkCreated', 'SecureLinkUpdated', 'AddedToSecureLink', 'CompanyLinkCreated') + sessionid = (Get-Random -Minimum 10000 -Maximum 99999) + startDate = $startDate + endDate = $endDate + # array for the same String[] binding reason as the rule search above + UserIds = @($UserName) + } + $SharingAuditData = (New-ExoRequest -tenantid $TenantFilter -cmdlet 'Search-UnifiedAuditLog' -cmdParams $SharingSearchParam -Anchor $UserName).AuditData + if (-not $SharingAuditData) { @() } else { + @($SharingAuditData | ConvertFrom-Json -ErrorAction Stop | ForEach-Object { + [pscustomobject]@{ + Operation = $_.Operation + UserKey = $_.UserId + Date = $_.CreationTime + Workload = $_.Workload + FileName = $_.SourceFileName + ItemUrl = $_.ObjectId + Target = $_.TargetUserOrGroupName + TargetType = $_.TargetUserOrGroupType + ClientIP = $_.ClientIP ?? $_.ClientIPAddress + } + }) + } + } + } catch { + $SharingChanges = @() + $CippSharingError = Get-CippException -Exception $_ + Write-LogMessage -API 'BECRun' -message "Failed to retrieve sharing link activity for $($UserName): $($CippSharingError.NormalizedError)" -tenant $TenantFilter -sev Warning -LogData $CippSharingError + } + Write-Information 'Getting sent message trace' try { $MessageTraceParams = @{ @@ -150,23 +278,83 @@ function Push-BECRun { StartDate = $startDate.ToString('s') EndDate = $endDate.ToString('s') } - $SentMessages = @(New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-MessageTraceV2' -cmdParams $MessageTraceParams -Anchor $UserName | - Select-Object MessageTraceId, Status, Subject, RecipientAddress, @{ Name = 'Received'; Expression = { $_.Received.ToString('u') } }, FromIP) + $SentMessagesRaw = @(New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-MessageTraceV2' -cmdParams $MessageTraceParams -Anchor $UserName) + $SentMessages = @($SentMessagesRaw | Select-Object MessageTraceId, Status, Subject, RecipientAddress, @{ Name = 'Received'; Expression = { $_.Received.ToString('u') } }, FromIP) } catch { + $SentMessagesRaw = @() $SentMessages = @() $CippTraceError = Get-CippException -Exception $_ Write-LogMessage -API 'BECRun' -message "Failed to retrieve message trace for $($UserName): $($CippTraceError.NormalizedError)" -tenant $TenantFilter -sev Warning -LogData $CippTraceError } - Write-Information 'Getting last 50 logons' + # Outbound mail pattern analysis. The trace returns one row per recipient, so 'messages' + # are distinct MessageTraceIds and 'recipients' are rows - one mail BCC'd to 200 people + # and 200 individual sends are both blasts, just along different axes. + try { + $RepeatSubjectMessages = 5 # same subject sent as this many separate messages + $RepeatSubjectRecipients = 20 # or reaching this many recipients in total + $BurstMessages = 10 # distinct messages inside one window + $BurstRecipients = 30 # or recipients inside one window + $BurstWindowTicks = [timespan]::FromMinutes(10).Ticks + + $RepeatedSubjects = @($SentMessagesRaw | Group-Object -Property { ([string]$_.Subject).Trim().ToLowerInvariant() } | ForEach-Object { + $MessageCount = @($_.Group.MessageTraceId | Select-Object -Unique).Count + $Times = @($_.Group.Received | Sort-Object) + [pscustomobject]@{ + Subject = if ([string]::IsNullOrWhiteSpace($_.Group[0].Subject)) { '(no subject)' } else { $_.Group[0].Subject } + MessageCount = $MessageCount + RecipientCount = $_.Count + FirstSent = if ($Times.Count -gt 0) { ([datetime]$Times[0]).ToString('u') } else { $null } + LastSent = if ($Times.Count -gt 0) { ([datetime]$Times[-1]).ToString('u') } else { $null } + Flagged = ($MessageCount -ge $RepeatSubjectMessages -or $_.Count -ge $RepeatSubjectRecipients) + } + } | Where-Object { $_.MessageCount -ge 3 -or $_.Flagged } | Sort-Object -Property MessageCount -Descending | Select-Object -First 10) + + $Bursts = @($SentMessagesRaw | Where-Object { $_.Received } | Group-Object -Property { [long](([datetime]$_.Received).ToUniversalTime().Ticks / $BurstWindowTicks) } | ForEach-Object { + $MessageCount = @($_.Group.MessageTraceId | Select-Object -Unique).Count + if ($MessageCount -ge $BurstMessages -or $_.Count -ge $BurstRecipients) { + $TopSubject = ($_.Group | Group-Object -Property Subject | Sort-Object -Property Count -Descending | Select-Object -First 1).Name + [pscustomobject]@{ + WindowStart = [datetime]::new(([long]$_.Name) * $BurstWindowTicks, [System.DateTimeKind]::Utc).ToString('u') + WindowMinutes = 10 + MessageCount = $MessageCount + RecipientCount = $_.Count + TopSubject = $TopSubject + } + } + } | Sort-Object -Property RecipientCount -Descending | Select-Object -First 10) + + $SentMessageAnalysis = [PSCustomObject]@{ + TotalMessages = @($SentMessagesRaw.MessageTraceId | Select-Object -Unique).Count + TotalRecipients = @($SentMessagesRaw).Count + RepeatedSubjects = $RepeatedSubjects + FlaggedSubjectCount = @($RepeatedSubjects | Where-Object { $_.Flagged }).Count + Bursts = $Bursts + Flagged = (@($RepeatedSubjects | Where-Object { $_.Flagged }).Count -gt 0 -or @($Bursts).Count -gt 0) + } + } catch { + $SentMessageAnalysis = [PSCustomObject]@{ + TotalMessages = @($SentMessages).Count + TotalRecipients = @($SentMessages).Count + RepeatedSubjects = @() + FlaggedSubjectCount = 0 + Bursts = @() + Flagged = $false + } + Write-LogMessage -API 'BECRun' -message "Failed to analyze sent message patterns for $($UserName): $($_.Exception.Message)" -tenant $TenantFilter -sev Warning + } + + Write-Information 'Getting last 50 tenant sign-ins' try { - $Last50Logons = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/auditLogs/signIns?`$filter=userDisplayName ne 'On-Premises Directory Synchronization Service Account'&`$top=50&`$orderby=createdDateTime desc" -tenantid $TenantFilter -noPagination $true | Select-Object @{ Name = 'CreatedDateTime'; Expression = { $(($_.createdDateTime | Out-String) -replace '\r\n') } }, + $TenantLastSignIns = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/auditLogs/signIns?`$filter=userDisplayName ne 'On-Premises Directory Synchronization Service Account'&`$top=50&`$orderby=createdDateTime desc" -tenantid $TenantFilter -noPagination $true | Select-Object @{ Name = 'CreatedDateTime'; Expression = $SignInDate }, id, @{ Name = 'AppDisplayName'; Expression = { $_.resourceDisplayName } }, - @{ Name = 'Status'; Expression = { if (($_.conditionalAccessStatus -eq 'Success' -or 'Not Applied') -and $_.status.errorCode -eq 0) { 'Success' } else { 'Failed' } } }, - @{ Name = 'IPAddress'; Expression = { $_.ipAddress } }, UserPrincipalName, UserDisplayName + @{ Name = 'Status'; Expression = $SignInStatus }, + @{ Name = 'IPAddress'; Expression = { $_.ipAddress } }, + @{ Name = 'Country'; Expression = { $_.location.countryOrRegion } }, + @{ Name = 'City'; Expression = { $_.location.city } }, UserPrincipalName, UserDisplayName } catch { - $Last50Logons = @( + $TenantLastSignIns = @( [PSCustomObject]@{ AppDisplayName = 'Unknown - could not retrieve information. No access to sign-in logs' CreatedDateTime = 'Unknown' @@ -177,6 +365,14 @@ function Push-BECRun { ) } + # Known-malicious application catalog shipped with CIPP; matched on appId below. + $MaliciousAppsCatalog = try { + @((Get-Content -Path (Join-Path $env:CIPPRootPath 'Config\MaliciousApps.json') -ErrorAction Stop | ConvertFrom-Json).applications) + } catch { + Write-Information "Could not load MaliciousApps.json: $($_.Exception.Message)" + @() + } + $Requests = @( @{ id = 'Users' @@ -198,7 +394,23 @@ function Push-BECRun { url = "users/$($SuspectUser)/managedDevices" method = 'GET' } + @{ + id = 'SuspectUser' + url = "users/$($SuspectUser)?`$select=id,displayName,userPrincipalName,usageLocation,country,city" + method = 'GET' + } ) + # Look for catalog apps present in the tenant regardless of age, chunked to keep each + # 'in' filter within Graph's operand limit. + $CatalogAppIds = @($MaliciousAppsCatalog.appId | Where-Object { $_ }) + for ($i = 0; $i -lt $CatalogAppIds.Count; $i += 15) { + $Chunk = $CatalogAppIds[$i..([Math]::Min($i + 14, $CatalogAppIds.Count - 1))] + $Requests += @{ + id = "MaliciousSPs$i" + url = "servicePrincipals?`$select=displayName,appId,accountEnabled,createdDateTime&`$filter=appId in ('$($Chunk -join "','")')" + method = 'GET' + } + } Write-Information 'Getting bulk requests' $GraphResults = New-GraphBulkRequest -Requests $Requests -tenantid $TenantFilter -asapp $true @@ -208,6 +420,35 @@ function Push-BECRun { $MFADevices = ($GraphResults | Where-Object { $_.id -eq 'MFADevices' }).body.value ?? @() $NewSPs = ($GraphResults | Where-Object { $_.id -eq 'NewSPs' }).body.value ?? @() + $SuspectUserDetail = ($GraphResults | Where-Object { $_.id -eq 'SuspectUser' }).body + if ($SuspectUserDetail.error) { $SuspectUserDetail = $null } + $UsageLocation = if ([string]::IsNullOrWhiteSpace($SuspectUserDetail.usageLocation)) { $null } else { $SuspectUserDetail.usageLocation } + + # Flag service principals added during the window that match the malicious catalog + $NewSPs = @(foreach ($SP in @($NewSPs)) { + $CatalogEntry = $MaliciousAppsCatalog | Where-Object { $_.appId -eq $SP.appId } | Select-Object -First 1 + $Match = if ($CatalogEntry) { + [PSCustomObject]@{ Name = $CatalogEntry.name; Categories = @($CatalogEntry.categories); Description = $CatalogEntry.description } + } else { $null } + $SP | Select-Object *, @{ Name = 'MaliciousMatch'; Expression = { $Match } } + }) + + # Catalog apps present in the tenant at all - persistence via OAuth consent survives a + # password reset, so an old grant matters as much as a new one. + $MaliciousSPResults = @($GraphResults | Where-Object { $_.id -like 'MaliciousSPs*' -and [int]$_.status -lt 400 } | ForEach-Object { $_.body.value } | Where-Object { $_ }) + $MaliciousSPs = @(foreach ($SP in $MaliciousSPResults) { + $CatalogEntry = $MaliciousAppsCatalog | Where-Object { $_.appId -eq $SP.appId } | Select-Object -First 1 + [PSCustomObject]@{ + displayName = $SP.displayName + appId = $SP.appId + accountEnabled = $SP.accountEnabled + createdDateTime = $SP.createdDateTime + CatalogName = $CatalogEntry.name + Categories = @($CatalogEntry.categories) + Description = $CatalogEntry.description + } + }) + # Intune managed devices for the suspect user — surface Graph failures instead of a silent empty list $IntuneResponse = $GraphResults | Where-Object { $_.id -eq 'IntuneDevices' } | Select-Object -First 1 $IntuneDevicesError = $null @@ -230,8 +471,8 @@ function Push-BECRun { operatingSystem = $Device.operatingSystem osVersion = $Device.osVersion complianceState = $Device.complianceState - enrolledDateTime = if ($Device.enrolledDateTime) { ($Device.enrolledDateTime | Out-String).Trim() } else { $null } - lastSyncDateTime = if ($Device.lastSyncDateTime) { ($Device.lastSyncDateTime | Out-String).Trim() } else { $null } + enrolledDateTime = if ($Device.enrolledDateTime) { ([datetime]$Device.enrolledDateTime).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') } else { $null } + lastSyncDateTime = if ($Device.lastSyncDateTime) { ([datetime]$Device.lastSyncDateTime).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') } else { $null } deviceEnrollmentType = $Device.deviceEnrollmentType manufacturer = $Device.manufacturer model = $Device.model @@ -243,20 +484,96 @@ function Push-BECRun { ) } + # Geo-locate the client IPs behind rule changes, safelist changes and sent mail so + # activity can be compared against the user's assigned usage location. Sign-ins carry + # their own location from Graph. A geo failure degrades to no location, never a failed run. + Write-Information 'Resolving IP locations' + $ClientIpRegex = [regex]'^(?(?:\d{1,3}(?:\.\d{1,3}){3}|\[[0-9a-fA-F:]+\]|[0-9a-fA-F:]+))(?::\d+)?$' + $GeoIPCandidates = [System.Collections.Generic.List[string]]::new() + foreach ($Row in (@($RuleChangesLog) + @($SafelistChanges) + @($SharingChanges))) { if ($Row.ClientIP) { $GeoIPCandidates.Add([string]$Row.ClientIP) } } + foreach ($Row in @($SentMessages)) { if ($Row.FromIP) { $GeoIPCandidates.Add([string]$Row.FromIP) } } + $GeoMap = @{} + if ($GeoIPCandidates.Count -gt 0) { + try { + $GeoMap = Get-CIPPGeoIPLocationBatch -IPs $GeoIPCandidates + } catch { + Write-LogMessage -API 'BECRun' -message "Failed to geo-locate activity IPs for $($UserName): $($_.Exception.Message)" -tenant $TenantFilter -sev Warning + $GeoMap = @{} + } + } + $GetGeo = { + param($RawIP) + if ([string]::IsNullOrWhiteSpace($RawIP)) { return $null } + # same normalization the batch helper applies to its keys (strip :port and brackets) + $Clean = $ClientIpRegex.Replace(([string]$RawIP).Trim(), '${IP}') -replace '[\[\]]', '' + if ([string]::IsNullOrWhiteSpace($Clean)) { return $null } + return $GeoMap[$Clean] + } + # $null when either side of the comparison is unknown - only a definite mismatch counts as foreign + $TestForeign = { + param($Country) + if (-not $UsageLocation -or [string]::IsNullOrWhiteSpace($Country) -or $Country -eq 'Unknown') { return $null } + return ($Country -ne $UsageLocation) + } + + foreach ($Row in (@($RuleChangesLog) + @($SafelistChanges) + @($SharingChanges))) { + $Geo = & $GetGeo $Row.ClientIP + $Row | Add-Member -NotePropertyName 'Country' -NotePropertyValue $Geo.CountryOrRegion -Force + $Row | Add-Member -NotePropertyName 'City' -NotePropertyValue $Geo.City -Force + $Row | Add-Member -NotePropertyName 'ForeignLocation' -NotePropertyValue (& $TestForeign $Geo.CountryOrRegion) -Force + } + foreach ($Row in @($SentMessages)) { + $Geo = & $GetGeo $Row.FromIP + $Row | Add-Member -NotePropertyName 'Country' -NotePropertyValue $Geo.CountryOrRegion -Force + $Row | Add-Member -NotePropertyName 'City' -NotePropertyValue $Geo.City -Force + $Row | Add-Member -NotePropertyName 'ForeignLocation' -NotePropertyValue (& $TestForeign $Geo.CountryOrRegion) -Force + } + foreach ($Row in @($SuspectUserSignIns)) { + $Row | Add-Member -NotePropertyName 'ForeignLocation' -NotePropertyValue (& $TestForeign $Row.Country) -Force + } + + $SignInCountries = @($SuspectUserSignIns | Where-Object { $_.Country } | Group-Object -Property Country | Sort-Object -Property Count -Descending | ForEach-Object { + [PSCustomObject]@{ Country = $_.Name; Count = $_.Count } + }) + $LocationAnalysis = [PSCustomObject]@{ + UsageLocation = $UsageLocation + UserRegisteredCountry = $SuspectUserDetail.country + SignInCountries = $SignInCountries + ForeignSignInCount = @($SuspectUserSignIns | Where-Object { $_.ForeignLocation -eq $true }).Count + # failed foreign attempts are password-spray background noise; only a success proves access + ForeignSuccessfulSignInCount = @($SuspectUserSignIns | Where-Object { $_.ForeignLocation -eq $true -and $_.Status -eq 'Success' }).Count + ForeignRuleChangeCount = @($RuleChangesLog | Where-Object { $_.ForeignLocation -eq $true }).Count + ForeignSafelistChangeCount = @($SafelistChanges | Where-Object { $_.ForeignLocation -eq $true }).Count + ForeignSharingChangeCount = @($SharingChanges | Where-Object { $_.ForeignLocation -eq $true }).Count + ForeignSentMessageCount = @($SentMessages | Where-Object { $_.ForeignLocation -eq $true }).Count + Note = if (-not $UsageLocation) { 'The user has no usage location assigned in Entra ID, so activity cannot be compared against an expected country. Countries are still listed for manual review.' } else { $null } + } + $Results = [PSCustomObject]@{ AddedApps = @($NewSPs) - SuspectUserMailboxLogons = @($Last50Logons) + MaliciousSPs = @($MaliciousSPs) + SuspectUserSignIns = @($SuspectUserSignIns) + SuspectUserSignInsError = $SuspectUserSignInsError + TenantLastSignIns = @($TenantLastSignIns) LastSuspectUserLogon = @($LastSignIn) SuspectUserDevices = @($Devices) NewRules = @($RulesLog) InboxRuleChanges = @($RuleChangesLog) SentMessages = @($SentMessages) + SentMessageAnalysis = $SentMessageAnalysis MailboxPermissionChanges = @($PermissionsLog) NewUsers = @($NewUsers) MFADevices = @($MFADevices | Where-Object { $_.'@odata.type' -ne '#microsoft.graph.passwordAuthenticationMethod' }) ChangedPasswords = @($PasswordChanges) + TrustedSenders = @($TrustedSenders) + BlockedSenders = @($BlockedSenders) + SafelistChanges = @($SafelistChanges) + SafelistError = $SafelistError + SharingChanges = @($SharingChanges) IntuneDevices = @($IntuneDevices) IntuneDevicesError = $IntuneDevicesError + LocationAnalysis = $LocationAnalysis + AnalysisWindowDays = 7 ExtractedAt = (Get-Date) ExtractResult = $ExtractResult } diff --git a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Mailbox Permissions/Push-GetCalendarPermissionsBatch.ps1 b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Mailbox Permissions/Push-GetCalendarPermissionsBatch.ps1 index ab0b934994b77..66424361f4b18 100644 --- a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Mailbox Permissions/Push-GetCalendarPermissionsBatch.ps1 +++ b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Mailbox Permissions/Push-GetCalendarPermissionsBatch.ps1 @@ -51,6 +51,9 @@ function Push-GetCalendarPermissionsBatch { Write-Information "Cache hits: $($FolderNameMap.Count), cache misses: $($CacheMissMailboxes.Count)" + # Declared out here because the completion log below reads its count even when Phase 1 is skipped + $NewCacheEntries = [System.Collections.Generic.List[hashtable]]::new() + # Phase 1: Bulk discover calendar folder names for cache misses if ($CacheMissMailboxes.Count -gt 0) { $FolderStatsRequests = foreach ($MailboxUPN in $CacheMissMailboxes) { @@ -69,7 +72,6 @@ function Push-GetCalendarPermissionsBatch { Write-Information "Phase 1: Bulk Get-MailboxFolderStatistics for $($CacheMissMailboxes.Count) mailboxes" $FolderStatsResults = New-ExoBulkRequest -tenantid $TenantFilter -cmdletArray @($FolderStatsRequests) - $NewCacheEntries = [System.Collections.Generic.List[hashtable]]::new() foreach ($Result in $FolderStatsResults) { if ($Result.error) { Write-Information "Failed to get folder stats for $($Result.OperationGuid): $($Result.error)" diff --git a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Mailbox Permissions/Push-StoreMailboxPermissions.ps1 b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Mailbox Permissions/Push-StoreMailboxPermissions.ps1 index c05728a208f35..3acec7b68daa1 100644 --- a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Mailbox Permissions/Push-StoreMailboxPermissions.ps1 +++ b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Mailbox Permissions/Push-StoreMailboxPermissions.ps1 @@ -19,76 +19,85 @@ function Push-StoreMailboxPermissions { Write-Information "Storing mailbox and calendar permissions for tenant $TenantFilter" Write-Information "Received $($Results.Count) batch results" - # Log each result for debugging - for ($i = 0; $i -lt $Results.Count; $i++) { - $result = $Results[$i] - Write-Information "Result $i type: $($result.GetType().Name), value: $($result | ConvertTo-Json -Depth 2 -Compress)" + # A batch result is normally the cmdlet-keyed hashtable, but an activity may return + # [hashtable, "status message"] - take the hashtable and ignore anything else. + $Unwrap = { + param($BatchResult) + $Actual = if ($BatchResult -is [array] -and $BatchResult.Count -gt 0) { $BatchResult[0] } else { $BatchResult } + if ($Actual -and ($Actual -is [hashtable] -or $Actual -is [System.Collections.IDictionary])) { $Actual } } - # Aggregate results by command type from all batches - $AllMailboxPermissions = [System.Collections.Generic.List[object]]::new() - $AllRecipientPermissions = [System.Collections.Generic.List[object]]::new() - $AllSendOnBehalfPermissions = [System.Collections.Generic.List[object]]::new() - $AllCalendarPermissions = [System.Collections.Generic.List[object]]::new() + # Grouped by cmdlet name due to ReturnWithCommand. Mailbox, recipient and + # send-on-behalf rows all land in the same MailboxPermissions type. + $MailboxCmdlets = 'Get-MailboxPermission', 'Get-RecipientPermission', 'Get-Mailbox' + # Count before writing. This pass only walks references that already live in + # $Item.Results, so it costs nothing next to the write; what it buys is the decision + # not to run a writer at all for a type with no rows. Add-CIPPDbItem's end block + # always writes the -Count row when -AddCount is present, so an unconditional + # pipeline would stamp a fresh count of 0 - without clearing the data rows - whenever + # every batch failed, and the freshness gates that read count rows (see + # Wait-CIPPBaselineCacheReady) would treat a stale cache as current. + $MailboxRows = 0 + $CalendarRows = 0 foreach ($BatchResult in $Results) { - # Activity functions may return an array [hashtable, "status message"] - # Extract the actual hashtable if result is an array - $ActualResult = $BatchResult - if ($BatchResult -is [array] -and $BatchResult.Count -gt 0) { - Write-Information "Result is array with $($BatchResult.Count) elements, extracting first element" - $ActualResult = $BatchResult[0] - } + $ActualResult = & $Unwrap $BatchResult + if (-not $ActualResult) { continue } - if ($ActualResult -and ($ActualResult -is [hashtable] -or $ActualResult -is [System.Collections.IDictionary])) { - Write-Information "Processing hashtable result with keys: $($ActualResult.Keys -join ', ')" - # Results are grouped by cmdlet name due to ReturnWithCommand - if ($ActualResult['Get-MailboxPermission']) { - $MailboxPerms = @($ActualResult['Get-MailboxPermission']) - Write-Information "Adding $($MailboxPerms.Count) mailbox permissions" - $AllMailboxPermissions.AddRange($MailboxPerms) - } - if ($ActualResult['Get-RecipientPermission']) { - $RecipientPerms = @($ActualResult['Get-RecipientPermission']) - Write-Information "Adding $($RecipientPerms.Count) recipient permissions" - $AllRecipientPermissions.AddRange($RecipientPerms) - } - if ($ActualResult['Get-Mailbox']) { - $SendOnBehalfRows = @($ActualResult['Get-Mailbox']) - Write-Information "Adding $($SendOnBehalfRows.Count) send-on-behalf permissions" - $AllSendOnBehalfPermissions.AddRange($SendOnBehalfRows) + foreach ($Cmdlet in $MailboxCmdlets) { + foreach ($Row in @($ActualResult[$Cmdlet])) { + if ($null -ne $Row) { $MailboxRows++ } } - if ($ActualResult['Get-MailboxFolderPermission']) { - $CalendarPerms = @($ActualResult['Get-MailboxFolderPermission']) - Write-Information "Adding $($CalendarPerms.Count) calendar permissions" - $AllCalendarPermissions.AddRange($CalendarPerms) - } - } else { - Write-Information "Skipping non-hashtable result: $($ActualResult.GetType().Name)" + } + foreach ($Row in @($ActualResult['Get-MailboxFolderPermission'])) { + if ($null -ne $Row) { $CalendarRows++ } } } - # Combine all permissions (mailbox and recipient) into a single collection - $AllPermissions = [System.Collections.Generic.List[object]]::new() - $AllPermissions.AddRange($AllMailboxPermissions) - $AllPermissions.AddRange($AllRecipientPermissions) - $AllPermissions.AddRange($AllSendOnBehalfPermissions) + # Rows are emitted straight into Add-CIPPDbItem rather than collected first. + # + # This used to build four Lists, then a fifth combining three of them, and hold all of it + # alongside $Item.Results - which is already the whole tenant's permission set - until both + # writes had finished. On a large tenant that is every permission record pinned twice over, + # and it showed: this job was one of two that took a production instance to 3.8GB. + # + # Feeding a script block into one pipeline keeps the peak at a single batch's rows, because + # Add-CIPPDbItem flushes every 100 and never accumulates. ONE invocation per Type is required, + # not merely tidier: its end block runs a single orphan cleanup keyed to the run id from its + # begin block and writes the -Count row once, so splitting the flush would have each later + # call treat rows the earlier ones just wrote as orphans (see Set-CIPPDBCacheDefenderCVEs). + if ($MailboxRows -gt 0) { + & { + foreach ($BatchResult in $Results) { + $ActualResult = & $Unwrap $BatchResult + if (-not $ActualResult) { continue } - Write-Information "Aggregated $($AllPermissions.Count) total permissions ($($AllMailboxPermissions.Count) mailbox + $($AllRecipientPermissions.Count) recipient + $($AllSendOnBehalfPermissions.Count) send-on-behalf)" - Write-Information "Aggregated $($AllCalendarPermissions.Count) calendar permissions" + foreach ($Cmdlet in $MailboxCmdlets) { + foreach ($Row in @($ActualResult[$Cmdlet])) { + if ($null -ne $Row) { $Row } + } + } + } + } | Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'MailboxPermissions' -AddCount - # Store all permissions together as MailboxPermissions - if ($AllPermissions.Count -gt 0) { - $AllPermissions | Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'MailboxPermissions' -AddCount - Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Cached $($AllPermissions.Count) mailbox permission records" -sev Info + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Cached $MailboxRows mailbox permission records" -sev Info } else { Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'No mailbox permissions found to cache' -sev Info } - # Store calendar permissions separately - if ($AllCalendarPermissions.Count -gt 0) { - $AllCalendarPermissions | Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'CalendarPermissions' -AddCount - Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Cached $($AllCalendarPermissions.Count) calendar permission records" -sev Info + if ($CalendarRows -gt 0) { + & { + foreach ($BatchResult in $Results) { + $ActualResult = & $Unwrap $BatchResult + if (-not $ActualResult) { continue } + + foreach ($Row in @($ActualResult['Get-MailboxFolderPermission'])) { + if ($null -ne $Row) { $Row } + } + } + } | Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'CalendarPermissions' -AddCount + + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Cached $CalendarRows calendar permission records" -sev Info } else { Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'No calendar permissions found to cache' -sev Info } diff --git a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Maintenance/Push-TableCleanupTask.ps1 b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Maintenance/Push-TableCleanupTask.ps1 index 6134363987045..3d610ace04a9d 100644 --- a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Maintenance/Push-TableCleanupTask.ps1 +++ b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Maintenance/Push-TableCleanupTask.ps1 @@ -17,6 +17,8 @@ function Push-TableCleanupTask { Write-Information "Deleting table $($Table.Context.TableName)" try { Remove-AzDataTable -Context $Table.Context + # Drop it from the Get-CIPPTable cache so it gets recreated on next use. + Unregister-CIPPTable -TableName $Table.Context.TableName } catch { #Write-LogMessage -API 'TableCleanup' -message "Failed to delete table $($Table.Context.TableName)" -sev Error -LogData (Get-CippException -Exception $_) } 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 42bd5b3e68e4c..d77f6d51190cb 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,16 +1,42 @@ function Push-DBCacheSharePointSiteSharingLinks { <# .SYNOPSIS - Scans a single SharePoint/OneDrive site for sharing links and returns the rows. + Scans a single SharePoint/OneDrive site for sharing links, resumably. .DESCRIPTION Processes one site (fanned out by Set-CIPPDBCacheSharePointSharingLinks). Enumerates the - site's drives, delta-scans each drive for items carrying the "shared" facet, fetches the - direct (non-inherited) sharing permissions of those items and returns one row per sharing - link (any scope) or direct grant to an external user. Delta pages are streamed and shared - items are processed in bounded buffers so a single very large library cannot exhaust the - worker's memory. The rows are returned to the orchestrator; Push-StoreSharePointSharingLinks - aggregates every site and writes the cache once. + 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. .FUNCTIONALITY Entrypoint @@ -18,11 +44,17 @@ function Push-DBCacheSharePointSiteSharingLinks { [CmdletBinding()] param($Item) - $TenantFilter = $Item.TenantFilter + $TenantFilter = Resolve-CIPPSharingLinksTenantFilter -TenantFilter $Item.TenantFilter $SiteId = $Item.SiteId $SiteName = $Item.SiteName $SiteUrl = $Item.SiteUrl $IsPersonalSite = [bool]$Item.IsPersonalSite + $ScanId = [string]$Item.ScanId + $ForceFull = [bool]$Item.ForceFull + $CacheType = 'SharePointSharingLinks' + + $FullScanDays = 14 + if ($env:CIPP_SHARINGLINKS_FULLSCAN_DAYS -match '^\d+$') { $FullScanDays = [Math]::Max(1, [int]$env:CIPP_SHARINGLINKS_FULLSCAN_DAYS) } # Verified domains passed from the parent; used to tell internal from external recipients. $InternalDomains = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) @@ -47,9 +79,7 @@ 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 } - $Rows = [System.Collections.Generic.List[object]]::new() - - # Fetch permissions for a buffer of shared items and append their sharing-link rows. + # 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) @@ -138,6 +168,137 @@ function Push-DBCacheSharePointSiteSharingLinks { } } + # --- 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. + $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 { + $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 { + param($State) + Add-CIPPAzDataTableEntity @StateTable -Entity @{ + PartitionKey = $TenantFilter + RowKey = $CheckpointRowKey + ScanId = $ScanId + StateJson = [string]($State | ConvertTo-Json -Depth 10 -Compress) + } -Force + } + + function Remove-SiteCheckpoint { + $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. + function Set-DriveState { + param([string]$DriveId, [AllowEmptyString()][string]$DeltaLink = '', [switch]$FullScan) + $NowUtc = [string]([DateTimeOffset]::UtcNow.ToString('o')) + $Existing = Get-CIPPSharingLinksDriveState -TenantFilter $TenantFilter -DriveId $DriveId + $LastFullScanUtc = if ($FullScan) { $NowUtc } else { [string]($Existing.LastFullScanUtc ?? '') } + Add-CIPPAzDataTableEntity @StateTable -Entity @{ + PartitionKey = $TenantFilter + RowKey = "delta-$(ConvertTo-CIPPSharingLinksKeySegment -Value $DriveId)" + DriveId = $DriveId + SiteId = $SiteId + DeltaLink = [string]$DeltaLink + LastScanId = $ScanId + LastScanUtc = $NowUtc + LastFullScanUtc = $LastFullScanUtc + } -Force + } + + # Marks this site finished (successfully or failed) and runs finalisation if it was the last + # pending one. Idempotent: the marker row is an insert (first writer wins), so however many + # times a retry mechanism dispatches this site, the counter is decremented exactly once - a + # duplicate decrement would reach zero early and finalisation would prune rows of sites that + # are still scanning. The decrement itself is ETag-conditional so two DIFFERENT sites + # finishing at once cannot both write the same counter value; the losing writer rereads and + # retries. A superseded scan or a persistent write conflict must never finalise. + function Complete-Site { + param([switch]$Failed) + # A task from a scan that has since been superseded must not write markers or touch + # counters - the current scan owns them. + $CurrentScan = Get-ScanRow + if (-not $CurrentScan -or [string]$CurrentScan.ScanId -ne $ScanId) { return } + + $Marker = @{ + PartitionKey = $TenantFilter + RowKey = "done-$SiteKeySegment" + ScanId = $ScanId + Failed = [bool]$Failed + CompletedUtc = [string]([DateTimeOffset]::UtcNow.ToString('o')) + } + try { + # Insert, not upsert: failing on an existing marker IS the duplicate detection. + Add-CIPPAzDataTableEntity @StateTable -Entity $Marker -ErrorAction Stop + } catch { + # Conflict: either this site was already counted against the current scan (a retry + # racing the original - suppress), or the marker is a leftover of a superseded scan + # that slipped past the parent's cleanup - take it over and count normally. + $Existing = Get-CIPPAzDataTableEntity @StateTable -Filter "PartitionKey eq '$SafeTenant' and RowKey eq 'done-$SiteKeySegment'" + if ($Existing -and [string]$Existing.ScanId -eq $ScanId) { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Sharing links: duplicate completion of '$SiteUrl' suppressed (scan $ScanId)" -sev Debug + return + } + Add-CIPPAzDataTableEntity @StateTable -Entity $Marker -Force + } + $Pending = $null + for ($Attempt = 0; $Attempt -lt 10; $Attempt++) { + $ScanRow = Get-ScanRow + if (-not $ScanRow -or [string]$ScanRow.ScanId -ne $ScanId) { return } + $ScanRow.PendingSites = [int]$ScanRow.PendingSites - 1 + if ($Failed) { + $FailedList = @() + try { $FailedList = @($ScanRow.FailedSites | ConvertFrom-Json -ErrorAction Stop) } catch {} + # Capped so the property can never outgrow a table column; the per-site log entry + # carries the detail, and finalisation only needs membership. + if ($FailedList.Count -lt 500) { $FailedList = @($FailedList) + $SiteId } + $ScanRow.FailedSites = [string](ConvertTo-Json @($FailedList) -Compress) + } + try { + # -ErrorAction Stop is load-bearing: the cmdlet reports an ETag conflict (412) + # as a NON-terminating error, which would sail past this catch, skip the retry + # and silently lose the decrement - leaving the counter stuck above zero and + # finalisation never running. + $null = Update-AzDataTableEntity @StateTable -Entity $ScanRow -ErrorAction Stop + $Pending = [int]$ScanRow.PendingSites + break + } catch { + Start-Sleep -Milliseconds (Get-Random -Minimum 50 -Maximum 250) + } + } + if ($null -eq $Pending) { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Sharing links: could not update scan counter for scan $ScanId after 10 attempts; finalisation may not run this scan" -sev Warning + return + } + if ($Pending -le 0) { + Push-StoreSharePointSharingLinks -TenantFilter $TenantFilter -ScanId $ScanId + } + } + + # 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 @() + } + try { # 1) Drives (document libraries) for this one site. $Drives = @() @@ -145,6 +306,7 @@ function Push-DBCacheSharePointSiteSharingLinks { $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 @() } @@ -154,37 +316,157 @@ function Push-DBCacheSharePointSiteSharingLinks { SiteUrl = $SiteUrl IsPersonalSite = $IsPersonalSite } - $PermissionBufferSize = 200 - # 2) Delta-scan each drive, streaming pages so a huge library never loads at once. - # Shared items are buffered and flushed to permission lookups in bounded chunks. + # 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 } - $Buffer = [System.Collections.Generic.List[object]]::new() - try { - New-GraphGetRequest -uri "https://graph.microsoft.com/beta/drives/$($Drive.id)/root/delta?`$select=id,name,webUrl,folder,shared,size,lastModifiedDateTime&`$top=999" -tenantid $TenantFilter -asapp $true -Stream | - Where-Object { $_.shared -and -not $_.deleted } | - ForEach-Object { - $Buffer.Add($_) - if ($Buffer.Count -ge $PermissionBufferSize) { - Add-CIPPSharingRows -Buffer $Buffer -Drive $Drive -Site $SiteContext -InternalDomains $InternalDomains -TenantFilter $TenantFilter -RowsOut $Rows - $Buffer.Clear() + 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 + } + } + + # 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) } } - # Flush the remainder for this drive. - Add-CIPPSharingRows -Buffer $Buffer -Drive $Drive -Site $SiteContext -InternalDomains $InternalDomains -TenantFilter $TenantFilter -RowsOut $Rows - } catch { - Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Sharing links: failed scanning drive '$($Drive.name)' on '$SiteUrl': $($_.Exception.Message)" -sev Warning - } finally { - $Buffer = $null - [System.GC]::Collect() + 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 + } + 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 { + $Uri = [string]$Page.'@odata.nextLink' + } + + # 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 + } + } + } + + 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 + } 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') + } + + [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 = '' } } - return @($Rows) + # 3) Site complete. + Remove-SiteCheckpoint + Complete-Site + return @() } catch { Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Sharing links: failed scanning site '$SiteUrl': $($_.Exception.Message)" -sev Error -LogData (Get-CippException -Exception $_) - return @($Rows) + Complete-Site -Failed + 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 40532b7f4b4e1..9e7f23996501f 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 @@ -1,36 +1,100 @@ function Push-StoreSharePointSharingLinks { <# .SYNOPSIS - Post-execution function that aggregates per-site sharing links and writes the cache. + Finalises a sharing-links scan: prunes rows for vanished drives and refreshes the count. .DESCRIPTION - Collects the row sets returned by every Push-DBCacheSharePointSiteSharingLinks activity and - writes them to the CIPP reporting database as a single SharePointSharingLinks dataset (full - replace with count). Writing once from this serial step avoids the {Type}-Count race that - parallel appenders would cause. + Runs once per scan, invoked inline by whichever Push-DBCacheSharePointSiteSharingLinks + activity completes the tenant's last pending site. Site activities write their rows to + the reporting DB as they scan, so there is nothing to aggregate here; what remains is + cross-site housekeeping no single site can decide alone: + + - Drives whose state row was not touched by this scan belong to deleted drives or sites, + so their cached rows are removed - unless the drive's site is on the scan's failed + list, in which case its rows survive one more cycle rather than vanish on a transient + error. + - The {Type}-Count row is recomputed from the rows actually present, which also absorbs + whatever adds and tombstones the incremental scans made along the way. + + Idempotent by design: the completion counter's conditional update makes a duplicate + invocation rare, and re-running prune + recount produces the same result. .FUNCTIONALITY Entrypoint #> [CmdletBinding()] - param($Item) + param( + $Item, + [string]$TenantFilter, + [string]$ScanId + ) - $TenantFilter = $Item.Parameters.TenantFilter + if ($Item) { + $TenantFilter = $Item.Parameters.TenantFilter ?? $TenantFilter + $ScanId = $Item.Parameters.ScanId ?? $ScanId + } + $TenantFilter = Resolve-CIPPSharingLinksTenantFilter -TenantFilter $TenantFilter + $CacheType = 'SharePointSharingLinks' try { - $AllRows = [System.Collections.Generic.List[object]]::new() - foreach ($SiteResult in $Item.Results) { - foreach ($Row in @($SiteResult)) { - if ($Row -and $Row.id) { $AllRows.Add($Row) } + # If a newer scan superseded this one between the last site completing and this read, + # pruning against ITS drive states would delete rows it is actively writing. Leave all + # housekeeping to the newer scan's own finalisation. + $StateTable = Get-CippTable -tablename 'CippSharingLinksState' + $SafeTenant = ConvertTo-CIPPODataFilterValue -Value $TenantFilter -Type String + $Scan = Get-CIPPAzDataTableEntity @StateTable -Filter "PartitionKey eq '$SafeTenant' and RowKey eq 'scan'" + $ScanMatches = $Scan -and [string]$Scan.ScanId -eq $ScanId + + $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 {} + } + + # Prune drives this scan never saw: deleted drives and deleted sites. Failed sites keep + # their rows - their drives were unreachable, not gone. + $PrunedDrives = 0 + $PrunedRows = 0 + if ($ScanMatches) { + 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 } + $DriveKeySegment = ConvertTo-CIPPSharingLinksKeySegment -Value "$($DriveState.DriveId)" + $PrunedRows += Remove-CIPPSharingLinksRowsByPrefix -TenantFilter $TenantFilter -Prefix "$CacheType-${DriveKeySegment}_" + Remove-CIPPAzDataTableEntity @StateTable -Entity $DriveState -Force + $PrunedDrives++ + } + + # A full-sweep scan (first scan of this design, or a forced full sync) rewrote every + # current link row with this scan's id, so anything left without it is stale by + # definition - including rows for drives deleted before delta state existed, which + # the per-drive prune above can never find. + if ([bool]$Scan.FullSweep) { + $PrunedRows += Remove-CIPPSharingLinksRowsByPrefix -TenantFilter $TenantFilter -Prefix "$CacheType-" -ExceptRunId $ScanId -ExceptRowKeys @("$CacheType-Count") } } - Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'SharePointSharingLinks' -Data @($AllRows) -AddCount - Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Cached $($AllRows.Count) SharePoint/OneDrive sharing links across $(@($Item.Results).Count) sites" -sev Info + # Recount from what is actually stored; incremental scans add and tombstone rows all + # through the run, so a running total would drift where this cannot. + $CountRowKey = "$CacheType-Count" + $LinkCount = 0 + foreach ($Row in (Get-CIPPSharingLinksRowKeysByPrefix -TenantFilter $TenantFilter -Prefix "$CacheType-")) { + if ($Row.RowKey -and $Row.RowKey -ne $CountRowKey) { $LinkCount++ } + } + + $ReportingTable = Get-CippTable -tablename 'CippReportingDB' + $null = Add-CIPPAzDataTableEntity @ReportingTable -Entity @{ + PartitionKey = $TenantFilter + RowKey = $CountRowKey + DataCount = [int]$LinkCount + Type = $CacheType + } -Force + + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Sharing links sync finalised: $LinkCount links cached, $PrunedDrives stale drives pruned ($PrunedRows rows), $($FailedSites.Count) sites failed" -sev Info return } catch { - Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Failed to store SharePoint sharing links: $($_.Exception.Message)" -sev Error -LogData (Get-CippException -Exception $_) + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Failed to finalise SharePoint sharing links sync: $($_.Exception.Message)" -sev Error -LogData (Get-CippException -Exception $_) throw } } diff --git a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertHuntressRogueApps.ps1 b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertHuntressRogueApps.ps1 index ec58292bbae10..a66e081963e03 100644 --- a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertHuntressRogueApps.ps1 +++ b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertHuntressRogueApps.ps1 @@ -19,7 +19,7 @@ function Get-CIPPAlertHuntressRogueApps { try { $RogueApps = Invoke-RestMethod -Uri 'https://huntresslabs.github.io/rogueapps/rogueapps.json' - $CippRogueApps = (Get-Content -Path (Join-Path $env:CIPPRootPath 'Config\schemaDefinitions.json') | ConvertFrom-Json).applications.appId + $CippRogueApps = (Get-Content -Path (Join-Path $env:CIPPRootPath 'Config\MaliciousApps.json') | ConvertFrom-Json).applications.appId $HuntressRogueApps = $RogueApps.appId $RogueAppIds = @($CippRogueApps) + @($HuntressRogueApps) | Where-Object { $_ } | Select-Object -Unique $Requests = for ($i = 0; $i -lt $RogueAppIds.Count; $i += 15) { diff --git a/Modules/CIPPCore/Public/Add-CIPPDbItem.ps1 b/Modules/CIPPCore/Public/Add-CIPPDbItem.ps1 index 029fd94972629..963619ad3bbe0 100644 --- a/Modules/CIPPCore/Public/Add-CIPPDbItem.ps1 +++ b/Modules/CIPPCore/Public/Add-CIPPDbItem.ps1 @@ -6,9 +6,9 @@ function Add-CIPPDbItem { Internal .PARAMETER ClearOnEmpty - Authorizes removal of existing rows when InputObject is an authoritative empty - collection and exact row-key cleanup for a non-empty authoritative collection. - Callers must only use this after a successful source response. + Authorizes removal of every row this run did not write, including when InputObject + is an authoritative empty collection (which clears the type entirely). Callers must + only use this after a successful source response. #> [CmdletBinding()] param( @@ -29,6 +29,12 @@ function Add-CIPPDbItem { [switch]$Append, [switch]$ClearOnEmpty, + # Stable run identity override. Callers whose logical "run" spans multiple invocations + # (resumable scans that append from many activities) pass the same id each time so a + # later cleanup can tell this run's rows from stale ones by identity, exactly like the + # single-invocation cleanup below does. Omit for the default: a new id per call. + [string]$RunId, + [ValidateRange(0, 60)] [int]$SkewMarginMinutes = 5 ) @@ -39,7 +45,14 @@ function Add-CIPPDbItem { $Batch = [System.Collections.Generic.List[hashtable]]::new($BatchSize) # Track batch duplicates separately from the full authoritative run used for cleanup. $SeenInBatch = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) - $SeenRowKeys = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + + # Every row written this run is stamped with this id, and the cleanup below deletes only + # rows that do NOT carry it. Identity, not age: a run of any length can never delete its + # own writes, however the worker's and the storage service's clocks disagree. It also + # means nothing per-row is retained across the run - a previous design kept a HashSet of + # every row key written for -ClearOnEmpty, which on a tenant-wide streaming cache was + # tens of thousands of strings held purely to be compared once at the end. + if (-not $RunId) { $RunId = [guid]::NewGuid().ToString() } # Allow for storage timestamp lag before considering untouched rows stale. $RunStartUtc = [DateTimeOffset]::UtcNow.AddMinutes(-$SkewMarginMinutes) @@ -70,7 +83,6 @@ function Add-CIPPDbItem { $ItemId = $Item.ExternalDirectoryObjectId ?? $Item.id ?? $Item.Identity ?? $Item.skuId ?? $Item.userPrincipalName ?? [guid]::NewGuid().ToString() $RowKey = $RowKeyControlRegex.Replace($RowKeyPathRegex.Replace("$Type-$ItemId", '_'), '') if ($SeenInBatch.Add($RowKey)) { - $null = $SeenRowKeys.Add($RowKey) $Batch.Add(@{ PartitionKey = $TenantFilter RowKey = $RowKey @@ -80,6 +92,7 @@ function Add-CIPPDbItem { # children and every consumer sees a mangled object. Data = [string]($Item | ConvertTo-Json -Depth 100 -Compress) Type = $Type + RunId = $RunId }) if ($Batch.Count -ge $BatchSize) { $null = Add-CIPPAzDataTableEntity @Table -Entity $Batch.ToArray() -Force @@ -102,24 +115,32 @@ function Add-CIPPDbItem { # the response was authoritative by passing -ClearOnEmpty. if (-not $Count.IsPresent -and -not $Append.IsPresent -and ($TotalProcessed -gt 0 -or $ClearOnEmpty.IsPresent)) { $Filter = "PartitionKey eq '{0}' and RowKey ge '{1}-' and RowKey lt '{1}0'" -f $TenantFilter, $Type - $Existing = Get-CIPPAzDataTableEntity @Table -Filter $Filter -Property PartitionKey, RowKey, ETag, OriginalEntityId, Timestamp + + # The Timestamp predicate is NARROWING ONLY: it lets the service return candidate + # orphans instead of every row of this type for the tenant, which at the end of a + # tenant-wide cache write was a second full copy of the dataset materialised exactly + # when the caller still held its first. The RunId check below is the delete + # authority, so if this predicate were ever dropped, unsupported, or subtly wrong, + # the candidate set merely changes size - rows this run wrote still cannot be + # deleted, because they carry this run's id. + # + # -ClearOnEmpty must consider every row (the source authoritatively returned the + # full - possibly empty - set), so it skips the narrowing. + if (-not $ClearOnEmpty.IsPresent) { + $Filter += " and Timestamp lt datetime'{0}'" -f $RunStartUtc.UtcDateTime.ToString('yyyy-MM-ddTHH:mm:ss.fffffffZ') + } + + $Existing = Get-CIPPAzDataTableEntity @Table -Filter $Filter -Property PartitionKey, RowKey, ETag, OriginalEntityId, RunId if ($Existing) { - $Undated = 0 $Orphans = foreach ($Row in @($Existing)) { if ($Row.RowKey -eq "$Type-Count") { continue } - if ($ClearOnEmpty.IsPresent) { - if (-not $SeenRowKeys.Contains($Row.RowKey)) { $Row } - continue - } - - $Stamp = $Row.Timestamp -as [datetimeoffset] - if ($null -eq $Stamp) { $Undated++; continue } - - if ($Stamp -lt $RunStartUtc) { $Row } - } - if ($Undated -gt 0) { - Write-LogMessage -API 'CIPPDbItem' -tenant $TenantFilter -sev Warning -message "Skipped $Undated $Type row(s) with no readable Timestamp during orphan cleanup — not deleting without positive evidence" + # Identity is the authority: a row written by this run always carries this + # run's id, no matter how long the run took, how the clocks drift, or what + # the query returned. Anything else - an earlier run's row, or a legacy row + # with no RunId at all - is an orphan by definition of an authoritative + # full-set write. + if ([string]$Row.RunId -ne $RunId) { $Row } } if ($Orphans) { $null = Remove-CIPPAzDataTableEntity @Table -Entity @($Orphans) -Force @@ -128,7 +149,6 @@ function Add-CIPPDbItem { } if ($Count.IsPresent -or $AddCount.IsPresent) { - $CntStart = $Stopwatch.ElapsedMilliseconds $NewCount = $TotalProcessed if ($Append.IsPresent) { $Filter = "PartitionKey eq '{0}' and RowKey eq '{1}-Count'" -f $TenantFilter, $Type @@ -141,7 +161,6 @@ function Add-CIPPDbItem { DataCount = [int]$NewCount Type = $Type } -Force - $CountMs = $Stopwatch.ElapsedMilliseconds - $CntStart } Write-LogMessage -API 'CIPPDbItem' -tenant $TenantFilter -message "Added $TotalProcessed items of type $Type" -sev Debug diff --git a/Modules/CIPPCore/Public/Clear-CippDurables.ps1 b/Modules/CIPPCore/Public/Clear-CippDurables.ps1 index 972f3d8fc853d..16c3911c67125 100644 --- a/Modules/CIPPCore/Public/Clear-CippDurables.ps1 +++ b/Modules/CIPPCore/Public/Clear-CippDurables.ps1 @@ -15,6 +15,14 @@ function Clear-CippDurables { Remove-AzDataTable @QueueTable Remove-AzDataTable @CippQueueTasks + # Drop these from the Get-CIPPTable cache so they get recreated on next use. + Unregister-CIPPTable -TableName @( + ('{0}Instances' -f $FunctionName) + ('{0}History' -f $FunctionName) + 'CippQueue' + 'CippQueueTasks' + ) + $Queues = Get-CIPPAzStorageQueue -Name ('{0}*' -f $FunctionName) $RunningQueues = $Queues | Where-Object { $_.ApproximateMessageCount -gt 0 } diff --git a/Modules/CIPPCore/Public/Compare-CIPPIntuneObject.ps1 b/Modules/CIPPCore/Public/Compare-CIPPIntuneObject.ps1 index 809df07d75081..33c61b457596d 100644 --- a/Modules/CIPPCore/Public/Compare-CIPPIntuneObject.ps1 +++ b/Modules/CIPPCore/Public/Compare-CIPPIntuneObject.ps1 @@ -12,6 +12,40 @@ function Compare-CIPPIntuneObject { [Parameter(Mandatory = $false)] [string[]]$CompareType = @() ) + + # Reusable settings carry a per-entry instance id that Intune mints on create, held in the + # child whose settingDefinitionId ends in '_id'. A template keeps the ids from the tenant it + # was captured in, so every entry differs on first read and the setting reports drift forever + # even when it deployed correctly. The exclusion list cannot express this: it matches property + # names, and this is a value keyed by a sibling settingDefinitionId. + if ($CompareType -contains 'ReusablePolicySetting') { + function Clear-ReusableInstanceId { + param($Node) + if ($null -eq $Node) { return } + if ($Node -is [System.Collections.IEnumerable] -and $Node -isnot [string]) { + foreach ($Item in $Node) { Clear-ReusableInstanceId -Node $Item } + return + } + if ($Node -isnot [psobject]) { return } + + foreach ($Child in @($Node.children)) { + if ($Child.settingDefinitionId -like '*_id' -and $Child.simpleSettingValue) { + $Child.simpleSettingValue.value = '' + } + } + foreach ($Prop in $Node.PSObject.Properties) { + if ($Prop.Name -eq 'children') { continue } + Clear-ReusableInstanceId -Node $Prop.Value + } + } + # Copy first: these objects belong to the caller, and the standard reuses the template body + # to build the remediation payload, where the real ids still matter. + $ReferenceObject = $ReferenceObject | ConvertTo-Json -Depth 100 -Compress | ConvertFrom-Json + $DifferenceObject = $DifferenceObject | ConvertTo-Json -Depth 100 -Compress | ConvertFrom-Json + Clear-ReusableInstanceId -Node $ReferenceObject + Clear-ReusableInstanceId -Node $DifferenceObject + } + if ($CompareType -notcontains 'Catalog') { # The exclusion list lives in Get-CIPPIntuneCompareExclusions - the baseline # engine's hard-gap pass consumes the SAME list so it never resurrects a diff --git a/Modules/CIPPCore/Public/Functions/Format-CIPPCAPolicy.ps1 b/Modules/CIPPCore/Public/Functions/Format-CIPPCAPolicy.ps1 new file mode 100644 index 0000000000000..f97189819906f --- /dev/null +++ b/Modules/CIPPCore/Public/Functions/Format-CIPPCAPolicy.ps1 @@ -0,0 +1,139 @@ +function Format-CIPPCAPolicy { + <# + .SYNOPSIS + Canonicalizes a conditional access policy body to full desired-state shape for a PATCH. + .DESCRIPTION + Deploying a CA template over an existing policy is a PATCH, and PATCH is a merge: anything + the body leaves out keeps whatever the tenant already had. Editors and older releases + stripped "empty" keys when saving, so a template that clears an assignment - excludeUsers, + includeGroups, platforms - could never say so, and the tenant policy never converged. + + This runs at the deploy/edit boundary and makes absence explicit, healing already-stored + templates without a re-save. Two phases: + + 1. Expand - every managed key the body omits is added back as its cleared form: [] for + assignment collections, null for the condition blocks Graph models as objects. A child + is only expanded when its parent exists, and clientAppTypes is deliberately skipped - + Graph requires it non-empty, so absence there stays a merge rather than a broken clear. + grantControls/sessionControls follow an at-least-one rule: the missing one is added as + null only while the other has a value, since Graph requires a policy to have one. + + 2. Collapse - the handful of condition containers Graph refuses outright when their + required "include" collection is empty (platforms, locations, devices, + clientApplications, the guest/external user blocks, grantControls) become $null rather + than being removed - null is accepted on a create and still clears on an update. Empty + assignment arrays are deliberately KEPT: an explicit "includeGroups": [] is the only + thing that strips a group off a policy that already has one. + .PARAMETER Policy + The parsed CA policy object. Mutated in place. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] + $Policy + ) + + function Test-CIPPCAHasContent { + param($Value) + if ($null -eq $Value) { return $false } + if ($Value -is [string]) { return -not [string]::IsNullOrWhiteSpace($Value) } + if ($Value -is [bool]) { return $Value } + if ($Value -is [Array] -or $Value -is [System.Collections.IList]) { + foreach ($Item in $Value) { + if (Test-CIPPCAHasContent -Value $Item) { return $true } + } + return $false + } + if ($Value -is [PSCustomObject]) { + foreach ($Property in $Value.PSObject.Properties) { + # An @odata.type on its own describes an otherwise empty block, it is not content. + if ($Property.Name -like '*@odata*') { continue } + if (Test-CIPPCAHasContent -Value $Property.Value) { return $true } + } + return $false + } + return $true + } + + function Add-CIPPCAClearedKey { + param($Parent, [string[]]$Collections, [string[]]$NullBlocks) + if ($null -eq $Parent -or $Parent -isnot [PSCustomObject]) { return } + foreach ($Name in $Collections) { + if ($Parent.PSObject.Properties.Name -notcontains $Name -or $null -eq $Parent.$Name) { + $Parent | Add-Member -NotePropertyName $Name -NotePropertyValue @() -Force + } elseif (-not (Test-CIPPCAHasContent -Value $Parent.$Name)) { + # Whitespace-only entries select nothing; normalise them to a clean clear. + $Parent.$Name = @() + } + } + foreach ($Name in $NullBlocks) { + if ($Parent.PSObject.Properties.Name -notcontains $Name) { + $Parent | Add-Member -NotePropertyName $Name -NotePropertyValue $null -Force + } + } + } + + function Clear-CIPPCAContainer { + param($Parent, [string]$Name, [string[]]$RequiredAnyOf) + if ($null -eq $Parent -or $Parent -isnot [PSCustomObject]) { return } + if ($Parent.PSObject.Properties.Name -notcontains $Name) { return } + if ($null -eq $Parent.$Name) { return } + foreach ($Required in $RequiredAnyOf) { + if (Test-CIPPCAHasContent -Value $Parent.$Name.$Required) { return } + } + $Parent.$Name = $null + } + + # --- Phase 1: expand ------------------------------------------------------------------------ + # conditions / users / applications are required by Graph and never invented or nulled here; + # their children are only expanded when the parent is actually present. + $Conditions = $Policy.conditions + if ($Conditions -is [PSCustomObject]) { + Add-CIPPCAClearedKey -Parent $Conditions ` + -Collections @('signInRiskLevels', 'userRiskLevels', 'servicePrincipalRiskLevels') ` + -NullBlocks @('platforms', 'locations', 'devices', 'clientApplications', 'authenticationFlows', 'insiderRiskLevels') + Add-CIPPCAClearedKey -Parent $Conditions.users ` + -Collections @('includeUsers', 'excludeUsers', 'includeGroups', 'excludeGroups', 'includeRoles', 'excludeRoles') ` + -NullBlocks @('includeGuestsOrExternalUsers', 'excludeGuestsOrExternalUsers') + Add-CIPPCAClearedKey -Parent $Conditions.applications ` + -Collections @('includeApplications', 'excludeApplications', 'includeUserActions', 'includeAuthenticationContextClassReferences') ` + -NullBlocks @('applicationFilter') + Add-CIPPCAClearedKey -Parent $Conditions.devices -Collections @() -NullBlocks @('deviceFilter') + Add-CIPPCAClearedKey -Parent $Conditions.clientApplications -Collections @() -NullBlocks @('servicePrincipalFilter') + } + # A policy must carry grantControls or sessionControls - say the missing one's absence out + # loud (as null) only while the other still has a value, and never null both. + foreach ($Control in 'grantControls', 'sessionControls') { + if ($Policy.PSObject.Properties.Name -contains $Control) { continue } + $Other = if ($Control -eq 'grantControls') { 'sessionControls' } else { 'grantControls' } + if ($Policy.PSObject.Properties.Name -contains $Other -and $null -ne $Policy.$Other) { + $Policy | Add-Member -NotePropertyName $Control -NotePropertyValue $null -Force + } + } + + # --- Phase 2: collapse ---------------------------------------------------------------------- + # Filters first: a filter carrying a mode but no rule selects nothing and Graph rejects it, and + # clearing it before the parent lets an otherwise-empty devices/clientApplications block collapse + # too rather than surviving on the strength of a filter that does nothing. + Clear-CIPPCAContainer -Parent $Policy.conditions.applications -Name 'applicationFilter' -RequiredAnyOf 'rule' + Clear-CIPPCAContainer -Parent $Policy.conditions.devices -Name 'deviceFilter' -RequiredAnyOf 'rule' + Clear-CIPPCAContainer -Parent $Policy.conditions.clientApplications -Name 'servicePrincipalFilter' -RequiredAnyOf 'rule' + + Clear-CIPPCAContainer -Parent $Policy.conditions -Name 'platforms' -RequiredAnyOf 'includePlatforms' + Clear-CIPPCAContainer -Parent $Policy.conditions -Name 'locations' -RequiredAnyOf 'includeLocations' + Clear-CIPPCAContainer -Parent $Policy.conditions -Name 'devices' -RequiredAnyOf 'deviceFilter', 'includeDevices' + Clear-CIPPCAContainer -Parent $Policy.conditions -Name 'clientApplications' -RequiredAnyOf 'includeServicePrincipals' + Clear-CIPPCAContainer -Parent $Policy.conditions.users -Name 'includeGuestsOrExternalUsers' -RequiredAnyOf 'guestOrExternalUserTypes' + Clear-CIPPCAContainer -Parent $Policy.conditions.users -Name 'excludeGuestsOrExternalUsers' -RequiredAnyOf 'guestOrExternalUserTypes' + Clear-CIPPCAContainer -Parent $Policy -Name 'grantControls' -RequiredAnyOf 'builtInControls', 'customAuthenticationFactors', 'termsOfUse', 'authenticationStrength' + + # sessionControls carries no required member, so it only has to survive as *something* + # addressable - an empty object would be dropped from the body by ConvertTo-Json's caller. + if ($Policy.PSObject.Properties.Name -contains 'sessionControls' -and $null -ne $Policy.sessionControls) { + if (@($Policy.sessionControls.PSObject.Properties).Count -eq 0) { + $Policy.sessionControls = $null + } + } +} diff --git a/Modules/CIPPCore/Public/Functions/Remove-EmptyArrays.ps1 b/Modules/CIPPCore/Public/Functions/Remove-EmptyArrays.ps1 deleted file mode 100644 index fd46b76e72b59..0000000000000 --- a/Modules/CIPPCore/Public/Functions/Remove-EmptyArrays.ps1 +++ /dev/null @@ -1,19 +0,0 @@ -function Remove-EmptyArrays ($Object) { - if ($Object -is [Array]) { - foreach ($Item in $Object) { Remove-EmptyArrays $Item } - } elseif ($Object -is [HashTable]) { - foreach ($Key in @($Object.get_Keys())) { - if ($Object[$Key] -is [Array] -and $Object[$Key].get_Count() -eq 0) { - $Object.Remove($Key) - } else { Remove-EmptyArrays $Object[$Key] } - } - } elseif ($Object -is [PSCustomObject]) { - foreach ($Name in @($Object.PSObject.Properties.Name)) { - if ($Object.$Name -is [Array] -and $Object.$Name.get_Count() -eq 0) { - $Object.PSObject.Properties.Remove($Name) - } elseif ($null -eq $Object.$Name) { - $Object.PSObject.Properties.Remove($Name) - } else { Remove-EmptyArrays $Object.$Name } - } - } -} diff --git a/Modules/CIPPCore/Public/Get-CIPPAzDatatableEntity.ps1 b/Modules/CIPPCore/Public/Get-CIPPAzDatatableEntity.ps1 index 2adbd7f88032e..f932f73db4df5 100644 --- a/Modules/CIPPCore/Public/Get-CIPPAzDatatableEntity.ps1 +++ b/Modules/CIPPCore/Public/Get-CIPPAzDatatableEntity.ps1 @@ -9,8 +9,9 @@ function Get-CIPPAzDataTableEntity { natively merges rows that were split across multiple properties or rows because they exceeded the table service size limits. - Kept as a wrapper for backward compatibility with existing call sites and to - default MaxRetries to 3 for throttled requests. + Kept as a wrapper for backward compatibility with existing call sites, to + default MaxRetries to 3 for throttled requests, and to record entities the + module could not reassemble. #> [CmdletBinding()] param( @@ -24,6 +25,42 @@ function Get-CIPPAzDataTableEntity { [int]$MaxRetries = 3 ) - $PSBoundParameters['MaxRetries'] = $MaxRetries - Get-AzDataTableLargeEntity @PSBoundParameters + # ErrorAction and ErrorVariable are set below, so a caller that passed its own would collide + # with the splat. Real errors are re-emitted afterwards, which honours the caller's preference. + $Parameters = @{} + $PSBoundParameters + $Parameters['MaxRetries'] = $MaxRetries + $null = $Parameters.Remove('ErrorAction') + $null = $Parameters.Remove('ErrorVariable') + + $Results = Get-AzDataTableLargeEntity @Parameters -ErrorAction SilentlyContinue -ErrorVariable TableErrors + + foreach ($TableError in $TableErrors) { + # An entity whose rows cannot be reassembled is skipped by the module and reported without + # failing the query, so one row orphaned by a pre-part-aware delete cannot empty a whole + # partition. Record which row so it can be removed; pass everything else through. + if ($TableError.FullyQualifiedErrorId -notlike 'IncompleteEntity*') { + Write-Error -ErrorRecord $TableError + continue + } + + if (-not $script:ReportedIncompleteEntities) { + $script:ReportedIncompleteEntities = [System.Collections.Generic.HashSet[string]]::new() + } + + # Write-LogMessage reads a table itself, so logging here re-enters this function. The guard + # stops that recursing, and the set reports each corrupt row once rather than on every read. + $RowIdentity = "$($TableError.TargetObject)" + if ($script:ReportingIncompleteEntity -or -not $script:ReportedIncompleteEntities.Add($RowIdentity)) { + continue + } + + $script:ReportingIncompleteEntity = $true + try { + Write-LogMessage -API 'Table' -message "Skipped a corrupt table entity. $($TableError.Exception.Message) Delete the orphaned '-partN' rows for '$RowIdentity' to clear this." -Sev 'Error' + } finally { + $script:ReportingIncompleteEntity = $false + } + } + + $Results } diff --git a/Modules/CIPPCore/Public/Get-CIPPCVEReport.ps1 b/Modules/CIPPCore/Public/Get-CIPPCVEReport.ps1 index 5cc40daccbe68..f995a1f470134 100644 --- a/Modules/CIPPCore/Public/Get-CIPPCVEReport.ps1 +++ b/Modules/CIPPCore/Public/Get-CIPPCVEReport.ps1 @@ -4,8 +4,15 @@ function Get-CIPPCVEReport { Generates a CVE report from the CIPP Reporting database .DESCRIPTION - Retrieves Defender CVE data for a tenant from the reporting database - Optimized for high-performance cross-referencing and memory efficiency. + Retrieves Defender CVE data for a tenant from the reporting database. + + Rows are folded one at a time: each row's Data blob is parsed, tenant-validated + and merged into the master table before the next row is touched, so the parsed + PSCustomObject graphs - the most expensive representation of the dataset - never + all exist at once. This previously materialised every cached row, a parsed copy + of every Data blob and a third list of references before aggregation began, + which for AllTenants is the entire CVE cache held three ways on the HTTP worker + pool's shared heap. .PARAMETER TenantFilter The tenant to generate the report for, or 'AllTenants' @@ -20,56 +27,33 @@ function Get-CIPPCVEReport { # Retrieve Exceptions from Exception database $CveExceptionsTable = Get-CIPPTable -TableName 'CveExceptions' $AllExceptions = Get-CIPPAzDataTableEntity @CveExceptionsTable - $ExceptionsByCve = @{} - - $RawCveData = Get-CIPPDbItem -TenantFilter $TenantFilter -Type 'DefenderCVEs' | Where-Object { $_.RowKey -ne 'DefenderCVEs-Count' } - $AllCachedCves = $RawCveData.Data | ConvertFrom-Json - - # Filter results by Tenant - $RawCveItems = [System.Collections.Generic.List[object]]::new() + # AllTenants rows are validated against the active tenant list so orphaned data is + # never returned. A HashSet turns that from a scan of the tenant list per row into + # a single lookup. Single-tenant reads are already partition-filtered by + # Get-CIPPDbItem, so no per-row validation is needed there. + $ActiveDomains = $null if ($TenantFilter -eq 'AllTenants') { - # Validate against active tenants to ensure we don't return orphaned data $TenantList = Get-Tenants -IncludeErrors - foreach ($Item in $AllCachedCves) { - if ($TenantList.defaultDomainName -contains $Item.customerId) { - [void]$RawCveItems.Add($Item) - } - } - } - else { + $ActiveDomains = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($Tenant in $TenantList) { [void]$ActiveDomains.Add([string]$Tenant.defaultDomainName) } + } else { $TenantList = Get-Tenants -TenantFilter $TenantFilter - $RawCveItems.AddRange(@($AllCachedCves)) - } - - if ($RawCveItems.Count -eq 0) { - return @() - } - - # Build filtered exception items - foreach ($Ex in $AllExceptions) { - if ($TenantList.defaultDomainName -contains $Ex.customerId -or $Ex.customerId -eq 'ALL') { - if (-not $ExceptionsByCve.ContainsKey($Ex.cveId)) { - $ExceptionsByCve[$Ex.cveId] = [System.Collections.Generic.List[object]]::new() - } - - [void]$ExceptionsByCve[$Ex.cveId].Add([PSCustomObject]@{ - cveId = $Ex.cveId - customerId = $Ex.customerId - exceptionType = $Ex.exceptionType - exceptionSource = $Ex.exceptionSource - exceptionComment = $Ex.exceptionComment - exceptionCreatedBy = $Ex.exceptionCreatedBy - exceptionDate = $Ex.exceptionReadableDate - exceptionExpiry = $Ex.exceptionExpiry - }) - } } # Process raw CVE items $CveMasterTable = @{} + $RowCount = 0 + + foreach ($Row in Get-CIPPDbItem -TenantFilter $TenantFilter -Type 'DefenderCVEs') { + if ($Row.RowKey -eq 'DefenderCVEs-Count' -or -not $Row.Data) { continue } - foreach ($Item in $RawCveItems) { + $Item = $Row.Data | ConvertFrom-Json + if ($ActiveDomains -and -not $ActiveDomains.Contains([string]$Item.customerId)) { continue } + $RowCount++ + + # The Data blob carries the CVE id as its PartitionKey - the row's own + # PartitionKey is the tenant. $CveId = $Item.PartitionKey if (-not $CveMasterTable.ContainsKey($CveId)) { @@ -117,6 +101,33 @@ function Get-CIPPCVEReport { } } + if ($RowCount -eq 0) { + return @() + } + + # Build filtered exception items + $ExceptionsByCve = @{} + + foreach ($Ex in $AllExceptions) { + $InScope = if ($ActiveDomains) { $ActiveDomains.Contains([string]$Ex.customerId) } else { $TenantList.defaultDomainName -contains $Ex.customerId } + if ($InScope -or $Ex.customerId -eq 'ALL') { + if (-not $ExceptionsByCve.ContainsKey($Ex.cveId)) { + $ExceptionsByCve[$Ex.cveId] = [System.Collections.Generic.List[object]]::new() + } + + [void]$ExceptionsByCve[$Ex.cveId].Add([PSCustomObject]@{ + cveId = $Ex.cveId + customerId = $Ex.customerId + exceptionType = $Ex.exceptionType + exceptionSource = $Ex.exceptionSource + exceptionComment = $Ex.exceptionComment + exceptionCreatedBy = $Ex.exceptionCreatedBy + exceptionDate = $Ex.exceptionReadableDate + exceptionExpiry = $Ex.exceptionExpiry + }) + } + } + # Combine filtered results $SortedCves = [System.Collections.Generic.List[PSCustomObject]]::new() diff --git a/Modules/CIPPCore/Public/Get-CIPPIntuneDefinitionIndex.ps1 b/Modules/CIPPCore/Public/Get-CIPPIntuneDefinitionIndex.ps1 index d2d1b84dcd734..9800e063d020a 100644 --- a/Modules/CIPPCore/Public/Get-CIPPIntuneDefinitionIndex.ps1 +++ b/Modules/CIPPCore/Public/Get-CIPPIntuneDefinitionIndex.ps1 @@ -33,9 +33,9 @@ function Get-CIPPIntuneDefinitionIndex { [CmdletBinding()] param() - # Interpolated rather than Join-Path deliberately - this is the path expression the collection - # has always been read with, and path handling differs between the dev and container hosts. - $Path = "$env:CIPPRootPath\Config\intuneCollection.json" + # Join-Path, not an interpolated backslash: Get-Item normalises '\' on Linux but + # [System.IO.File] does not, so the read below threw and the index never loaded there. + $Path = Join-Path $env:CIPPRootPath 'Config/intuneCollection.json' # An index we already hold is always better than none. If the collection cannot be read or # parsed right now, keep serving the cached one rather than degrading every comparison to raw diff --git a/Modules/CIPPCore/Public/Get-CIPPSPOTenant.ps1 b/Modules/CIPPCore/Public/Get-CIPPSPOTenant.ps1 index c31d0457fe657..bc42246db9842 100644 --- a/Modules/CIPPCore/Public/Get-CIPPSPOTenant.ps1 +++ b/Modules/CIPPCore/Public/Get-CIPPSPOTenant.ps1 @@ -4,6 +4,9 @@ function Get-CIPPSPOTenant { [Parameter(Mandatory = $true)] [string]$TenantFilter, [string]$SharepointPrefix, + # Only meaningful alongside SharepointPrefix. Sovereign clouds are not on sharepoint.com + # (see Get-SharePointAdminLink), so a prefix on its own cannot build the admin URL. + [string]$SharepointDomain = 'sharepoint.com', [switch]$SkipCache ) @@ -12,9 +15,10 @@ function Get-CIPPSPOTenant { $SharePointInfo = Get-SharePointAdminLink -Public $false -tenantFilter $TenantFilter $tenantName = $SharePointInfo.TenantName $AdminUrl = $SharePointInfo.AdminUrl + $SharepointDomain = $SharePointInfo.SharePointDomain } else { $tenantName = $SharepointPrefix - $AdminUrl = "https://$($tenantName)-admin.sharepoint.com" + $AdminUrl = "https://$($tenantName)-admin.$SharepointDomain" } $Table = Get-CIPPTable -tablename 'cachespotenant' @@ -24,7 +28,12 @@ function Get-CIPPSPOTenant { $CachedTenant = Get-CIPPAzDataTableEntity @Table -Filter $Filter if ($CachedTenant -and (Test-Json $CachedTenant.JSON)) { $Results = $CachedTenant.JSON | ConvertFrom-Json - return $Results + # Rows written before SharepointDomain existed carry only the prefix, and everything + # downstream (Set-CIPPSPOTenant via the pipeline) would rebuild a sharepoint.com URL + # from it - wrong on sovereign clouds. Treat those rows as stale and re-resolve. + if ($Results.PSObject.Properties.Name -contains 'SharepointDomain') { + return $Results + } } } @@ -36,9 +45,12 @@ function Get-CIPPSPOTenant { 'Accept' = 'application/json;odata=verbose' } - $Results = New-GraphPostRequest -scope "$($AdminUrl)/.default" -tenantid $TenantFilter -Uri "$($SharePointInfo.AdminUrl)/_vti_bin/client.svc/ProcessQuery" -Type POST -Body $XML -ContentType 'text/xml' -AddedHeaders $AdditionalHeaders + # $AdminUrl, not $SharePointInfo.AdminUrl - the latter is empty when a prefix was supplied. + $Results = New-GraphPostRequest -scope "$($AdminUrl)/.default" -tenantid $TenantFilter -Uri "$($AdminUrl)/_vti_bin/client.svc/ProcessQuery" -Type POST -Body $XML -ContentType 'text/xml' -AddedHeaders $AdditionalHeaders - $Results = $Results | Select-Object -Last 1 *, @{n = 'SharepointPrefix'; e = { $tenantName } }, @{n = 'TenantFilter'; e = { $TenantFilter } } + # SharepointDomain rides along with the prefix so Set-CIPPSPOTenant can rebuild the same + # admin URL from the pipeline (and from this cache row) without assuming .com. + $Results = $Results | Select-Object -Last 1 *, @{n = 'SharepointPrefix'; e = { $tenantName } }, @{n = 'SharepointDomain'; e = { $SharepointDomain } }, @{n = 'TenantFilter'; e = { $TenantFilter } } # Cache result $Entity = @{ diff --git a/Modules/CIPPCore/Public/Get-CIPPSecureScoreReport.ps1 b/Modules/CIPPCore/Public/Get-CIPPSecureScoreReport.ps1 index fc212c145a6c2..27e102d1292a2 100644 --- a/Modules/CIPPCore/Public/Get-CIPPSecureScoreReport.ps1 +++ b/Modules/CIPPCore/Public/Get-CIPPSecureScoreReport.ps1 @@ -59,6 +59,12 @@ function Get-CIPPSecureScoreReport { } } + # Drop partitions for tenants we no longer manage. The allTenants read is deliberately + # unfiltered and cached rows outlive an excluded tenant, so without this they keep showing + # up in the estate-wide view. Every other AllTenants report filters the same way. + $Rows = @($Rows | Where-Object { $TenantLookup.ContainsKey([string]$_.PartitionKey) }) + if ($Rows.Count -eq 0) { return @() } + # Only these three fields are ever materialized; controlScores and friends are skipped by the parser. $Projection = [string[]]@('currentScore', 'maxScore', 'createdDateTime') diff --git a/Modules/CIPPCore/Public/Get-CIPPTestResultsTenants.ps1 b/Modules/CIPPCore/Public/Get-CIPPTestResultsTenants.ps1 index 98823f972dc44..9c1c267925ff7 100644 --- a/Modules/CIPPCore/Public/Get-CIPPTestResultsTenants.ps1 +++ b/Modules/CIPPCore/Public/Get-CIPPTestResultsTenants.ps1 @@ -48,6 +48,18 @@ function Get-CIPPTestResultsTenants { over everything the scan matched — regardless of RowStatus. Changes the return shape to @{ Results; Counts }. + Counts also carries HighRiskTenants (distinct tenants with a high-risk failure) and + ByTestType — per test type, the failure count, the distinct tenants failing it, and the + checks failing across the most tenants. Those facets are computed over Failed rows only, + which is what "failing" means for every caller that has asked for them. + + .PARAMETER CountsOnly + Return the aggregates with an empty Results array, and skip building rows entirely. Implies + -IncludeCounts and the -SummaryOnly projection. + + For callers that render totals only. Returning the rows to aggregate client-side sends the + whole estate's result set over the wire, and grows linearly with tenant count. + .PARAMETER AllowedTenantIds Customer ids the caller may see. When supplied, rows for other tenants are dropped before any counting or row building, so counts never leak the size of an estate the caller cannot @@ -85,10 +97,21 @@ function Get-CIPPTestResultsTenants { [Parameter(Mandatory = $false)] [switch]$IncludeCounts, + [Parameter(Mandatory = $false)] + [switch]$CountsOnly, + [Parameter(Mandatory = $false)] [string[]]$AllowedTenantIds ) + # CountsOnly is a stricter IncludeCounts: same aggregates, no rows built at all. + $WantCounts = $IncludeCounts.IsPresent -or $CountsOnly.IsPresent + # Counting only ever reads Status/Risk/Name/TestType, so the blob columns can always be + # projected away for a counts-only read even when the caller did not ask for SummaryOnly. + $ProjectColumns = $SummaryOnly.IsPresent -or $CountsOnly.IsPresent + # How many per-test-type checks to return in the ByTestType facet. + $TopCheckLimit = 10 + $Table = Get-CippTable -tablename 'CippTestResults' # Map tenant domain (PartitionKey) -> tenant identity, used for display, access control, and — @@ -142,7 +165,7 @@ function Get-CIPPTestResultsTenants { $PropertyFilter = $FilterParts -join ' and ' $GetParams = @{} - if ($SummaryOnly) { + if ($ProjectColumns) { $GetParams.Property = [string[]]@( 'PartitionKey', 'RowKey', 'Timestamp', 'Status', 'Risk', 'Name', 'Pillar', 'UserImpact', 'ImplementationEffort', 'Category', 'TestType' @@ -168,10 +191,12 @@ function Get-CIPPTestResultsTenants { HighRiskFailed = 0 TenantsWithResults = 0 TenantsFailing = 0 + HighRiskTenants = 0 + ByTestType = [PSCustomObject]@{} } if ($Results.Count -eq 0) { - if ($IncludeCounts) { return [PSCustomObject]@{ Results = @(); Counts = [PSCustomObject]$Counts } } + if ($WantCounts) { return [PSCustomObject]@{ Results = @(); Counts = [PSCustomObject]$Counts } } return @() } @@ -221,6 +246,10 @@ function Get-CIPPTestResultsTenants { $TenantsSeen = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) $TenantsFailing = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + $HighRiskTenants = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + # testType -> @{ Failed; Tenants (set); Checks (name -> set of tenants) }. Tenant sets rather + # than counters because the same check failing twice for one tenant is still one tenant. + $FailedByType = @{} # Single pass: count, then build only the rows the caller asked for. Access control already # happened at the partition level. Rows are rebuilt as fresh objects rather than mutated with @@ -239,7 +268,32 @@ function Get-CIPPTestResultsTenants { 'Failed' { $Counts['Failed']++ [void]$TenantsFailing.Add($Result.PartitionKey) - if ([string]$Result.Risk -eq 'High') { $Counts['HighRiskFailed']++ } + if ([string]$Result.Risk -eq 'High') { + $Counts['HighRiskFailed']++ + [void]$HighRiskTenants.Add($Result.PartitionKey) + } + + $TypeKey = [string]$Result.TestType + if ($TypeKey) { + if (-not $FailedByType.ContainsKey($TypeKey)) { + $FailedByType[$TypeKey] = @{ + Failed = 0 + Tenants = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + Checks = @{} + } + } + $Bucket = $FailedByType[$TypeKey] + $Bucket.Failed++ + [void]$Bucket.Tenants.Add($Result.PartitionKey) + + $CheckName = [string]$Result.Name + if ($CheckName) { + if (-not $Bucket.Checks.ContainsKey($CheckName)) { + $Bucket.Checks[$CheckName] = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + } + [void]$Bucket.Checks[$CheckName].Add($Result.PartitionKey) + } + } } 'Investigate' { $Counts['Investigate']++ } 'Skipped' { $Counts['Skipped']++ } @@ -247,6 +301,8 @@ function Get-CIPPTestResultsTenants { } if ($RowStatusSet -and -not $RowStatusSet.Contains($StatusValue)) { continue } + # Counting is done for this row; a counts-only caller wants none of the row building below. + if ($CountsOnly) { continue } $Row = [ordered]@{} foreach ($Prop in $Result.PSObject.Properties) { $Row[$Prop.Name] = $Prop.Value } @@ -294,11 +350,31 @@ function Get-CIPPTestResultsTenants { $Output.Add([PSCustomObject]$Row) } - if ($IncludeCounts) { + if ($WantCounts) { $Counts['TenantsWithResults'] = $TenantsSeen.Count $Counts['TenantsFailing'] = $TenantsFailing.Count + $Counts['HighRiskTenants'] = $HighRiskTenants.Count + + $ByTestType = [ordered]@{} + foreach ($TypeKey in ($FailedByType.Keys | Sort-Object)) { + $Bucket = $FailedByType[$TypeKey] + # Ties broken by name so the list is stable between calls with identical data. + $TopChecks = @( + $Bucket.Checks.GetEnumerator() | + Sort-Object -Property @{ Expression = { $_.Value.Count }; Descending = $true }, Key | + Select-Object -First $TopCheckLimit | + ForEach-Object { [PSCustomObject]@{ Name = $_.Key; TenantCount = $_.Value.Count } } + ) + $ByTestType[$TypeKey] = [PSCustomObject]@{ + Failed = $Bucket.Failed + Tenants = $Bucket.Tenants.Count + TopChecks = $TopChecks + } + } + $Counts['ByTestType'] = [PSCustomObject]$ByTestType + return [PSCustomObject]@{ - Results = $Output + Results = if ($CountsOnly) { @() } else { $Output } Counts = [PSCustomObject]$Counts } } diff --git a/Modules/CIPPCore/Public/Get-DefenderCves.ps1 b/Modules/CIPPCore/Public/Get-DefenderCves.ps1 index cf95befa60779..50b42f0e0960e 100644 --- a/Modules/CIPPCore/Public/Get-DefenderCves.ps1 +++ b/Modules/CIPPCore/Public/Get-DefenderCves.ps1 @@ -9,17 +9,20 @@ function get-DefenderCVEs { (Invoke-ListCVEManagement), so it shares the container's managed heap with the background pool and an OOM here takes user-facing requests down with it. - Raw records are therefore folded into per-CVE buckets as they arrive off the wire. - The ConvertFrom-Json PSCustomObject graph is by far the most expensive of the - representations this function would otherwise hold at once - roughly 6.5 KB of a - ~10 KB per-record peak - and streaming makes it collectable page by page instead of - keeping every record alive until the return. Each bucket is then dropped as soon as - its row is built, so the aggregator and the returned entity list never both hold the - whole tenant. - - Unlike Set-CIPPDBCacheDefenderCVEs the emit stage cannot stream into a writer: the - caller needs the full set for a single HTTP response. Peak is therefore the entity - list plus one page, rather than raw records + aggregator + entity list at once. + Both stages stream, mirroring Set-CIPPDBCacheDefenderCVEs: + + 1. Raw records are folded into per-CVE buckets as they arrive off the wire, with + each device's metadata serialised immediately to the JSON text it will be + returned as. The ConvertFrom-Json PSCustomObject graph - by far the most + expensive of the representations this function would otherwise hold, roughly + 6.5 KB of a ~10 KB per-record peak - is collectable page by page, and the + aggregator holds one string per CVE instead of a hashtable per record. + 2. Rows are emitted straight to the pipeline as they are built. The caller folds + them one at a time, and each bucket is dropped as soon as its row is emitted, + so the aggregator and the emitted row set never both hold the whole tenant. + + Peak is therefore the aggregated JSON text plus one page, rather than raw records + + aggregator + a fully materialised entity list all at once. .PARAMETER TenantFilter The tenant to retrieve vulnerabilities for @@ -55,22 +58,34 @@ function get-DefenderCVEs { recommendedSecurityUpdateUrl = $Vuln.recommendedSecurityUpdateUrl ?? '' exploitabilityLevel = $Vuln.exploitabilityLevel ?? '' - # Arrays to collect device metadata efficiently - AffectedDevices = [System.Collections.Generic.List[object]]::new() + # Device metadata as the JSON text it will be returned as, not as objects. + DeviceJson = [System.Text.StringBuilder]::new() + DeviceCount = 0 } } - # Extract properties specific to this device instance and append in one - # step, so a record that fails mid-extraction cannot leave a previous - # record's payload behind to be appended to the wrong CVE. - [void]$CveAggregator[$CveId].AffectedDevices.Add(@{ - deviceId = ($Vuln.deviceId -join ',') ?? '' - deviceName = ($Vuln.deviceName -join ',') ?? '' - osVersion = $Vuln.osVersion ?? '' - softwareVersion = ($Vuln.softwareVersion -join ',') ?? '' - diskPaths = if ($Vuln.diskPaths) { $Vuln.diskPaths -join ';' } else { '' } - registryPaths = if ($Vuln.registryPaths) { $Vuln.registryPaths -join ';' } else { '' } - }) + # Extract this device instance and fold it in as serialised text immediately - + # see Set-CIPPDBCacheDefenderCVEs for the full rationale. Keeping one hashtable + # per record alive until the stream ends is the single largest thing this + # function would otherwise retain, and this path runs per user request. + # + # ConvertTo-Json builds the fragment rather than string interpolation, so + # escaping of device names and registry paths stays correct. + $Fragment = @{ + deviceId = ($Vuln.deviceId -join ',') ?? '' + deviceName = ($Vuln.deviceName -join ',') ?? '' + osVersion = $Vuln.osVersion ?? '' + softwareVersion = ($Vuln.softwareVersion -join ',') ?? '' + diskPaths = if ($Vuln.diskPaths) { $Vuln.diskPaths -join ';' } else { '' } + registryPaths = if ($Vuln.registryPaths) { $Vuln.registryPaths -join ';' } else { '' } + } | ConvertTo-Json -Compress + + # Appended only after the fragment is fully built, so a record that fails + # mid-extraction cannot leave a partial payload attached to the wrong CVE. + $Bucket = $CveAggregator[$CveId] + if ($Bucket.DeviceCount -gt 0) { [void]$Bucket.DeviceJson.Append(',') } + [void]$Bucket.DeviceJson.Append($Fragment) + $Bucket.DeviceCount++ } catch { $SkippedCount++ $ErrorMessage = Get-CippException -Exception $_ @@ -99,7 +114,9 @@ function get-DefenderCVEs { # single cacheTimeStamp per CVE. $LastUpdated = [string]$(Get-Date (Get-Date).ToUniversalTime() -UFormat '+%Y-%m-%dT%H:%M:%S.000Z') - $Entities = [System.Collections.Generic.List[object]]::new() + # One row per bucket, so this is the unique CVE count without a second pass. Logged + # before the emit loop because the buckets are consumed as rows go out. + Write-LogMessage -API 'DefenderCVEs' -tenant $TenantFilter -message "Retrieved $($CveAggregator.Count) Unique CVEs" -sev 'Info' # Snapshot the keys so buckets can be dropped while iterating - enumerating # $CveAggregator.Keys directly and removing from it throws InvalidOperationException. @@ -108,41 +125,44 @@ function get-DefenderCVEs { foreach ($CveKey in $CveKeys) { $CveData = $CveAggregator[$CveKey] - # Flatten or convert device info arrays into a compact, compressed JSON string. - # Piped (not -InputObject) so a single-device CVE serialises to an object and a - # multi-device CVE to an array, exactly as before. - $CompactDeviceJson = $CveData.AffectedDevices | ConvertTo-Json -Compress - - [void]$Entities.Add(@{ - PartitionKey = $CveKey - RowKey = $TenantFilter # RowKey becomes just the Tenant, ensuring 1 row per CVE per Tenant - customerId = $TenantFilter - cveId = $CveKey - softwareVendor = $CveData.softwareVendor - softwareName = $CveData.softwareName - vulnerabilitySeverityLevel = $CveData.vulnerabilitySeverityLevel - recommendedSecurityUpdate = $CveData.recommendedSecurityUpdate - recommendedSecurityUpdateUrl = $CveData.recommendedSecurityUpdateUrl - exploitabilityLevel = $CveData.exploitabilityLevel - - # Meta aggregation counts - deviceCount = $CveData.AffectedDevices.Count - - # All individual device variations compressed safely inside a single field - deviceDetailsJson = $CompactDeviceJson - - lastUpdated = $LastUpdated - }) - - # The row is built; drop the bucket so its device list is collectable while the - # rest of the set is still being serialised. - $CveAggregator.Remove($CveKey) - } + # The fragments are already JSON; only the surrounding shape is decided here. + # A single-device CVE stays a bare object and a multi-device CVE becomes an + # array, which is what piping a List through ConvertTo-Json used to produce and + # what Invoke-ListCVEManagement parses. + $CompactDeviceJson = if ($CveData.DeviceCount -eq 1) { + $CveData.DeviceJson.ToString() + } else { + [void]$CveData.DeviceJson.Insert(0, '[').Append(']') + $CveData.DeviceJson.ToString() + } - # One row per bucket, so this is the unique CVE count without a second pass. - Write-LogMessage -API 'DefenderCVEs' -tenant $TenantFilter -message "Retrieved $($Entities.Count) Unique CVEs" -sev 'Info' + # Emitted straight to the pipeline: the caller merges each row as it arrives, + # so no entity list ever materialises here. + @{ + PartitionKey = $CveKey + RowKey = $TenantFilter # RowKey becomes just the Tenant, ensuring 1 row per CVE per Tenant + customerId = $TenantFilter + cveId = $CveKey + softwareVendor = $CveData.softwareVendor + softwareName = $CveData.softwareName + vulnerabilitySeverityLevel = $CveData.vulnerabilitySeverityLevel + recommendedSecurityUpdate = $CveData.recommendedSecurityUpdate + recommendedSecurityUpdateUrl = $CveData.recommendedSecurityUpdateUrl + exploitabilityLevel = $CveData.exploitabilityLevel + + # Meta aggregation counts + deviceCount = $CveData.DeviceCount + + # All individual device variations compressed safely inside a single field + deviceDetailsJson = $CompactDeviceJson + + lastUpdated = $LastUpdated + } - return $Entities + # The row is emitted; drop the bucket so its device JSON is collectable once + # the caller has folded the row. + $CveAggregator.Remove($CveKey) + } } catch { $ErrorMessage = Get-CippException -Exception $_ diff --git a/Modules/CIPPCore/Public/Get-DefenderTvmRaw.ps1 b/Modules/CIPPCore/Public/Get-DefenderTvmRaw.ps1 index d16a51bb1e48d..01ae9151fb0f6 100644 --- a/Modules/CIPPCore/Public/Get-DefenderTvmRaw.ps1 +++ b/Modules/CIPPCore/Public/Get-DefenderTvmRaw.ps1 @@ -35,8 +35,10 @@ function Get-DefenderTvmRaw { # New-GraphGetRequest already follows @odata.nextLink internally and returns the # flattened .value rows for every page, so this loop only ever runs once and - # $MaxPages never takes effect. Left as-is: Get-DefenderCves depends on the - # buffered return shape. + # $MaxPages never takes effect. Both in-repo callers (get-DefenderCVEs and + # Set-CIPPDBCacheDefenderCVEs) now pass -Stream, so this buffered path is kept only + # for ad-hoc use - it holds the whole tenant dataset, so don't put it back on a hot + # path. do { Write-LogMessage -API 'DefenderTVM' -tenant $TenantId -message "Fetching page $($page + 1)" -Sev 'Debug' diff --git a/Modules/CIPPCore/Public/GraphHelper/Get-CIPPSharePointDomain.ps1 b/Modules/CIPPCore/Public/GraphHelper/Get-CIPPSharePointDomain.ps1 new file mode 100644 index 0000000000000..779d57a461511 --- /dev/null +++ b/Modules/CIPPCore/Public/GraphHelper/Get-CIPPSharePointDomain.ps1 @@ -0,0 +1,32 @@ +function Get-CIPPSharePointDomain { + <# + .SYNOPSIS + Maps a tenant's initial (onmicrosoft) domain to the SharePoint domain that goes with it. + .DESCRIPTION + SharePoint is only on sharepoint.com in the commercial cloud. Sovereign clouds keep their + own domain, and the tenant's initial domain carries the same TLD: + + contoso.onmicrosoft.de -> sharepoint.de (old German tenants, see issue #269) + contoso.partner.onmschina.cn -> sharepoint.cn (21Vianet) + contoso.onmicrosoft.us -> sharepoint.us (GCC High) + contoso.onmicrosoft.com -> sharepoint.com + + This is a best-effort mapping for the paths that cannot ask Graph (autodiscover, cached + values, extension fallbacks). Prefer Get-SharePointAdminLink, which reads the real host off + the tenant's root site - it is the only way to tell DoD (sharepoint-mil.us) from GCC High, + since both are onmicrosoft.us. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + # The tenant's initial domain, e.g. contoso.onmicrosoft.de + [string]$TenantDomain + ) + + switch -Regex ($TenantDomain) { + '\.onmschina\.cn$' { return 'sharepoint.cn' } + '\.onmicrosoft\.(?[a-z]{2,})$' { return "sharepoint.$($Matches.Tld)" } + default { return 'sharepoint.com' } + } +} diff --git a/Modules/CIPPCore/Public/GraphHelper/Get-CIPPTable.ps1 b/Modules/CIPPCore/Public/GraphHelper/Get-CIPPTable.ps1 index 122d0a82d81aa..992b3aaee1835 100644 --- a/Modules/CIPPCore/Public/GraphHelper/Get-CIPPTable.ps1 +++ b/Modules/CIPPCore/Public/GraphHelper/Get-CIPPTable.ps1 @@ -13,7 +13,23 @@ function Get-CIPPTable { } $ContextParams['MaxConnectionsPerServer'] = if ($env:AZBOBBY_MAX_CONNECTIONS_PER_SERVER) { [int]$env:AZBOBBY_MAX_CONNECTIONS_PER_SERVER } else { 30 } $Context = New-AzDataTableContext @ContextParams - New-AzDataTable -Context $Context | Out-Null + + # New-AzDataTable 409s once the table exists, and those 409s bill like any other request. + # This runs on nearly every code path, so skip the round trip for tables we know exist. + # Anything that drops a table must call Unregister-CIPPTable. + # + # Craft injects the cache (ModuleInjections/CIPPTableCache) so all runspaces share one + # instance. Mutate it, never reassign, or this runspace gets a private copy. + if (-not $script:CIPPEnsuredTables) { $script:CIPPEnsuredTables = [HashTable]::Synchronized(@{}) } + + # Account is in the key: AzureWebJobsStorage can be repointed at a different account. + $Account = if ($env:AzureWebJobsStorage -match 'AccountName=([^;]+)') { $Matches[1] } else { 'default' } + $CacheKey = '{0}/{1}' -f $Account, $tablename + + if (-not $script:CIPPEnsuredTables.ContainsKey($CacheKey)) { + New-AzDataTable -Context $Context | Out-Null + $script:CIPPEnsuredTables[$CacheKey] = $true + } @{ Context = $Context diff --git a/Modules/CIPPCore/Public/GraphHelper/Get-CippExoErrorText.ps1 b/Modules/CIPPCore/Public/GraphHelper/Get-CippExoErrorText.ps1 new file mode 100644 index 0000000000000..5f895153efeda --- /dev/null +++ b/Modules/CIPPCore/Public/GraphHelper/Get-CippExoErrorText.ps1 @@ -0,0 +1,19 @@ +function Get-CippExoErrorText { + <# + .SYNOPSIS + Pulls a readable message out of an Exchange bulk error record. + + .DESCRIPTION + New-ExoBulkRequest sets 'error' to error.details.message when Exchange supplies one and falls + back to error.message otherwise, so the value is normally a string - but callers also hand this + raw Graph-style objects, so handle both rather than printing a type name at the operator. + #> + [CmdletBinding()] + param($ErrorRecord) + + $ErrorValue = $ErrorRecord.error ?? $ErrorRecord + if ($ErrorValue -is [string]) { return $ErrorValue } + if ($ErrorValue.details.message) { return [string]$ErrorValue.details.message } + if ($ErrorValue.message) { return [string]$ErrorValue.message } + return [string]$ErrorValue +} \ No newline at end of file diff --git a/Modules/CIPPCore/Public/GraphHelper/Get-SharePointAdminLink.ps1 b/Modules/CIPPCore/Public/GraphHelper/Get-SharePointAdminLink.ps1 index 5186d09eaeaef..472bb1c4d65d1 100644 --- a/Modules/CIPPCore/Public/GraphHelper/Get-SharePointAdminLink.ps1 +++ b/Modules/CIPPCore/Public/GraphHelper/Get-SharePointAdminLink.ps1 @@ -40,25 +40,43 @@ function Get-SharePointAdminLink { # Invoke autodiscover $Response = Invoke-RestMethod -UseBasicParsing -Method Post -Uri 'https://autodiscover-s.outlook.com/autodiscover/autodiscover.svc' -Body $body -Headers $AutoDiscoverHeaders - # Get the onmicrosoft.com domain from the response + # Get the onmicrosoft domain from the response. Sovereign clouds use their own + # suffix (onmicrosoft.de, onmicrosoft.us, partner.onmschina.cn), so don't filter on '.com'. $TenantDomains = $Response.Envelope.body.GetFederationInformationResponseMessage.response.Domains.Domain | Sort-Object - $OnMicrosoftDomains = $TenantDomains | Where-Object { $_ -like '*.onmicrosoft.com' } + # @() matters: a single match comes back as a bare string, and indexing a string with + # [0] yields a [char], which then has no .Split(). + $OnMicrosoftDomains = @($TenantDomains | Where-Object { $_ -like '*.onmicrosoft.*' -or $_ -like '*.onmschina.cn' }) if ($OnMicrosoftDomains.Count -eq 0) { - throw 'Could not find onmicrosoft.com domain through autodiscover' + throw 'Could not find onmicrosoft domain through autodiscover' } elseif ($OnMicrosoftDomains.Count -gt 1) { - throw "Multiple onmicrosoft.com domains found through autodiscover. Cannot determine the correct one: $($OnMicrosoftDomains -join ', ')" + throw "Multiple onmicrosoft domains found through autodiscover. Cannot determine the correct one: $($OnMicrosoftDomains -join ', ')" } else { $OnMicrosoftDomain = $OnMicrosoftDomains[0] $tenantName = $OnMicrosoftDomain.Split('.')[0] + # Best-effort mapping of the tenant domain suffix to the SharePoint one. Autodiscover + # cannot tell GCC High (sharepoint.us) from DoD (sharepoint-mil.us) - both are + # onmicrosoft.us - so DoD needs the Graph path below. + $SharePointDomain = Get-CIPPSharePointDomain -TenantDomain $OnMicrosoftDomain } } catch { throw "Failed to get SharePoint admin URL through autodiscover: $($_.Exception.Message)" } } else { - # id looks like 'contoso.sharepoint.com,,' - the host's first label is the name. + # id looks like 'contoso.sharepoint.com,,' - the host's first label is the name, + # and the rest is the SharePoint domain. That domain is not always 'sharepoint.com': + # sovereign clouds use sharepoint.de (Germany), sharepoint.cn (21Vianet), + # sharepoint.us (GCC High) and sharepoint-mil.us (DoD), so take it from the host we got + # back rather than assuming. $RootSite = New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/sites/root' -asApp $true -tenantid $TenantFilter - $tenantName = ($RootSite.id -split '\.')[0] + $SharePointHost = $RootSite.siteCollection.hostname + if ([string]::IsNullOrWhiteSpace($SharePointHost)) { $SharePointHost = ($RootSite.id -split ',')[0] } + if ([string]::IsNullOrWhiteSpace($SharePointHost) -and $RootSite.webUrl) { $SharePointHost = ([uri]$RootSite.webUrl).Host } + + $tenantName = ($SharePointHost -split '\.')[0] + if ($SharePointHost -match '^[^.]+\.(?sharepoint(?:-[a-z]+)?\.[a-z.]+)$') { + $SharePointDomain = $Matches.Domain + } } # Without a name every URL below is a well-formed link to nowhere ('https://-admin.sharepoint.com'). @@ -67,10 +85,14 @@ function Get-SharePointAdminLink { throw "Could not determine the SharePoint tenant name for $TenantFilter. The tenant may not have SharePoint provisioned, or the Sites.Read.All permission may be missing." } + # Commercial cloud is the fallback when the host did not look like a SharePoint one. + if ([string]::IsNullOrWhiteSpace($SharePointDomain)) { $SharePointDomain = 'sharepoint.com' } + # Return object with all needed properties return [PSCustomObject]@{ - AdminUrl = "https://$tenantName-admin.sharepoint.com" - TenantName = $tenantName - SharePointUrl = "https://$tenantName.sharepoint.com" + AdminUrl = "https://$tenantName-admin.$SharePointDomain" + TenantName = $tenantName + SharePointUrl = "https://$tenantName.$SharePointDomain" + SharePointDomain = $SharePointDomain } } diff --git a/Modules/CIPPCore/Public/GraphHelper/Initialize-CIPPTables.ps1 b/Modules/CIPPCore/Public/GraphHelper/Initialize-CIPPTables.ps1 new file mode 100644 index 0000000000000..a3cd0f708fa87 --- /dev/null +++ b/Modules/CIPPCore/Public/GraphHelper/Initialize-CIPPTables.ps1 @@ -0,0 +1,55 @@ +function Initialize-CIPPTables { + <# + .SYNOPSIS + Seeds the Get-CIPPTable "this table already exists" cache at warmup, from a single + ListTables call against the storage account. + + .DESCRIPTION + Get-CIPPTable already caps CreateTable at one call per table; this removes even those, so a + warm instance issues none at all. + + A listing rather than a fixed list: a hardcoded list rots as features add tables, and many + table names are derived from Graph queries at runtime so a list could never cover them. + + Best effort - if the listing fails, Get-CIPPTable just creates each table on first use. + Tables that do not exist yet are deliberately left out so first use still creates them. + #> + [CmdletBinding()] + param() + + if (-not $env:AzureWebJobsStorage) { + Write-Warning '[Tables-Init] AzureWebJobsStorage is not set, skipping table cache warmup' + return + } + + $Account = if ($env:AzureWebJobsStorage -match 'AccountName=([^;]+)') { $Matches[1] } else { 'default' } + + try { + $ContextParams = @{ + ConnectionString = $env:AzureWebJobsStorage + TableName = 'CippLogs' + } + $ContextParams['MaxConnectionsPerServer'] = if ($env:AZBOBBY_MAX_CONNECTIONS_PER_SERVER) { [int]$env:AZBOBBY_MAX_CONNECTIONS_PER_SERVER } else { 30 } + $Context = New-AzDataTableContext @ContextParams + + # Account-scoped listing; the context's TableName is unused, which the cmdlet warns about. + $Existing = @(Get-AzDataTable -Context $Context -WarningAction SilentlyContinue) + } catch { + # Storage may not be up yet at warmup. Not fatal. + Write-Warning "[Tables-Init] Could not list tables on $Account, they will be created on first use: $($_.Exception.Message)" + return + } + + if (-not $Existing.Count) { + Write-Information "[Tables-Init] No existing tables on $Account (new instance) - they will be created on first use" + return + } + + # Shared across runspaces - mutate, never reassign. See Get-CIPPTable. + if (-not $script:CIPPEnsuredTables) { $script:CIPPEnsuredTables = [HashTable]::Synchronized(@{}) } + foreach ($TableName in $Existing) { + $script:CIPPEnsuredTables[('{0}/{1}' -f $Account, $TableName)] = $true + } + + Write-Information "[Tables-Init] Cached $($Existing.Count) existing table(s) on $Account - CreateTable will be skipped for them" +} diff --git a/Modules/CIPPCore/Public/GraphHelper/Remove-CIPPCache.ps1 b/Modules/CIPPCore/Public/GraphHelper/Remove-CIPPCache.ps1 index a3756df1a415c..1fb95d477f57b 100644 --- a/Modules/CIPPCore/Public/GraphHelper/Remove-CIPPCache.ps1 +++ b/Modules/CIPPCore/Public/GraphHelper/Remove-CIPPCache.ps1 @@ -24,12 +24,15 @@ function Remove-CIPPCache { "Removing cache table $Table" $TableContext = Get-CIPPTable -TableName $Table Remove-AzDataTable @TableContext + # Drop it from the Get-CIPPTable cache so it gets recreated on next use. + Unregister-CIPPTable -TableName $Table } } 'Clearing Intune policy tracking data' $TrackingTableContext = Get-CIPPTable -TableName 'IntunePolicyTypeTracking' Remove-AzDataTable @TrackingTableContext + Unregister-CIPPTable -TableName 'IntunePolicyTypeTracking' 'Clearing domain analyser results' # Remove Domain Analyser cached results diff --git a/Modules/CIPPCore/Public/GraphHelper/Resolve-CippExoBulkResult.ps1 b/Modules/CIPPCore/Public/GraphHelper/Resolve-CippExoBulkResult.ps1 index c16d86a95a0fe..d447f2293981f 100644 --- a/Modules/CIPPCore/Public/GraphHelper/Resolve-CippExoBulkResult.ps1 +++ b/Modules/CIPPCore/Public/GraphHelper/Resolve-CippExoBulkResult.ps1 @@ -85,24 +85,4 @@ function Resolve-CippExoBulkResult { ErrorMessage = if ($Failure) { Get-CippExoErrorText -ErrorRecord $Failure } else { $null } } } -} - -function Get-CippExoErrorText { - <# - .SYNOPSIS - Pulls a readable message out of an Exchange bulk error record. - - .DESCRIPTION - New-ExoBulkRequest sets 'error' to error.details.message when Exchange supplies one and falls - back to error.message otherwise, so the value is normally a string - but callers also hand this - raw Graph-style objects, so handle both rather than printing a type name at the operator. - #> - [CmdletBinding()] - param($ErrorRecord) - - $ErrorValue = $ErrorRecord.error ?? $ErrorRecord - if ($ErrorValue -is [string]) { return $ErrorValue } - if ($ErrorValue.details.message) { return [string]$ErrorValue.details.message } - if ($ErrorValue.message) { return [string]$ErrorValue.message } - return [string]$ErrorValue -} +} \ No newline at end of file diff --git a/Modules/CIPPCore/Public/GraphHelper/Unregister-CIPPTable.ps1 b/Modules/CIPPCore/Public/GraphHelper/Unregister-CIPPTable.ps1 new file mode 100644 index 0000000000000..fdc3795501feb --- /dev/null +++ b/Modules/CIPPCore/Public/GraphHelper/Unregister-CIPPTable.ps1 @@ -0,0 +1,44 @@ +function Unregister-CIPPTable { + <# + .SYNOPSIS + Forgets a table in the Get-CIPPTable "already created" cache so the next Get-CIPPTable for + it creates it again. Call this after dropping a table with Remove-AzDataTable. + + .DESCRIPTION + Without this the cache still claims a dropped table exists, so callers get empty results or + TableNotFound. The cache is shared across the runspace pool, so one call covers every worker. + + .PARAMETER TableName + Table(s) to forget. Names that were never cached are ignored. + + .PARAMETER All + Forget every table, for callers that cannot know what was dropped. + + .EXAMPLE + Remove-AzDataTable @TableContext + Unregister-CIPPTable -TableName 'CippQueue' + #> + [CmdletBinding(DefaultParameterSetName = 'ByName')] + param( + [Parameter(ParameterSetName = 'ByName', Mandatory, ValueFromPipeline)] + [string[]]$TableName, + + [Parameter(ParameterSetName = 'All', Mandatory)] + [switch]$All + ) + + process { + # Cold process, nothing to forget. + if (-not $script:CIPPEnsuredTables) { return } + + if ($All) { + $script:CIPPEnsuredTables.Clear() + return + } + + $Account = if ($env:AzureWebJobsStorage -match 'AccountName=([^;]+)') { $Matches[1] } else { 'default' } + foreach ($Name in $TableName) { + $script:CIPPEnsuredTables.Remove(('{0}/{1}' -f $Account, $Name)) + } + } +} diff --git a/Modules/CIPPCore/Public/Invoke-CIPPSharePointTemplateDeploy.ps1 b/Modules/CIPPCore/Public/Invoke-CIPPSharePointTemplateDeploy.ps1 index d21d1a67a1d20..706a758569bf6 100644 --- a/Modules/CIPPCore/Public/Invoke-CIPPSharePointTemplateDeploy.ps1 +++ b/Modules/CIPPCore/Public/Invoke-CIPPSharePointTemplateDeploy.ps1 @@ -84,7 +84,7 @@ function Invoke-CIPPSharePointTemplateDeploy { $SharePointInfo = Get-SharePointAdminLink -Public $false -tenantFilter $TenantFilter $SitePath = $SiteTemplate.displayName -replace ' ' -replace '[^A-Za-z0-9-]' try { - $ExistingSite = New-GraphGetRequest -uri "https://graph.microsoft.com/v1.0/sites/$($SharePointInfo.TenantName).sharepoint.com:/sites/$($SitePath)?`$select=id" -tenantid $TenantFilter -AsApp $true + $ExistingSite = New-GraphGetRequest -uri "https://graph.microsoft.com/v1.0/sites/$($SharePointInfo.TenantName).$($SharePointInfo.SharePointDomain):/sites/$($SitePath)?`$select=id" -tenantid $TenantFilter -AsApp $true $AlreadyExists = [bool]$ExistingSite.id } catch { # 404 means the site does not exist yet, which is the normal path. @@ -141,7 +141,7 @@ function Invoke-CIPPSharePointTemplateDeploy { $null = New-CIPPSharepointSite @SiteParams $SharePointInfo = Get-SharePointAdminLink -Public $false -tenantFilter $TenantFilter $SitePath = $SiteTemplate.displayName -replace ' ' -replace '[^A-Za-z0-9-]' - $SiteUrl = "https://$($SharePointInfo.TenantName).sharepoint.com/sites/$SitePath" + $SiteUrl = "$($SharePointInfo.SharePointUrl)/sites/$SitePath" $Results.Add("[$TenantFilter] Created site '$($SiteTemplate.displayName)' at $SiteUrl") } diff --git a/Modules/CIPPCore/Public/New-CIPPCAPolicy.ps1 b/Modules/CIPPCore/Public/New-CIPPCAPolicy.ps1 index 8b1be67c6e12d..3a151d084ee65 100644 --- a/Modules/CIPPCore/Public/New-CIPPCAPolicy.ps1 +++ b/Modules/CIPPCore/Public/New-CIPPCAPolicy.ps1 @@ -102,7 +102,11 @@ function New-CIPPCAPolicy { $displayName = ($RawJSON | ConvertFrom-Json).displayName $JSONobj = $RawJSON | ConvertFrom-Json | Select-Object * -ExcludeProperty ID, GUID, *time* - Remove-EmptyArrays $JSONobj + # Canonicalize to full desired-state shape: an overwrite is a PATCH, and PATCH merges, so every + # managed key the template omits (stripped by older editors at save time) is added back as its + # cleared form - [] for assignments, null for condition blocks - or the tenant's deviations + # (extra excluded users, a different user set) survive every run and drift never converges. + Format-CIPPCAPolicy -Policy $JSONobj #Remove context as it does not belong in the payload. try { if ($JSONobj.grantControls) { @@ -121,7 +125,8 @@ function New-CIPPCAPolicy { $JSONobj.sessionControls.PSObject.Properties.Remove('disableResilienceDefaults') } if (@($JSONobj.sessionControls.PSObject.Properties).Count -eq 0) { - $JSONobj.PSObject.Properties.Remove('sessionControls') + # Null, not removed - a removed property leaves the tenant's session controls in place. + $JSONobj.sessionControls = $null } } if ($State -and $State -ne 'donotchange') { @@ -519,15 +524,17 @@ function New-CIPPCAPolicy { $groups = ($BulkResults | Where-Object { $_.id -eq 'groups' }).body.value } + # Cleared collections stay cleared - piping an empty into the converters resolves a + # phantom entry and logs a "did not match any user" warning for something nobody asked for. foreach ($userType in 'includeUsers', 'excludeUsers') { - if ($JSONobj.conditions.users.PSObject.Properties.Name -contains $userType -and $JSONobj.conditions.users.$userType -notin 'All', 'None', 'GuestsOrExternalUsers') { + if (@($JSONobj.conditions.users.$userType).Count -gt 0 -and $JSONobj.conditions.users.$userType -notin 'All', 'None', 'GuestsOrExternalUsers') { $JSONobj.conditions.users.$userType = @(Convert-UserNameToId -userNames $JSONobj.conditions.users.$userType) } } # Check the included and excluded groups foreach ($groupType in 'includeGroups', 'excludeGroups') { - if ($JSONobj.conditions.users.PSObject.Properties.Name -contains $groupType) { + if (@($JSONobj.conditions.users.$groupType).Count -gt 0) { $JSONobj.conditions.users.$groupType = @(Convert-GroupNameToId -groupNames $JSONobj.conditions.users.$groupType -CreateGroups $CreateGroups -TenantFilter $TenantFilter -GroupTemplates $GroupTemplates) } } @@ -541,26 +548,6 @@ function New-CIPPCAPolicy { } $JSONobj.PSObject.Properties.Remove('LocationInfo') $JSONobj.PSObject.Properties.Remove('AuthContextInfo') - foreach ($condition in $JSONobj.conditions.users.PSObject.Properties.Name) { - $value = $JSONobj.conditions.users.$condition - if ($null -eq $value) { - $JSONobj.conditions.users.$condition = @() - continue - } - if ($value -is [string]) { - if ([string]::IsNullOrWhiteSpace($value)) { - $JSONobj.conditions.users.$condition = @() - continue - } - } - if ($value -is [array]) { - $nonWhitespaceItems = $value | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } - if ($nonWhitespaceItems.Count -eq 0) { - $JSONobj.conditions.users.$condition = @() - continue - } - } - } if ($DisableSD -eq $true) { # Check if Security Defaults is already disabled using preloaded or live data $SDPolicy = $PreloadedSecurityDefaults diff --git a/Modules/CIPPCore/Public/New-CIPPSharepointSite.ps1 b/Modules/CIPPCore/Public/New-CIPPSharepointSite.ps1 index 23b9be5142a37..d8be8d93685bd 100644 --- a/Modules/CIPPCore/Public/New-CIPPSharepointSite.ps1 +++ b/Modules/CIPPCore/Public/New-CIPPSharepointSite.ps1 @@ -86,7 +86,7 @@ function New-CIPPSharepointSite { $SharePointInfo = Get-SharePointAdminLink -Public $false -tenantFilter $TenantFilter $SitePath = $SiteName -replace ' ' -replace '[^A-Za-z0-9-]' - $SiteUrl = "https://$($SharePointInfo.TenantName).sharepoint.com/sites/$SitePath" + $SiteUrl = "$($SharePointInfo.SharePointUrl)/sites/$SitePath" # Resolve site language: # - Explicit positive LCID → use it (must be in $AllowedSiteLcids) @@ -110,7 +110,7 @@ function New-CIPPSharepointSite { $RootLanguageError = $null try { $JsonAccept = @{ Accept = 'application/json;odata=nometadata' } - $RootWeb = New-GraphGetRequest -uri "https://$($SharePointInfo.TenantName).sharepoint.com/_api/web?`$select=Language" -tenantid $TenantFilter -scope "$($SharePointInfo.SharePointUrl)/.default" -extraHeaders $JsonAccept -UseCertificate -AsApp $true + $RootWeb = New-GraphGetRequest -uri "$($SharePointInfo.SharePointUrl)/_api/web?`$select=Language" -tenantid $TenantFilter -scope "$($SharePointInfo.SharePointUrl)/.default" -extraHeaders $JsonAccept -UseCertificate -AsApp $true if ($RootWeb.Language -gt 0) { $ResolvedLcid = [int]$RootWeb.Language } diff --git a/Modules/CIPPCore/Public/Remove-CIPPCalendarPermissions.ps1 b/Modules/CIPPCore/Public/Remove-CIPPCalendarPermissions.ps1 index 7703e88cc7f3b..afb54a02e86c9 100644 --- a/Modules/CIPPCore/Public/Remove-CIPPCalendarPermissions.ps1 +++ b/Modules/CIPPCore/Public/Remove-CIPPCalendarPermissions.ps1 @@ -66,6 +66,9 @@ function Remove-CIPPCalendarPermissions { # Resolve user to display name if a UPN was provided # Calendar permissions use display names, not UPNs $UserToMatch = $UserToRemove + # Exchange resolves -User by name, which is ambiguous when two recipients share one. + # Keep a unique id for Exchange; the display name is only for matching the cache. + $UserIdentifier = $UserToRemove if ($UserToRemove -match '@') { # Try to get display name from mailbox cache $MailboxItems = Get-CIPPDbItem -TenantFilter $TenantFilter -Type 'Mailboxes' | Where-Object { $_.RowKey -ne 'Mailboxes-Count' } @@ -73,6 +76,7 @@ function Remove-CIPPCalendarPermissions { $Mailbox = $Item.Data | ConvertFrom-Json if ($Mailbox.UPN -eq $UserToRemove -or $Mailbox.primarySmtpAddress -eq $UserToRemove) { $UserToMatch = $Mailbox.displayName + if ($Mailbox.ExternalDirectoryObjectId) { $UserIdentifier = $Mailbox.ExternalDirectoryObjectId } Write-Information "Resolved $UserToRemove to display name: $UserToMatch" -InformationAction Continue break } @@ -89,33 +93,34 @@ function Remove-CIPPCalendarPermissions { # Remove from each calendar foreach ($CalPermEntry in $CalendarPermissions.Permissions) { - try { - $Folder = if ($CalPermEntry.FolderName) { $CalPermEntry.FolderName } else { 'Calendar' } - $CalIdentity = "$($CalPermEntry.CalendarUPN):\$Folder" - - $RemovalResult = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Remove-MailboxFolderPermission' -cmdParams @{ - Identity = $CalIdentity - User = $UserToMatch - } -UseSystemMailbox $true + $Folder = if ($CalPermEntry.FolderName) { $CalPermEntry.FolderName } else { 'Calendar' } + $CalIdentity = "$($CalPermEntry.CalendarUPN):\$Folder" + $CacheIsStale = $false - # Sync cache regardless of whether permission existed in Exchange - # Cache sync uses flexible matching so it will find and remove the entry - Sync-CIPPCalendarPermissionCache -TenantFilter $TenantFilter -MailboxIdentity $CalPermEntry.CalendarUPN -FolderName $Folder -User $UserToMatch -Action 'Remove' + try { + $null = Remove-CIPPFolderPermission -TenantFilter $TenantFilter -FolderIdentity $CalIdentity -User $UserIdentifier -AccessRights $CalPermEntry.AccessRights -Anchor $CalPermEntry.CalendarUPN + $CacheIsStale = $true $SuccessMsg = "Removed $UserToRemove from calendar $CalIdentity" Write-LogMessage -headers $Headers -API $APIName -message $SuccessMsg -Sev 'Info' -tenant $TenantFilter $Results.Add($SuccessMsg) } catch { - # Sync cache even on error (permission might not exist) + # Only drop the cached row when Exchange confirms there is nothing to remove. + # Any other failure leaves the permission live, and clearing the cache would + # report an offboarded user as having lost access they still have. + $CacheIsStale = $_.Exception.Message -match 'UserNotFoundInPermissionEntryException' + + $ErrorMsg = "Failed to remove $UserToRemove from calendar $($CalPermEntry.CalendarUPN): $($_.Exception.Message)" + Write-LogMessage -headers $Headers -API $APIName -message $ErrorMsg -sev 'Warning' -tenant $TenantFilter + $Results.Add($ErrorMsg) + } + + if ($CacheIsStale) { try { Sync-CIPPCalendarPermissionCache -TenantFilter $TenantFilter -MailboxIdentity $CalPermEntry.CalendarUPN -FolderName $Folder -User $UserToMatch -Action 'Remove' } catch { Write-Verbose "Failed to sync cache: $_" } - - $ErrorMsg = "Failed to remove $UserToRemove from calendar $($CalPermEntry.CalendarUPN): $($_.Exception.Message)" - Write-LogMessage -headers $Headers -API $APIName -message $ErrorMsg -sev 'Warning' -tenant $TenantFilter - $Results.Add($ErrorMsg) } } @@ -134,18 +139,12 @@ function Remove-CIPPCalendarPermissions { throw 'CalendarIdentity is required when not using cache' } - try { - $RemovalResult = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Remove-MailboxFolderPermission' -cmdParams @{ - Identity = $CalendarIdentity - User = $UserToRemove - } -UseSystemMailbox $true + $MailboxUPN = if ($CalendarIdentity -match '^([^:]+):') { $Matches[1] } else { $CalendarIdentity } + $Folder = if ($CalendarIdentity -match ':\\(.+)$') { $Matches[1] } else { $FolderName } - # Sync cache - extract mailbox UPN from identity - $MailboxUPN = if ($CalendarIdentity -match '^([^:]+):') { $Matches[1] } else { $CalendarIdentity } - $Folder = if ($CalendarIdentity -match ':\\(.+)$') { $Matches[1] } else { $FolderName } + try { + $null = Remove-CIPPFolderPermission -TenantFilter $TenantFilter -FolderIdentity $CalendarIdentity -User $UserToRemove -Anchor $MailboxUPN - # Sync cache regardless of whether permission existed in Exchange - # Cache sync uses flexible matching so it will find and remove the entry Sync-CIPPCalendarPermissionCache -TenantFilter $TenantFilter -MailboxIdentity $MailboxUPN -FolderName $Folder -User $UserToRemove -Action 'Remove' $SuccessMsg = "Removed $UserToRemove from calendar $CalendarIdentity" @@ -153,14 +152,14 @@ function Remove-CIPPCalendarPermissions { return $SuccessMsg } catch { - # Sync cache even on error (permission might not exist) - $MailboxUPN = if ($CalendarIdentity -match '^([^:]+):') { $Matches[1] } else { $CalendarIdentity } - $Folder = if ($CalendarIdentity -match ':\\(.+)$') { $Matches[1] } else { $FolderName } - - try { - Sync-CIPPCalendarPermissionCache -TenantFilter $TenantFilter -MailboxIdentity $MailboxUPN -FolderName $Folder -User $UserToRemove -Action 'Remove' - } catch { - Write-Verbose "Failed to sync cache: $_" + # Only drop the cached row when Exchange confirms there is nothing to remove. + $CacheIsStale = $_.Exception.Message -match 'UserNotFoundInPermissionEntryException' + if ($CacheIsStale) { + try { + Sync-CIPPCalendarPermissionCache -TenantFilter $TenantFilter -MailboxIdentity $MailboxUPN -FolderName $Folder -User $UserToRemove -Action 'Remove' + } catch { + Write-Verbose "Failed to sync cache: $_" + } } $ErrorMessage = Get-CippException -Exception $_ diff --git a/Modules/CIPPCore/Public/Remove-CIPPFolderPermission.ps1 b/Modules/CIPPCore/Public/Remove-CIPPFolderPermission.ps1 new file mode 100644 index 0000000000000..26f799dfc272b --- /dev/null +++ b/Modules/CIPPCore/Public/Remove-CIPPFolderPermission.ps1 @@ -0,0 +1,78 @@ +function Remove-CIPPFolderPermission { + <# + .SYNOPSIS + Remove a mailbox folder permission, resolving grantees that share a display name. + + .DESCRIPTION + Get-MailboxFolderPermission reports grantees by display name only, so a display name is all + CIPP has to send back. Exchange cannot resolve one shared by two recipients and throws + ManagementObjectAmbiguousException. + + Recover from that with two calls: list the namesakes, then probe them all in a single bulk + request whose OperationGuid maps each permission entry back to the recipient holding it. + Namesakes that hold nothing come back as errors. Where more than one holds an entry, + AccessRights identifies which listed row the caller acted on. + + .PARAMETER AccessRights + Rights of the entry being removed, used to pick between namesakes who both hold one. + + .OUTPUTS + The identifier the removal succeeded with. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter, + + [Parameter(Mandatory = $true)] + [string]$FolderIdentity, + + [Parameter(Mandatory = $true)] + [string]$User, + + [Parameter(Mandatory = $false)] + [string]$AccessRights, + + [Parameter(Mandatory = $false)] + [string]$Anchor + ) + + if ([string]::IsNullOrWhiteSpace($Anchor)) { $Anchor = ($FolderIdentity -split ':\\')[0] } + $Exo = @{ tenantid = $TenantFilter; Anchor = $Anchor } + + try { + $null = New-ExoRequest @Exo -cmdlet 'Remove-MailboxFolderPermission' -cmdParams @{ Identity = $FolderIdentity; User = $User } + return $User + } catch { + if ($_.Exception.Message -notmatch 'ManagementObjectAmbiguousException') { throw } + } + + $Candidates = @(New-ExoRequest @Exo -cmdlet 'Get-Recipient' -cmdParams @{ + Filter = "DisplayName -eq '$($User -replace "'", "''")'" + ResultSize = 'Unlimited' + } -Select 'PrimarySmtpAddress,Guid') + + $Probe = @(New-ExoBulkRequest -tenantid $TenantFilter -useSystemMailbox $true -cmdletArray @( + foreach ($Candidate in $Candidates) { + @{ + CmdletInput = @{ CmdletName = 'Get-MailboxFolderPermission'; Parameters = @{ Identity = $FolderIdentity; User = $Candidate.Guid } } + OperationGuid = $Candidate.Guid + } + })) + $Holders = @($Probe | Where-Object { -not $_.error }) + + if ($Holders.Count -gt 1 -and $AccessRights) { + $Matched = @($Holders | Where-Object { ($_.AccessRights -join ', ') -eq $AccessRights }) + if ($Matched.Count -eq 1) { $Holders = $Matched } + } + + if ($Holders.Count -ne 1) { + $Addresses = @($Candidates | ForEach-Object { $_.PrimarySmtpAddress }) -join ', ' + throw "'$User' matches $($Candidates.Count) recipients ($Addresses), $($Holders.Count) of which hold matching permissions on $FolderIdentity. The entry cannot be identified - remove it by address in Exchange." + } + + $Target = $Holders[0].OperationGuid + $null = New-ExoRequest @Exo -cmdlet 'Remove-MailboxFolderPermission' -cmdParams @{ Identity = $FolderIdentity; User = $Target } + Write-Information "Resolved '$User' to $(($Candidates | Where-Object { $_.Guid -eq $Target }).PrimarySmtpAddress) on $FolderIdentity" + return $Target +} diff --git a/Modules/CIPPCore/Public/Resolve-CIPPFolderPermissionUser.ps1 b/Modules/CIPPCore/Public/Resolve-CIPPFolderPermissionUser.ps1 new file mode 100644 index 0000000000000..472e611d73511 --- /dev/null +++ b/Modules/CIPPCore/Public/Resolve-CIPPFolderPermissionUser.ps1 @@ -0,0 +1,78 @@ +function Resolve-CIPPFolderPermissionUser { + <# + .SYNOPSIS + Attach a unique recipient id to each mailbox folder permission entry. + + .DESCRIPTION + Get-MailboxFolderPermission identifies grantees by display name, which Exchange cannot + resolve when two recipients share one. Resolve every grantee in a single bulk request. Names + matching several recipients need a second bulk request that probes each candidate against + the folder, so an entry can be attributed to the recipient actually holding it - including + when two namesakes hold entries with identical rights, since each row is handed a distinct + holder and stays individually removable. + + Rows keep a null UserId when nothing resolves, which leaves callers on the display-name path. + + .OUTPUTS + The permission entries, each with a UserId property added. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter, + + [Parameter(Mandatory = $true)] + [string]$FolderIdentity, + + [Parameter(Mandatory = $false)] + $Permissions + ) + + $Rows = @($Permissions) + $Names = @($Rows | Where-Object { $_.User -notin 'Default', 'Anonymous', 'NT AUTHORITY\SELF' } | Select-Object -ExpandProperty User -Unique) + if ($Names.Count -eq 0) { return $Rows } + + $Recipients = @(New-ExoBulkRequest -tenantid $TenantFilter -useSystemMailbox $true -Select 'PrimarySmtpAddress,Guid' -cmdletArray @( + foreach ($Name in $Names) { + @{ + CmdletInput = @{ CmdletName = 'Get-Recipient'; Parameters = @{ Filter = "DisplayName -eq '$($Name -replace "'", "''")'"; ResultSize = 'Unlimited' } } + OperationGuid = $Name + } + }) | Where-Object { $_.Guid }) + + $ByName = @{} + foreach ($Group in $Recipients | Group-Object OperationGuid) { $ByName[$Group.Name] = @($Group.Group) } + + $Ambiguous = @($ByName.Keys | Where-Object { $ByName[$_].Count -gt 1 }) + $Holders = @{} + if ($Ambiguous.Count -gt 0) { + $Probe = @(New-ExoBulkRequest -tenantid $TenantFilter -useSystemMailbox $true -cmdletArray @( + foreach ($Name in $Ambiguous) { + foreach ($Candidate in $ByName[$Name]) { + @{ + CmdletInput = @{ CmdletName = 'Get-MailboxFolderPermission'; Parameters = @{ Identity = $FolderIdentity; User = $Candidate.Guid } } + OperationGuid = $Candidate.Guid + } + } + })) + foreach ($Entry in $Probe | Where-Object { -not $_.error }) { + $Key = '{0}|{1}' -f $Entry.User, ($Entry.AccessRights -join ', ') + if (-not $Holders.ContainsKey($Key)) { $Holders[$Key] = [System.Collections.Generic.Queue[string]]::new() } + $Holders[$Key].Enqueue($Entry.OperationGuid) + } + } + + foreach ($Row in $Rows) { + $UserId = $null + $Resolved = $ByName[[string]$Row.User] + if ($Resolved.Count -eq 1) { + $UserId = $Resolved[0].Guid + } elseif ($Resolved.Count -gt 1) { + $Key = '{0}|{1}' -f $Row.User, ($Row.AccessRights -join ', ') + if ($Holders.ContainsKey($Key) -and $Holders[$Key].Count -gt 0) { $UserId = $Holders[$Key].Dequeue() } + } + $Row | Add-Member -NotePropertyName 'UserId' -NotePropertyValue $UserId -Force + } + + return $Rows +} diff --git a/Modules/CIPPCore/Public/Set-CIPPCalendarPermission.ps1 b/Modules/CIPPCore/Public/Set-CIPPCalendarPermission.ps1 index 8435ecf7dbdd6..2e4075535c864 100644 --- a/Modules/CIPPCore/Public/Set-CIPPCalendarPermission.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPCalendarPermission.ps1 @@ -49,7 +49,7 @@ function Set-CIPPCalendarPermission { if ($RemoveAccess) { if ($PSCmdlet.ShouldProcess("$UserID\$FolderName", "Remove permissions for $LoggingName")) { - $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Remove-MailboxFolderPermission' -cmdParams @{Identity = $FolderIdentity; User = $RemoveAccess } + $null = Remove-CIPPFolderPermission -TenantFilter $TenantFilter -FolderIdentity $FolderIdentity -User $RemoveAccess -AccessRights ($Permissions -join ', ') -Anchor $UserID $Result = "Successfully removed access for $LoggingName from calendar $($CalParam.Identity)" Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message $Result -sev Info @@ -61,7 +61,14 @@ function Set-CIPPCalendarPermission { try { $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Set-MailboxFolderPermission' -cmdParams $CalParam -Anchor $UserID } catch { - $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Add-MailboxFolderPermission' -cmdParams $CalParam -Anchor $UserID + # Set fails when there is no entry to update, so Add is the expected fallback. + # Keep Set's error too, or an unrelated Add failure hides why Set failed. + $SetError = $_ + try { + $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Add-MailboxFolderPermission' -cmdParams $CalParam -Anchor $UserID + } catch { + throw "Set-MailboxFolderPermission failed ($($SetError.Exception.Message)) and Add-MailboxFolderPermission also failed: $($_.Exception.Message)" + } } $Result = "Successfully set permissions on folder $($CalParam.Identity). The user $LoggingName now has $Permissions permissions on this folder." if ($CanViewPrivateItems) { diff --git a/Modules/CIPPCore/Public/Set-CIPPContactPermission.ps1 b/Modules/CIPPCore/Public/Set-CIPPContactPermission.ps1 index ae61cc3b3458f..9982404e9dfb2 100644 --- a/Modules/CIPPCore/Public/Set-CIPPContactPermission.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPContactPermission.ps1 @@ -30,7 +30,7 @@ function Set-CIPPContactPermission { if ($RemoveAccess) { if ($PSCmdlet.ShouldProcess("$UserID\$FolderName", "Remove permissions for $LoggingName")) { - $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Remove-MailboxFolderPermission' -cmdParams @{Identity = "$($UserID):\$FolderName"; User = $RemoveAccess } + $null = Remove-CIPPFolderPermission -TenantFilter $TenantFilter -FolderIdentity "$($UserID):\$FolderName" -User $RemoveAccess -AccessRights ($Permissions -join ', ') -Anchor $UserID $Result = "Successfully removed access for $LoggingName from contact folder $($ContactParam.Identity)" Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message $Result -sev Info } @@ -39,7 +39,14 @@ function Set-CIPPContactPermission { try { $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Set-MailboxFolderPermission' -cmdParams $ContactParam -Anchor $UserID } catch { - $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Add-MailboxFolderPermission' -cmdParams $ContactParam -Anchor $UserID + # Set fails when there is no entry to update, so Add is the expected fallback. + # Keep Set's error too, or an unrelated Add failure hides why Set failed. + $SetError = $_ + try { + $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Add-MailboxFolderPermission' -cmdParams $ContactParam -Anchor $UserID + } catch { + throw "Set-MailboxFolderPermission failed ($($SetError.Exception.Message)) and Add-MailboxFolderPermission also failed: $($_.Exception.Message)" + } } $Result = "Successfully set permissions on contact folder $($ContactParam.Identity). The user $LoggingName now has $Permissions permissions on this folder." diff --git a/Modules/CIPPCore/Public/Set-CIPPSPOTenant.ps1 b/Modules/CIPPCore/Public/Set-CIPPSPOTenant.ps1 index a3a870aacc0fa..fe1188e1ca7cf 100644 --- a/Modules/CIPPCore/Public/Set-CIPPSPOTenant.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPSPOTenant.ps1 @@ -25,6 +25,11 @@ function Set-CIPPSPOTenant { .PARAMETER SharepointPrefix Prefix for the sharepoint tenant + .PARAMETER SharepointDomain + SharePoint domain that goes with the prefix (sharepoint.com, sharepoint.de, ...). Supplied by + Get-CIPPSPOTenant over the pipeline; without it the prefix alone is re-resolved rather than + assumed to be sharepoint.com. + .EXAMPLE $Properties = @{ 'EnableAIPIntegration' = $true @@ -60,7 +65,10 @@ function Set-CIPPSPOTenant { [array]$MethodParameters, [Parameter(ValueFromPipelineByPropertyName = $true, ParameterSetName = 'Properties')] [Parameter(ValueFromPipelineByPropertyName = $true, ParameterSetName = 'Method')] - [string]$SharepointPrefix + [string]$SharepointPrefix, + [Parameter(ValueFromPipelineByPropertyName = $true, ParameterSetName = 'Properties')] + [Parameter(ValueFromPipelineByPropertyName = $true, ParameterSetName = 'Method')] + [string]$SharepointDomain ) process { @@ -68,9 +76,13 @@ function Set-CIPPSPOTenant { # get sharepoint admin site $SharePointInfo = Get-SharePointAdminLink -Public $false -tenantFilter $TenantFilter $AdminUrl = $SharePointInfo.AdminUrl + } elseif ($SharepointDomain) { + # Prefix and domain both came off the pipeline (Get-CIPPSPOTenant) - rebuild from them. + $AdminUrl = "https://$($SharepointPrefix)-admin.$SharepointDomain" } else { - $tenantName = $SharepointPrefix - $AdminUrl = "https://$($tenantName)-admin.sharepoint.com" + # A prefix with no domain cannot be trusted to be sharepoint.com, and this object may + # have come from a cache row predating SharepointDomain. Resolve the real one. + $AdminUrl = (Get-SharePointAdminLink -Public $false -tenantFilter $TenantFilter).AdminUrl } $Identity = $Identity -replace "`n", ' ' diff --git a/Modules/CIPPCore/Public/Test-CIPPAccessPermissions.ps1 b/Modules/CIPPCore/Public/Test-CIPPAccessPermissions.ps1 index 6fa625020660a..99845e0216632 100644 --- a/Modules/CIPPCore/Public/Test-CIPPAccessPermissions.ps1 +++ b/Modules/CIPPCore/Public/Test-CIPPAccessPermissions.ps1 @@ -101,18 +101,18 @@ function Test-CIPPAccessPermissions { # non-interactive sign-ins, which are the redemptions themselves. try { $SignInFilter = "appId eq '$($env:ApplicationID)' and signInEventTypes/any(t: t eq 'nonInteractiveUser')" - $SamSignIns = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/auditLogs/signIns?api-version=beta&`$filter=$SignInFilter&`$top=10&`$select=createdDateTime,originalTransferMethod,authenticationProtocol" -tenantid $env:TenantID -NoAuthCheck $true -ErrorAction Stop + $SamSignIns = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/auditLogs/signIns?api-version=beta&`$filter=$SignInFilter&`$top=10&`$select=createdDateTime,originalTransferMethod,authenticationProtocol" -tenantid $env:TenantID -NoAuthCheck $true -noPagination $true -ErrorAction Stop $DeviceCodeSignIn = $SamSignIns | Where-Object { $_.originalTransferMethod -eq 'deviceCodeFlow' -or $_.authenticationProtocol -eq 'deviceCode' } | Select-Object -First 1 if ($DeviceCodeSignIn) { - $ErrorMessages.Add('Your refresh token originated from a device code login. Security defaults and Conditional Access authentication flow policies block that flow when the token is redeemed, which fails Graph calls in affected tenants with a Conditional Access error. Refresh your SAM tokens to sign in again - the weekly token update will not replace it.') | Out-Null + $ErrorMessages.Add('Your refresh token came from a device code login and will fail Conditional Access in some tenants. Refresh your token in the Setup Wizard.') | Out-Null $Success = $false } else { - $Messages.Add('Your refresh token did not originate from a device code login.') | Out-Null + $Messages.Add('Your refresh token is not from a device code login.') | Out-Null } } catch { # Reading sign-in logs needs AuditLog.Read.All and an Entra ID P1 licence. Not # having either is not an access check failure, it just leaves this unknown. - $Messages.Add('Could not determine whether your refresh token originated from a device code login. Reading sign-in logs requires AuditLog.Read.All and an Entra ID P1 license.') | Out-Null + $Messages.Add('Could not check for a device code login, this needs AuditLog.Read.All and Entra ID P1.') | Out-Null } } diff --git a/Modules/CIPPDB/Public/DBCache/ConvertTo-CIPPSharingLinksKeySegment.ps1 b/Modules/CIPPDB/Public/DBCache/ConvertTo-CIPPSharingLinksKeySegment.ps1 new file mode 100644 index 0000000000000..e08d65f9e2eb7 --- /dev/null +++ b/Modules/CIPPDB/Public/DBCache/ConvertTo-CIPPSharingLinksKeySegment.ps1 @@ -0,0 +1,14 @@ +function ConvertTo-CIPPSharingLinksKeySegment { + <# + .SYNOPSIS + Applies Add-CIPPDbItem's RowKey sanitisation to a single id segment. + .DESCRIPTION + Prefix queries against CippReportingDB only match if the prefix is built with the + exact transformation the rows were written under: path/wildcard chars to '_', + control chars stripped. + .FUNCTIONALITY + Internal + #> + param([Parameter(Mandatory = $true)][string]$Value) + ($Value -replace '[/\\#?]', '_') -replace '[\u0000-\u001F\u007F-\u009F]', '' +} diff --git a/Modules/CIPPDB/Public/DBCache/Get-CIPPSharingLinksDriveState.ps1 b/Modules/CIPPDB/Public/DBCache/Get-CIPPSharingLinksDriveState.ps1 new file mode 100644 index 0000000000000..ce4c5e9c275ec --- /dev/null +++ b/Modules/CIPPDB/Public/DBCache/Get-CIPPSharingLinksDriveState.ps1 @@ -0,0 +1,26 @@ +function Get-CIPPSharingLinksDriveState { + <# + .SYNOPSIS + Returns per-drive delta-scan state: one drive's row, or every drive's when -DriveId is omitted. + .DESCRIPTION + Drive state lives in the CippSharingLinksState table under RowKey 'delta-{driveId}': the + deltaLink captured when the drive last completed (used for incremental scans), which scan + last saw the drive (used to prune drives that no longer exist), and when it last had a + FULL scan (used to bound incremental drift). Written by the site activity; read here by + the activity, the fan-out parent and the finaliser. + .FUNCTIONALITY + Internal + #> + param( + [Parameter(Mandatory = $true)][string]$TenantFilter, + [string]$DriveId + ) + $Table = Get-CippTable -tablename 'CippSharingLinksState' + $SafeTenant = ConvertTo-CIPPODataFilterValue -Value $TenantFilter -Type String + if ($DriveId) { + $RowKey = "delta-$(ConvertTo-CIPPSharingLinksKeySegment -Value $DriveId)" + Get-CIPPAzDataTableEntity @Table -Filter "PartitionKey eq '$SafeTenant' and RowKey eq '$RowKey'" + } else { + Get-CIPPAzDataTableEntity @Table -Filter "PartitionKey eq '$SafeTenant' and RowKey ge 'delta-' and RowKey lt 'delta.'" + } +} diff --git a/Modules/CIPPDB/Public/DBCache/Get-CIPPSharingLinksRowKeysByPrefix.ps1 b/Modules/CIPPDB/Public/DBCache/Get-CIPPSharingLinksRowKeysByPrefix.ps1 new file mode 100644 index 0000000000000..7acc5b8babfa4 --- /dev/null +++ b/Modules/CIPPDB/Public/DBCache/Get-CIPPSharingLinksRowKeysByPrefix.ps1 @@ -0,0 +1,17 @@ +function Get-CIPPSharingLinksRowKeysByPrefix { + <# + .SYNOPSIS + Returns the RowKeys (keys only, no Data) under a RowKey prefix. + .FUNCTIONALITY + Internal + #> + param( + [Parameter(Mandatory = $true)][string]$TenantFilter, + [Parameter(Mandatory = $true)][string]$Prefix + ) + $Table = Get-CippTable -tablename 'CippReportingDB' + $SafeTenant = ConvertTo-CIPPODataFilterValue -Value $TenantFilter -Type String + $SafePrefix = ConvertTo-CIPPODataFilterValue -Value $Prefix -Type String + $Filter = "PartitionKey eq '{0}' and RowKey ge '{1}' and RowKey lt '{1}~'" -f $SafeTenant, $SafePrefix + @(Get-CIPPAzDataTableEntity @Table -Filter $Filter -Property @('PartitionKey', 'RowKey', 'ETag')) +} diff --git a/Modules/CIPPDB/Public/DBCache/Remove-CIPPSharingLinksRowsByPrefix.ps1 b/Modules/CIPPDB/Public/DBCache/Remove-CIPPSharingLinksRowsByPrefix.ps1 new file mode 100644 index 0000000000000..851bc20002b3a --- /dev/null +++ b/Modules/CIPPDB/Public/DBCache/Remove-CIPPSharingLinksRowsByPrefix.ps1 @@ -0,0 +1,37 @@ +function Remove-CIPPSharingLinksRowsByPrefix { + <# + .SYNOPSIS + Deletes CippReportingDB rows whose RowKey starts with the given prefix. + .DESCRIPTION + -ExceptRunId keeps rows stamped with that run's id (used after a full drive scan to + drop only the rows the scan did not rewrite). -ExceptRowKeys keeps exact keys (the + {Type}-Count row). The '~' upper bound (0x7E) sorts after every character a sanitised + id can contain, so [prefix, prefix~) covers exactly the keys that start with prefix. + Returns the number of rows deleted. + .FUNCTIONALITY + Internal + #> + param( + [Parameter(Mandatory = $true)][string]$TenantFilter, + [Parameter(Mandatory = $true)][string]$Prefix, + [string]$ExceptRunId, + [string[]]$ExceptRowKeys = @() + ) + $Table = Get-CippTable -tablename 'CippReportingDB' + $SafeTenant = ConvertTo-CIPPODataFilterValue -Value $TenantFilter -Type String + $SafePrefix = ConvertTo-CIPPODataFilterValue -Value $Prefix -Type String + $Filter = "PartitionKey eq '{0}' and RowKey ge '{1}' and RowKey lt '{1}~'" -f $SafeTenant, $SafePrefix + $Properties = @('PartitionKey', 'RowKey', 'ETag') + if ($ExceptRunId) { $Properties += 'RunId' } + $Rows = Get-CIPPAzDataTableEntity @Table -Filter $Filter -Property $Properties + $ToDelete = foreach ($Row in @($Rows)) { + if (-not $Row) { continue } + if ($ExceptRowKeys -contains $Row.RowKey) { continue } + if ($ExceptRunId -and [string]$Row.RunId -eq $ExceptRunId) { continue } + $Row + } + if (@($ToDelete).Count -gt 0) { + $null = Remove-CIPPAzDataTableEntity @Table -Entity @($ToDelete) -Force + } + return @($ToDelete).Count +} diff --git a/Modules/CIPPDB/Public/DBCache/Resolve-CIPPSharingLinksTenantFilter.ps1 b/Modules/CIPPDB/Public/DBCache/Resolve-CIPPSharingLinksTenantFilter.ps1 new file mode 100644 index 0000000000000..67e570b495287 --- /dev/null +++ b/Modules/CIPPDB/Public/DBCache/Resolve-CIPPSharingLinksTenantFilter.ps1 @@ -0,0 +1,20 @@ +function Resolve-CIPPSharingLinksTenantFilter { + <# + .SYNOPSIS + Normalises a tenant GUID to its default domain name. + .DESCRIPTION + Add-CIPPDbItem partitions CippReportingDB rows on the default domain, resolving GUID + tenant filters before writing. Every reader/pruner of those rows must resolve the + same way or prefix queries silently miss the partition. + .FUNCTIONALITY + Internal + #> + param([Parameter(Mandatory = $true)][string]$TenantFilter) + if ($TenantFilter -match '^[0-9a-f]{8}-([0-9a-f]{4}-){3}[0-9a-f]{12}$') { + try { + $TenantLookup = @(Get-Tenants -TenantFilter $TenantFilter -IncludeErrors) + if ($TenantLookup.Count -gt 0 -and $TenantLookup[0].defaultDomainName) { return $TenantLookup[0].defaultDomainName } + } catch {} + } + return $TenantFilter +} diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheDefenderCVEs.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheDefenderCVEs.ps1 index 8a31574e3dcef..5434c6c6a2f8b 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheDefenderCVEs.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheDefenderCVEs.ps1 @@ -57,22 +57,44 @@ function Set-CIPPDBCacheDefenderCVEs { recommendedSecurityUpdateUrl = $Vuln.recommendedSecurityUpdateUrl ?? '' exploitabilityLevel = $Vuln.exploitabilityLevel ?? '' - # Arrays to collect device metadata efficiently - AffectedDevices = [System.Collections.Generic.List[object]]::new() + # Device metadata as the JSON text it will be stored as, not as objects. + DeviceJson = [System.Text.StringBuilder]::new() + DeviceCount = 0 } } - # Extract properties specific to this device instance and append in one - # step, so a record that fails mid-extraction cannot leave a previous - # record's payload behind to be appended to the wrong CVE. - [void]$CveAggregator[$CveId].AffectedDevices.Add(@{ - deviceId = ($Vuln.deviceId -join ',') ?? '' - deviceName = ($Vuln.deviceName -join ',') ?? '' - osVersion = $Vuln.osVersion ?? '' - softwareVersion = ($Vuln.softwareVersion -join ',') ?? '' - diskPaths = if ($Vuln.diskPaths) { $Vuln.diskPaths -join ';' } else { '' } - registryPaths = if ($Vuln.registryPaths) { $Vuln.registryPaths -join ';' } else { '' } - }) + # Extract this device instance and fold it in as serialised text immediately. + # + # The aggregation itself is unavoidable: TVM returns one record per + # (device x software x CVE), so a CVE's records are scattered across the whole + # stream and its row cannot be written until the stream ends. What IS avoidable is + # keeping every record as a live object until then. This previously held one + # hashtable per record in a List per CVE - on a large tenant that is hundreds of + # thousands of hashtables, each carrying its own dictionary overhead plus six + # strings, and it is the single largest thing this job retains. + # + # Serialising on arrival keeps the same bytes in one allocation instead of eight, + # and lets the source record become collectable straight away. It also removes the + # second copy that used to exist at emit time, where a CVE's whole device List and + # the JSON produced from it were both live at once. + # + # ConvertTo-Json builds the fragment rather than string interpolation, so escaping + # of device names and registry paths stays correct. + $Fragment = @{ + deviceId = ($Vuln.deviceId -join ',') ?? '' + deviceName = ($Vuln.deviceName -join ',') ?? '' + osVersion = $Vuln.osVersion ?? '' + softwareVersion = ($Vuln.softwareVersion -join ',') ?? '' + diskPaths = if ($Vuln.diskPaths) { $Vuln.diskPaths -join ';' } else { '' } + registryPaths = if ($Vuln.registryPaths) { $Vuln.registryPaths -join ';' } else { '' } + } | ConvertTo-Json -Compress + + # Appended only after the fragment is fully built, so a record that fails + # mid-extraction cannot leave a partial payload attached to the wrong CVE. + $Bucket = $CveAggregator[$CveId] + if ($Bucket.DeviceCount -gt 0) { [void]$Bucket.DeviceJson.Append(',') } + [void]$Bucket.DeviceJson.Append($Fragment) + $Bucket.DeviceCount++ } catch { $SkippedCount++ $ErrorMessage = Get-CippException -Exception $_ @@ -103,7 +125,7 @@ function Set-CIPPDBCacheDefenderCVEs { # this as a per-run cacheTimeStamp. $LastUpdated = [string]$(Get-Date (Get-Date).ToUniversalTime() -UFormat '+%Y-%m-%dT%H:%M:%S.000Z') - # Snapshot the keys so buckets can be dropped while iterating — enumerating + # Snapshot the keys so buckets can be dropped while iterating - enumerating # $CveAggregator.Keys directly and removing from it throws InvalidOperationException. $CveKeys = [string[]]$CveAggregator.Keys @@ -111,19 +133,26 @@ function Set-CIPPDBCacheDefenderCVEs { Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Cached $UniqueCves CVEs" -sev 'Info' # A single Add-CIPPDbItem invocation, fed lazily. This is deliberate: the - # function's end block runs one orphan cleanup against the RunStartUtc captured - # in its begin block, and writes DefenderCVEs-Count once. Splitting the flush - # into several calls would make each later call's cleanup delete rows written by - # earlier ones as soon as the run exceeded the 5 minute skew margin, and would - # leave the stored count equal to the final chunk instead of the total. + # function's end block runs one orphan cleanup keyed to the run id minted in + # its begin block, and writes DefenderCVEs-Count once. Splitting the flush + # into several calls would give each chunk its own run id, so each later call's + # cleanup would treat earlier chunks' rows as orphans as soon as the run + # exceeded the 5 minute skew margin, and would leave the stored count equal to + # the final chunk instead of the total. & { foreach ($CveKey in $CveKeys) { $CveData = $CveAggregator[$CveKey] - # Flatten or convert device info arrays into a compact, compressed JSON string. - # Piped (not -InputObject) so a single-device CVE serialises to an object and a - # multi-device CVE to an array, exactly as before. - $CompactDeviceJson = $CveData.AffectedDevices | ConvertTo-Json -Compress + # The fragments are already JSON; only the surrounding shape is decided here. + # A single-device CVE stays a bare object and a multi-device CVE becomes an + # array, which is what piping a List through ConvertTo-Json used to produce and + # what Get-CIPPCVEReport and the CVE management endpoint parse. + $CompactDeviceJson = if ($CveData.DeviceCount -eq 1) { + $CveData.DeviceJson.ToString() + } else { + [void]$CveData.DeviceJson.Insert(0, '[').Append(']') + $CveData.DeviceJson.ToString() + } @{ PartitionKey = $CveKey @@ -138,7 +167,7 @@ function Set-CIPPDBCacheDefenderCVEs { exploitabilityLevel = $CveData.exploitabilityLevel # Meta aggregation counts - deviceCount = $CveData.AffectedDevices.Count + deviceCount = $CveData.DeviceCount # All individual device variations compressed safely inside a single field deviceDetailsJson = $CompactDeviceJson diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheSharePointSharingLinks.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheSharePointSharingLinks.ps1 index 0516291c1b9f8..49e9bca51f94f 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheSharePointSharingLinks.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheSharePointSharingLinks.ps1 @@ -1,31 +1,47 @@ function Set-CIPPDBCacheSharePointSharingLinks { <# .SYNOPSIS - Fans out SharePoint & OneDrive sharing link collection, one activity per site. + Fans out SharePoint & OneDrive sharing link collection, one resumable activity per site. .DESCRIPTION Enumerates every site in the tenant (SharePoint sites and OneDrive personal sites) and the tenant's verified domains, then starts a child orchestration with one activity per site (Push-DBCacheSharePointSiteSharingLinks). Each site activity scans its own drives for shared - items and returns the sharing-link rows; a single PostExecution - (Push-StoreSharePointSharingLinks) aggregates all sites and writes the SharePointSharingLinks - cache once. Scanning all sites inline in one activity buffers the whole tenant's file tree and - OOM-kills the worker on large tenants - per-site fan-out bounds memory and runtime per activity. + items and writes sharing-link rows straight to the reporting DB as it goes. + + Site activities are resumable: each checkpoints its delta position after every persisted + page, so a run killed by a timeout or recycle loses at most one page — re-dispatching the + same task payload resumes where the dead run stopped, and completion is idempotent, so a + task-level retry mechanism can safely fire a site's task again at any time (large sites + previously hit the activity timeout, were retried from scratch and never completed). + Drives that completed a previous scan store their Graph deltaLink and are scanned + incrementally — only changed items are processed — with a periodic full rescan + (CIPP_SHARINGLINKS_FULLSCAN_DAYS, default 14) bounding any drift. + + Completion is tracked in the CippSharingLinksState table; the activity that finishes the + last site runs Push-StoreSharePointSharingLinks to prune rows for drives that no longer + exist and refresh the cached count. There is deliberately no PostExecution aggregation: + rows stream to storage per page, so the whole tenant's link set is never held in memory. .PARAMETER TenantFilter The tenant to cache sharing links for .PARAMETER QueueId The queue ID to update with total tasks (optional) + + .PARAMETER ForceFullSync + Ignore stored delta tokens and rescan every drive in full. #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] [string]$TenantFilter, - [string]$QueueId + [string]$QueueId, + [switch]$ForceFullSync ) try { + $TenantFilter = Resolve-CIPPSharingLinksTenantFilter -TenantFilter $TenantFilter Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Starting SharePoint/OneDrive sharing link collection (per-site fan-out)' -sev Debug # Verified domains, used by each site activity to tell internal from external recipients. @@ -46,6 +62,51 @@ function Set-CIPPDBCacheSharePointSharingLinks { return } + # One scan generation for the whole run. Every site task (including retried dispatches) + # carries this id; it stamps every row written, gates stale tasks from a superseded run, + # and drives the last-site-out finalisation. + $ScanId = [guid]::NewGuid().ToString() + + # With no delta state at all this is the first scan of the new design (or a fresh + # tenant): every drive scans full, so finalisation can safely sweep any row this scan + # did not write - including legacy rows for drives deleted before delta state existed. + # 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). + $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. + $SafeTenant = ConvertTo-CIPPODataFilterValue -Value $TenantFilter -Type String + foreach ($Prefix in @('done-', 'chk-')) { + $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 } + } + + Add-CIPPAzDataTableEntity @StateTable -Entity @{ + 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 + $Batch = foreach ($Site in $Sites) { [PSCustomObject]@{ FunctionName = 'DBCacheSharePointSiteSharingLinks' @@ -55,6 +116,9 @@ function Set-CIPPDBCacheSharePointSharingLinks { SiteUrl = $Site.webUrl IsPersonalSite = [bool]$Site.isPersonalSite InternalDomains = @($InternalDomains) + ScanId = $ScanId + Slice = 1 + ForceFull = [bool]$ForceFullSync QueueId = $QueueId QueueName = "Sharing Links - $($Site.webUrl)" } @@ -73,16 +137,10 @@ function Set-CIPPDBCacheSharePointSharingLinks { Batch = @($Batch) OrchestratorName = "SharePointSharingLinks_$TenantFilter" SkipLog = $true - PostExecution = @{ - FunctionName = 'StoreSharePointSharingLinks' - Parameters = @{ - TenantFilter = $TenantFilter - } - } } $null = Start-CIPPOrchestrator -InputObject $InputObject - Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Started sharing link collection across $($Sites.Count) sites" -sev Debug + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Started sharing link collection across $($Sites.Count) sites (scan $ScanId)" -sev Debug } catch { Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Failed to start SharePoint sharing link collection: $($_.Exception.Message)" -sev Error -LogData (Get-CippException -Exception $_) diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecAzBobbyTables.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecAzBobbyTables.ps1 index aaeae20cafbb7..4c7e5a7a4a7bf 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecAzBobbyTables.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecAzBobbyTables.ps1 @@ -43,6 +43,15 @@ function Invoke-ExecAzBobbyTables { if (!$Results) { $Results = "Function $Function executed successfully" } + # Drop it from the Get-CIPPTable cache so it gets recreated on next use. The table + # name comes from the request, so clear everything when it was not supplied. + if ($Function -eq 'Remove-AzDataTable') { + if ($Request.Body.TableName) { + Unregister-CIPPTable -TableName $Request.Body.TableName + } else { + Unregister-CIPPTable -All + } + } $StatusCode = [HttpStatusCode]::OK } catch { $Results = $_.Exception.Message diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecDurableFunctions.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecDurableFunctions.ps1 index f8655fbec78ac..274496c2fb679 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecDurableFunctions.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecDurableFunctions.ps1 @@ -150,6 +150,11 @@ function Invoke-ExecDurableFunctions { } else { Remove-AzDataTable @InstancesTable Remove-AzDataTable @HistoryTable + # Drop these from the Get-CIPPTable cache so they get recreated on next use. + Unregister-CIPPTable -TableName @( + ('{0}Instances' -f $FunctionName) + ('{0}History' -f $FunctionName) + ) $BlobContainer = '{0}-largemessages' -f $Function.Name if (Get-AzStorageContainer -Name $BlobContainer -Context $StorageContext -ErrorAction SilentlyContinue) { Write-Information "- Removing blob container: $BlobContainer" diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/MCP/Invoke-PublicMcpRegister.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/MCP/Invoke-PublicMcpRegister.ps1 index 7f01524715a9e..d98869d4b6101 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/MCP/Invoke-PublicMcpRegister.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/MCP/Invoke-PublicMcpRegister.ps1 @@ -58,29 +58,48 @@ function Invoke-PublicMcpRegister { return (New-CippMcpRegistrationError -Code 'invalid_redirect_uri' -Description 'redirect_uris is required and must contain at least one URI.' -Headers $CorsHeaders) } - # Allowlist: the exact callback URLs of known MCP clients, plus loopback (any port/path — - # desktop/CLI clients bind ephemeral ports; Entra applies its own loopback rules at authorize - # time and remains the final authority on every redirect). + # The "registered client" is always the instance's single MCP resource app registration. + $Table = Get-CippTable -tablename 'ApiClients' + $McpClient = Get-CIPPAzDataTableEntity @Table -Filter 'Enabled eq true' | + Where-Object { "$($_.MCPAllowed)" -eq 'True' } | Select-Object -First 1 + if (-not $McpClient) { + return (New-CippMcpRegistrationError -Code 'invalid_client_metadata' -Description 'No MCP resource client is configured on this instance. Enable "MCP Access Allowed" on an API client in CIPP and run Save to Azure.' -Headers $CorsHeaders) + } + + # Known client callbacks and loopback (any port/path) pass directly. Anything else must be a + # redirect URI on the MCP resource app registration — the same list Entra enforces at + # authorize time, covering callbacks that can't be enumerated statically (e.g. Copilot + # Studio's per-connector azure-apim suffix). That lookup is lazy and cached for 60s per + # runspace: this endpoint is anonymous, so junk-URI spam must not translate into Graph calls. $KnownClients = Get-CippMcpKnownClients $AllowedCallbacks = @($KnownClients.PublicClientRedirectUris) + @($KnownClients.ConfidentialRedirectUris) + $ResourceAppCallbacks = $null foreach ($Uri in $RedirectUris) { $Parsed = $null if (-not [System.Uri]::TryCreate($Uri, [System.UriKind]::Absolute, [ref]$Parsed)) { return (New-CippMcpRegistrationError -Code 'invalid_redirect_uri' -Description "Redirect URI '$Uri' is not a valid absolute URI." -Headers $CorsHeaders) } $IsLoopback = $Parsed.Scheme -eq 'http' -and $Parsed.Host -in @('127.0.0.1', 'localhost', '[::1]') - $IsKnown = $AllowedCallbacks -contains $Uri - if (-not ($IsKnown -or $IsLoopback)) { - return (New-CippMcpRegistrationError -Code 'invalid_redirect_uri' -Description "Redirect URI '$Uri' is not an allowed MCP client callback for this server." -Headers $CorsHeaders) - } - } + if ($IsLoopback -or $AllowedCallbacks -contains $Uri) { continue } - # The "registered client" is always the instance's single MCP resource app registration. - $Table = Get-CippTable -tablename 'ApiClients' - $McpClient = Get-CIPPAzDataTableEntity @Table -Filter 'Enabled eq true' | - Where-Object { "$($_.MCPAllowed)" -eq 'True' } | Select-Object -First 1 - if (-not $McpClient) { - return (New-CippMcpRegistrationError -Code 'invalid_client_metadata' -Description 'No MCP resource client is configured on this instance. Enable "MCP Access Allowed" on an API client in CIPP and run Save to Azure.' -Headers $CorsHeaders) + if ($null -eq $ResourceAppCallbacks) { + $Cache = $script:McpResourceAppRedirectCache + if ($Cache -and $Cache.AppId -eq "$($McpClient.RowKey)" -and ([DateTimeOffset]::UtcNow - $Cache.FetchedAt).TotalSeconds -lt 60) { + $ResourceAppCallbacks = $Cache.Uris + } else { + $ResourceAppCallbacks = @() + try { + $ResourceApp = New-GraphGetRequest -uri "https://graph.microsoft.com/v1.0/applications(appId='$($McpClient.RowKey)')?`$select=publicClient,web" -NoAuthCheck $true -AsApp $true + $ResourceAppCallbacks = @(@($ResourceApp.publicClient.redirectUris) + @($ResourceApp.web.redirectUris) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) + } catch { + Write-LogMessage -API 'PublicMcpRegister' -message "Could not read the MCP resource app's redirect URIs; validating against the built-in client list only. Error: $($_.Exception.Message)" -Sev 'Warning' + } + # Failures cache as empty so a Graph outage can't be amplified into repeated calls. + $script:McpResourceAppRedirectCache = @{ AppId = "$($McpClient.RowKey)"; Uris = $ResourceAppCallbacks; FetchedAt = [DateTimeOffset]::UtcNow } + } + } + if ($ResourceAppCallbacks -contains $Uri) { continue } + return (New-CippMcpRegistrationError -Code 'invalid_redirect_uri' -Description "Redirect URI '$Uri' is not an allowed MCP client callback for this server. To allow a custom client, add its callback to the MCP resource app registration (see the CIPP-API integration docs)." -Headers $CorsHeaders) } $ClientName = "$($Body.client_name ?? 'MCP client')" diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecAddTrustedIP.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecAddTrustedIP.ps1 index b44a44522925f..3aef21a287281 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecAddTrustedIP.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecAddTrustedIP.ps1 @@ -16,7 +16,8 @@ function Invoke-ExecAddTrustedIP { }) } - $tenantDomain = (Get-Tenants -TenantFilter $tenantfilter).defaultDomainName + $tenantDomain = if ($tenantfilter -eq 'AllTenants') { 'AllTenants' } + else { (Get-Tenants -TenantFilter $tenantfilter).defaultDomainName } if (-not $tenantDomain) { return ([HttpResponseContext]@{ StatusCode = [HttpStatusCode]::BadRequest diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecBrandingSettings.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecBrandingSettings.ps1 index cfd362267670a..886a547bcd20d 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecBrandingSettings.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecBrandingSettings.ps1 @@ -23,23 +23,13 @@ Function Invoke-ExecBrandingSettings { ) $DefaultCoverStock = '/reportImages/soc.jpg' - function ConvertTo-IdList { - param($Value) - # Preserve single-element string[] — `return (…)` unwraps it to a scalar - # string, which then breaks ConvertTo-IdListJson / [0] indexing. - $Ids = ConvertTo-CIPPCoverImageIdList -Value $Value - if ($null -eq $Ids) { - return , [string[]]@() - } - return , [string[]]@($Ids) - } - function ConvertTo-IdListJson { param($Value) - $Ids = ConvertTo-IdList -Value $Value - if ($null -eq $Ids) { $Ids = [string[]]@() } - # Ids is always a real string[] here — do not use -AsArray (that would - # wrap a one-element array as [["id"]]). + # Assign before casting. The id list comes back comma-wrapped so a one-element result + # cannot unwrap to a scalar, and assignment is what removes that wrapper - casting the + # wrapper straight to [string[]] coerces the inner array into one space-joined string. + $Ids = ConvertTo-CIPPCoverImageIdList -Value $Value + # Never -AsArray: that would wrap a one-element array as [["id"]]. return ConvertTo-Json -InputObject ([string[]]$Ids) -Compress } @@ -113,7 +103,7 @@ Function Invoke-ExecBrandingSettings { try { $Added = Add-CIPPImage -PartitionKey $PartitionKey -Data "$Data" if ($Kind -eq 'logo') { - $CurrentIds = ConvertTo-IdList -Value $BrandingConfig.logoImageIds + $CurrentIds = ConvertTo-CIPPCoverImageIdList -Value $BrandingConfig.logoImageIds $CurrentIds = @($Added.id) + @($CurrentIds | Where-Object { $_ -ne $Added.id }) $BrandingConfig | Add-Member -MemberType NoteProperty -Name 'logoImageIds' -Value (ConvertTo-IdListJson -Value $CurrentIds) -Force $BrandingConfig | Add-Member -MemberType NoteProperty -Name 'logoImageId' -Value $Added.id -Force @@ -121,7 +111,7 @@ Function Invoke-ExecBrandingSettings { $BrandingConfig.RowKey = 'BrandingSettings' Add-CIPPAzDataTableEntity @Table -Entity $BrandingConfig -Force | Out-Null } elseif ($Kind -eq 'cover') { - $CurrentIds = ConvertTo-IdList -Value $BrandingConfig.coverImageIds + $CurrentIds = ConvertTo-CIPPCoverImageIdList -Value $BrandingConfig.coverImageIds $CurrentIds = @($Added.id) + @($CurrentIds | Where-Object { $_ -ne $Added.id }) $BrandingConfig | Add-Member -MemberType NoteProperty -Name 'coverImageIds' -Value (ConvertTo-IdListJson -Value $CurrentIds) -Force $BrandingConfig | Add-Member -MemberType NoteProperty -Name 'coverImageId' -Value $Added.id -Force @@ -147,7 +137,12 @@ Function Invoke-ExecBrandingSettings { if ($Kind -eq 'logo') { $PartitionKey = 'logo' Remove-CIPPImage -PartitionKey $PartitionKey -Id $ImageId - $CurrentIds = @(ConvertTo-IdList -Value $BrandingConfig.logoImageIds | Where-Object { $_ -ne $ImageId }) + # .Where() rather than a pipe. The id list is returned comma-wrapped so a + # one-element result cannot unwrap to a scalar, and piping that hands + # Where-Object the whole array as one item: `$_ -ne $ImageId` then compares an + # array to a string, which passes every id through as a single value and + # serialises them space-joined into one bogus id, emptying the gallery. + $CurrentIds = [string[]]@((ConvertTo-CIPPCoverImageIdList -Value $BrandingConfig.logoImageIds).Where({ $_ -ne $ImageId })) $BrandingConfig | Add-Member -MemberType NoteProperty -Name 'logoImageIds' -Value (ConvertTo-IdListJson -Value $CurrentIds) -Force if ("$($BrandingConfig.logoImageId)" -eq $ImageId) { $BrandingConfig | Add-Member -MemberType NoteProperty -Name 'logoImageId' -Value '' -Force @@ -155,7 +150,8 @@ Function Invoke-ExecBrandingSettings { } elseif ($Kind -eq 'cover') { $PartitionKey = 'brandingCover' Remove-CIPPImage -PartitionKey $PartitionKey -Id $ImageId - $CurrentIds = @(ConvertTo-IdList -Value $BrandingConfig.coverImageIds | Where-Object { $_ -ne $ImageId }) + # See the logo branch above for why this is .Where() and not a pipe. + $CurrentIds = [string[]]@((ConvertTo-CIPPCoverImageIdList -Value $BrandingConfig.coverImageIds).Where({ $_ -ne $ImageId })) $BrandingConfig | Add-Member -MemberType NoteProperty -Name 'coverImageIds' -Value (ConvertTo-IdListJson -Value $CurrentIds) -Force if ("$($BrandingConfig.coverImageId)" -eq $ImageId) { $BrandingConfig | Add-Member -MemberType NoteProperty -Name 'coverImageId' -Value '' -Force @@ -329,7 +325,7 @@ Function Invoke-ExecBrandingSettings { } if (-not $ErrorMessage -and $Request.Body.PSObject.Properties.Name -contains 'logoImageIds') { - $LogoIds = ConvertTo-IdList -Value $Request.Body.logoImageIds + $LogoIds = ConvertTo-CIPPCoverImageIdList -Value $Request.Body.logoImageIds if ($LogoIds.Count -eq 0 -or (Test-ImageIdsExist -PartitionKey 'logo' -Ids $LogoIds)) { $BrandingConfig | Add-Member -MemberType NoteProperty -Name 'logoImageIds' -Value (ConvertTo-IdListJson -Value $LogoIds) -Force $Updated = $true @@ -354,7 +350,7 @@ Function Invoke-ExecBrandingSettings { } if (-not $ErrorMessage -and $Request.Body.PSObject.Properties.Name -contains 'coverImageIds') { - $CoverIds = ConvertTo-IdList -Value $Request.Body.coverImageIds + $CoverIds = ConvertTo-CIPPCoverImageIdList -Value $Request.Body.coverImageIds if ($CoverIds.Count -eq 0 -or (Test-ImageIdsExist -PartitionKey 'brandingCover' -Ids $CoverIds)) { $BrandingConfig | Add-Member -MemberType NoteProperty -Name 'coverImageIds' -Value (ConvertTo-IdListJson -Value $CoverIds) -Force $Updated = $true @@ -398,11 +394,11 @@ Function Invoke-ExecBrandingSettings { } } 'Reset' { - $LogoIds = ConvertTo-IdList -Value $BrandingConfig.logoImageIds + $LogoIds = ConvertTo-CIPPCoverImageIdList -Value $BrandingConfig.logoImageIds if ($BrandingConfig.logoImageId) { $LogoIds = @("$($BrandingConfig.logoImageId)") + @($LogoIds | Where-Object { $_ -ne "$($BrandingConfig.logoImageId)" }) } - $CoverIds = ConvertTo-IdList -Value $BrandingConfig.coverImageIds + $CoverIds = ConvertTo-CIPPCoverImageIdList -Value $BrandingConfig.coverImageIds if ($BrandingConfig.coverImageId) { $CoverIds = @("$($BrandingConfig.coverImageId)") + @($CoverIds | Where-Object { $_ -ne "$($BrandingConfig.coverImageId)" }) } diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ExecModifyCalPerms.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ExecModifyCalPerms.ps1 index 0325b100c5cea..e584b3457eb9c 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ExecModifyCalPerms.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ExecModifyCalPerms.ps1 @@ -25,7 +25,6 @@ function Invoke-ExecModifyCalPerms { StatusCode = [HttpStatusCode]::BadRequest Body = @{'Results' = @('Username is required') } }) - return } try { @@ -43,7 +42,6 @@ function Invoke-ExecModifyCalPerms { StatusCode = [HttpStatusCode]::NotFound Body = @{'Results' = @("Failed to get user ID: $($ErrorMessage.NormalizedError)") } }) - return } $Results = [System.Collections.Generic.List[string]]::new() @@ -87,7 +85,8 @@ function Invoke-ExecModifyCalPerms { UserID = $UserId folderName = $FolderName UserToGetPermissions = $TargetUser - LoggingName = $TargetUser + # TargetUser may be a recipient id, so log the display name the caller saw + LoggingName = $Permission.DisplayName ?? $TargetUser Permissions = $PermissionLevel CanViewPrivateItems = $CanViewPrivateItems SendNotificationToUser = $SendNotificationToUser diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ExecModifyContactPerms.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ExecModifyContactPerms.ps1 index badb034c48f43..ca5cd0247d5c7 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ExecModifyContactPerms.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ExecModifyContactPerms.ps1 @@ -87,7 +87,8 @@ function Invoke-ExecModifyContactPerms { UserID = $UserId folderName = $FolderName UserToGetPermissions = $TargetUser - LoggingName = $TargetUser + # TargetUser may be a recipient id, so log the display name the caller saw + LoggingName = $Permission.DisplayName ?? $TargetUser Permissions = $PermissionLevel SendNotificationToUser = $SendNotificationToUser } diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ListCalendarPermissions.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ListCalendarPermissions.ps1 index 803a7665e2815..1e96c919ce973 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ListCalendarPermissions.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ListCalendarPermissions.ps1 @@ -42,12 +42,23 @@ function Invoke-ListCalendarPermissions { }) } - # Original live query logic for specific user + # Original live query logic for specific user. + # -Select everywhere: Get-Mailbox alone is 340 properties (~15 KB) and MailboxInfo repeats + # on every permission row. $GetCalParam = @{Identity = $UserID; FolderScope = 'Calendar' } - $CalendarFolder = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-MailboxFolderStatistics' -anchor $UserID -cmdParams $GetCalParam | Select-Object -First 1 -ExcludeProperty *data.type* + $CalendarFolders = @(New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-MailboxFolderStatistics' -anchor $UserID -cmdParams $GetCalParam -Select 'Name,FolderType') + # FolderType is an internal enum and stays English whatever the mailbox language, so it + # finds the calendar root where the folder name cannot. + $CalendarFolder = $CalendarFolders | Where-Object { $_.FolderType -eq 'Calendar' } | Select-Object -First 1 + if (-not $CalendarFolder) { $CalendarFolder = $CalendarFolders | Select-Object -First 1 } $CalParam = @{Identity = "$($UserID):\$($CalendarFolder.name)" } - $Mailbox = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-Mailbox' -cmdParams @{Identity = $UserID } - $GraphRequest = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-MailboxFolderPermission' -anchor $UserID -cmdParams $CalParam -UseSystemMailbox $true | Select-Object Identity, User, AccessRights, FolderName, @{ Name = 'MailboxInfo'; Expression = { $Mailbox } } + $MailboxSelect = 'DisplayName,UserPrincipalName,PrimarySmtpAddress,Alias,Identity,Guid,ExchangeGuid,ExternalDirectoryObjectId,RecipientType,RecipientTypeDetails' + $Mailbox = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-Mailbox' -cmdParams @{Identity = $UserID } -Select $MailboxSelect + $Permissions = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-MailboxFolderPermission' -anchor $UserID -cmdParams $CalParam -UseSystemMailbox $true -Select 'Identity,User,AccessRights,FolderName' + # UserId is what the remove action sends back; without it Exchange only has a display name, + # which it cannot resolve when two recipients share one. + $Permissions = Resolve-CIPPFolderPermissionUser -TenantFilter $TenantFilter -FolderIdentity $CalParam.Identity -Permissions $Permissions + $GraphRequest = $Permissions | Select-Object Identity, User, UserId, AccessRights, FolderName, @{ Name = 'MailboxInfo'; Expression = { $Mailbox } } Write-LogMessage -API $APIName -tenant $TenantFilter -message "Calendar permissions listed for $($TenantFilter)" -sev Debug $StatusCode = [HttpStatusCode]::OK diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ListContactPermissions.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ListContactPermissions.ps1 index ea70f90b71253..bee47059e20e7 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ListContactPermissions.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ListContactPermissions.ps1 @@ -15,11 +15,22 @@ Function Invoke-ListContactPermissions { $TenantFilter = $Request.Query.tenantFilter try { + # -Select everywhere: the Contacts scope returns eight folders of ~90 properties, Get-Mailbox + # alone is 340, and MailboxInfo repeats on every permission row. $GetContactParam = @{Identity = $UserID; FolderScope = 'Contacts' } - $ContactFolder = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-MailboxFolderStatistics' -anchor $UserID -cmdParams $GetContactParam | Select-Object -First 1 -ExcludeProperty *data.type* + $ContactFolders = @(New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-MailboxFolderStatistics' -anchor $UserID -cmdParams $GetContactParam -Select 'Name,FolderType') + # FolderType is an internal enum and stays English whatever the mailbox language, so it finds + # the contacts root among the seven siblings (QuickContacts, GalContacts, RecipientCache...). + $ContactFolder = $ContactFolders | Where-Object { $_.FolderType -eq 'Contacts' } | Select-Object -First 1 + if (-not $ContactFolder) { $ContactFolder = $ContactFolders | Select-Object -First 1 } $ContactParam = @{Identity = "$($UserID):\$($ContactFolder.name)" } - $Mailbox = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-Mailbox' -cmdParams @{Identity = $UserID } - $GraphRequest = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-MailboxFolderPermission' -anchor $UserID -cmdParams $ContactParam -UseSystemMailbox $true | Select-Object Identity, User, AccessRights, FolderName, @{ Name = 'MailboxInfo'; Expression = { $Mailbox } } + $MailboxSelect = 'DisplayName,UserPrincipalName,PrimarySmtpAddress,Alias,Identity,Guid,ExchangeGuid,ExternalDirectoryObjectId,RecipientType,RecipientTypeDetails' + $Mailbox = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-Mailbox' -cmdParams @{Identity = $UserID } -Select $MailboxSelect + $Permissions = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-MailboxFolderPermission' -anchor $UserID -cmdParams $ContactParam -UseSystemMailbox $true -Select 'Identity,User,AccessRights,FolderName' + # UserId is what the remove action sends back; without it Exchange only has a display name, + # which it cannot resolve when two recipients share one. + $Permissions = Resolve-CIPPFolderPermissionUser -TenantFilter $TenantFilter -FolderIdentity $ContactParam.Identity -Permissions $Permissions + $GraphRequest = $Permissions | Select-Object Identity, User, UserId, AccessRights, FolderName, @{ Name = 'MailboxInfo'; Expression = { $Mailbox } } Write-LogMessage -API $APIName -tenant $TenantFilter -message "Contact permissions listed for $($TenantFilter)" -sev Debug $StatusCode = [HttpStatusCode]::OK diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ExecAddCippCveException.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ExecAddCippCveException.ps1 index 060ac28b2d690..e0433cc56f2e6 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ExecAddCippCveException.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ExecAddCippCveException.ps1 @@ -27,7 +27,6 @@ function Invoke-ExecAddCippCveException { } $CveExceptionsTable = Get-CIPPTable -TableName 'CveExceptions' - $CveCacheTable = Get-CIPPTable -TableName 'CveCache' # Load all existing exceptions for this CVE $AllCveExceptions = Get-CIPPAzDataTableEntity @CveExceptionsTable -Filter "PartitionKey eq '$CveId'" @@ -40,9 +39,20 @@ function Invoke-ExecAddCippCveException { @($TenantFilter) } 'AllAffected' { - $RawCveData = Get-CIPPDbItem -TenantFilter 'allTenants' -Type 'DefenderCVEs' | Where-Object { $_.RowKey -ne 'DefenderCVEs-Count' } - $AffectedEntries = $RawCveData.Data | ConvertFrom-Json | -Filter "PartitionKey eq '$CveId'" - @($AffectedEntries | Select-Object -ExpandProperty customerId -Unique) + # One cached row exists per (tenant x CVE) and the CVE id lives inside the + # Data JSON, so it cannot be filtered server-side. A substring probe skips + # the vast majority of rows without deserialising them (the writer stores + # Data with -Compress, so the pair carries no whitespace), and the parse + # confirms the match - the whole cache is never held parsed at once. + $AffectedTenants = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + $Needle = '"cveId":"{0}"' -f $CveId + foreach ($Row in Get-CIPPDbItem -TenantFilter 'allTenants' -Type 'DefenderCVEs') { + if ($Row.RowKey -eq 'DefenderCVEs-Count' -or -not $Row.Data) { continue } + if ($Row.Data.IndexOf($Needle, [System.StringComparison]::OrdinalIgnoreCase) -lt 0) { continue } + $Item = $Row.Data | ConvertFrom-Json + if ($Item.cveId -eq $CveId -and $Item.customerId) { [void]$AffectedTenants.Add([string]$Item.customerId) } + } + @($AffectedTenants) } 'Global' { @('ALL') diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ExecBECCheck.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ExecBECCheck.ps1 index dbc08b19233b3..00058a5319df9 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ExecBECCheck.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ExecBECCheck.ps1 @@ -5,7 +5,7 @@ Function Invoke-ExecBECCheck { .ROLE Identity.User.Read .DESCRIPTION - Returns the business email compromise assessment for a user: recent sign-ins, mailbox rules, added applications and password changes. If no cached result exists the check is queued as a background job and the response reports it as waiting, so poll rather than expecting results on the first call. Pass overwrite=true to force a fresh run. + Returns the business email compromise assessment for a user: sign-ins with a location analysis against the user's assigned usage location, mailbox rules and rule changes, trusted/blocked sender changes, OneDrive and SharePoint sharing link activity, added applications matched against the known-malicious catalog, MFA methods, Intune devices, sent mail, and tenant-wide password changes. If no cached result exists the check is queued as a background job and the response reports it as waiting, so poll rather than expecting results on the first call. Pass overwrite=true to force a fresh run. #> [CmdletBinding()] param($Request, $TriggerMetadata) diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSharepointAdminUrl.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSharepointAdminUrl.ps1 index a630624ad9533..c0e3d31bd1daa 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSharepointAdminUrl.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSharepointAdminUrl.ps1 @@ -34,7 +34,19 @@ function Invoke-ListSharepointAdminUrl { throw "Tenant '$TenantFilter' was not found." } - if ($Tenant.SharepointAdminUrl) { + # This cache has no expiry, so a value stored before sovereign clouds were handled would be + # a sharepoint.com URL that never heals (issue #269). The tenant's initial domain shares its + # TLD with its SharePoint domain, so a mismatch means the cached value predates the fix - + # re-resolve and overwrite it. Compared on TLD, not the full domain, because DoD is + # sharepoint-mil.us against an onmicrosoft.us tenant. + $CachedUrlIsStale = $false + if ($Tenant.SharepointAdminUrl -and $Tenant.initialDomainName) { + $ExpectedTld = (Get-CIPPSharePointDomain -TenantDomain $Tenant.initialDomainName) -split '\.' | Select-Object -Last 1 + $CachedTld = ([uri]$Tenant.SharepointAdminUrl).Host -split '\.' | Select-Object -Last 1 + $CachedUrlIsStale = $CachedTld -ne $ExpectedTld + } + + if ($Tenant.SharepointAdminUrl -and -not $CachedUrlIsStale) { $AdminUrl = $Tenant.SharepointAdminUrl } else { # Throws rather than returning a placeholder if the name can't be resolved, so we never diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Tenant/Invoke-ListTenants.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Tenant/Invoke-ListTenants.ps1 index 5600b161541bf..70a461acb5c5a 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Tenant/Invoke-ListTenants.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Tenant/Invoke-ListTenants.ps1 @@ -136,9 +136,7 @@ function Invoke-ListTenants { $Body = $Tenants } if ($Request.Query.Mode -eq 'TenantList') { - # Index tenant group membership by customerId so each tenant can carry the - # groups it belongs to. Get-TenantGroups is cached and already scoped to the - # groups the calling user is allowed to see, so restricted users only get theirs. + # Get-TenantGroups is cached and already scoped to the groups the caller may see. $GroupsByCustomerId = @{} try { foreach ($Group in @(Get-TenantGroups)) { @@ -157,10 +155,8 @@ function Invoke-ListTenants { Write-LogMessage -headers $Headers -API $APIName -message "Failed to retrieve tenant groups for the tenant list. The error is: $($_.Exception.Message)" -Sev 'Warning' } - # add portal link properties - # The unary comma on tenantGroups is required: Select-Object unrolls calculated - # property values, which would turn a single group into a bare object and no - # groups into $null instead of an empty array. + # add portal link properties. The unary comma on tenantGroups is required: + # Select-Object unrolls calculated property values. $Body = $Body | Select-Object *, @{Name = 'tenantGroups'; Expression = { , @($GroupsByCustomerId[$_.customerId] | Sort-Object -Property Name) } }, @{Name = 'portal_m365'; Expression = { "https://admin.cloud.microsoft/?delegatedOrg=$($_.initialDomainName)" } }, @{Name = 'portal_exchange'; Expression = { "https://admin.cloud.microsoft/exchange?delegatedOrg=$($_.initialDomainName)" } }, @@ -175,7 +171,16 @@ function Invoke-ListTenants { # tenant - it has to be resolved through Graph. Hand out the cached URL when we # have one so the link behaves like every other portal, and fall back to the # endpoint that resolves (and caches) it on first use. - if ($_.SharepointAdminUrl) { $_.SharepointAdminUrl } else { "/api/ListSharePointAdminUrl?tenantFilter=$($_.defaultDomainName)" } + # + # A cached URL whose TLD does not match the tenant's own was stored before + # sovereign clouds were handled (a .com link for a sharepoint.de tenant, + # issue #269). Send those back through the resolver, which overwrites the row. + $CachedAdminUrl = $_.SharepointAdminUrl + if ($CachedAdminUrl -and $_.initialDomainName) { + $ExpectedTld = (Get-CIPPSharePointDomain -TenantDomain $_.initialDomainName) -split '\.' | Select-Object -Last 1 + if ((([uri]$CachedAdminUrl).Host -split '\.' | Select-Object -Last 1) -ne $ExpectedTld) { $CachedAdminUrl = $null } + } + if ($CachedAdminUrl) { $CachedAdminUrl } else { "/api/ListSharePointAdminUrl?tenantFilter=$($_.defaultDomainName)" } } }, @{Name = 'portal_platform'; Expression = { "https://admin.powerplatform.microsoft.com/account/login/$($_.customerId)" } }, diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Conditional/Invoke-ExecEditCAPolicyFull.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Conditional/Invoke-ExecEditCAPolicyFull.ps1 index 40c347946fe25..73dcab5ceadd6 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Conditional/Invoke-ExecEditCAPolicyFull.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Conditional/Invoke-ExecEditCAPolicyFull.ps1 @@ -38,7 +38,13 @@ function Invoke-ExecEditCAPolicyFull { # Strip read-only properties that cannot be PATCHed $CleanBody = $PolicyBody | Select-Object -Property * -ExcludeProperty id, createdDateTime, modifiedDateTime, templateId - $RawJSON = ConvertTo-Json -InputObject $CleanBody -Depth 20 -Compress + # Round-trip so the canonicalizer always sees a PSCustomObject, whatever shape the request + # body deserialised into, then apply the same rules the template deploy path uses: managed + # keys the body omits are restored as their cleared form (that is how a PATCH clears an + # assignment) and a condition block Graph would reject half-populated becomes null instead. + $PolicyObject = ConvertTo-Json -InputObject $CleanBody -Depth 20 | ConvertFrom-Json + Format-CIPPCAPolicy -Policy $PolicyObject + $RawJSON = ConvertTo-Json -InputObject $PolicyObject -Depth 20 -Compress $null = New-GraphPOSTRequest ` -uri "https://graph.microsoft.com/beta/identity/conditionalAccess/policies/$PolicyId" ` diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ExecCopilotSettings.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ExecCopilotSettings.ps1 index 7ba11d2e1c2ac..229aff09dc06b 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ExecCopilotSettings.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ExecCopilotSettings.ps1 @@ -31,13 +31,31 @@ function Invoke-ExecCopilotSettings { }) } + # Web search is three-state (0 = enabled everywhere, 1 = disabled everywhere, 2 = disabled in + # Copilot Work mode only), and image generation inverts the usual toggle ('1' disables it). + # These are only used for the log line - the value is passed through to Graph either way. + $WebSearchStates = @{ + '0' = 'Enabled in Copilot and Copilot Chat' + '1' = 'Disabled in Copilot and Copilot Chat' + '2' = 'Disabled in Copilot Work mode, Enabled in Copilot Chat' + } + $InvertedToggleSettings = @('microsoft.copilot.imagegeneration') + # 'clear'/'notconfigured'/blank -> remove the value (Not configured); otherwise set the string value. if ([string]::IsNullOrWhiteSpace($Value) -or $Value -in @('clear', 'notconfigured')) { $PatchBody = [pscustomobject]@{ value = $null } | ConvertTo-Json -Compress $StateText = 'Not configured' } else { $PatchBody = [pscustomobject]@{ value = [string]$Value } | ConvertTo-Json -Compress - $StateText = if ($Value -eq '1') { 'Enabled' } elseif ($Value -eq '0') { 'Disabled' } else { "value '$Value'" } + $StateText = if ($SettingId -eq 'microsoft.copilot.allowwebsearch' -and $WebSearchStates[[string]$Value]) { + $WebSearchStates[[string]$Value] + } elseif ($Value -eq '1') { + if ($SettingId -in $InvertedToggleSettings) { 'Disabled' } else { 'Enabled' } + } elseif ($Value -eq '0') { + if ($SettingId -in $InvertedToggleSettings) { 'Enabled' } else { 'Disabled' } + } else { + "value '$Value'" + } } # The Copilot admin APIs currently require delegated auth, so use the default delegated token. diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ListCopilotSettings.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ListCopilotSettings.ps1 index f9dd2608a845f..05456775b0e20 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ListCopilotSettings.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ListCopilotSettings.ps1 @@ -40,12 +40,23 @@ function Invoke-ListCopilotSettings { $BulkResults = @() } - # Web search is a three-state setting (values match the config.office.com policy options); - # the other settings are plain 1/0 toggles. + # Web search is a three-state setting. The values are the config.office.com policy options in + # order, zero-indexed: 0 is the first (most permissive) option, 2 the last. Verified against a + # tenant holding '0', which the Microsoft 365 Apps admin center reports as fully enabled. $WebSearchStates = @{ - '2' = 'Enabled in Copilot and Copilot Chat' + '0' = 'Enabled in Copilot and Copilot Chat' '1' = 'Disabled in Copilot and Copilot Chat' - '0' = 'Disabled in Copilot Work mode, Enabled in Copilot Chat' + '2' = 'Disabled in Copilot Work mode, Enabled in Copilot Chat' + } + + # Designer image generation inverts the usual toggle: '1' disables it, '0' enables it. + $InvertedToggleSettings = @('microsoft.copilot.imagegeneration') + + # 'Block Copilot Access to Open Content' is phrased as a block, so plain Enabled/Disabled reads + # ambiguously. Use the same wording as the standard's own option list. + $BlockToggleStates = @{ + '1' = 'Blocked' + '0' = 'Allowed' } $Results = foreach ($Setting in $PolicySettings) { @@ -57,10 +68,12 @@ function Invoke-ListCopilotSettings { 'Not configured' } elseif ($Setting.id -eq 'microsoft.copilot.allowwebsearch' -and $WebSearchStates[[string]$Value]) { $WebSearchStates[[string]$Value] + } elseif ($Setting.id -eq 'microsoft.copilot.blockaccesstoopenfiles' -and $BlockToggleStates[[string]$Value]) { + $BlockToggleStates[[string]$Value] } elseif ($Value -eq '1') { - 'Enabled' + if ($Setting.id -in $InvertedToggleSettings) { 'Disabled' } else { 'Enabled' } } elseif ($Value -eq '0') { - 'Disabled' + if ($Setting.id -in $InvertedToggleSettings) { 'Enabled' } else { 'Disabled' } } else { "Custom ($Value)" } diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ListTenantAlignment.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ListTenantAlignment.ps1 index 9dd728b3142b0..d74b06b336b06 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ListTenantAlignment.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ListTenantAlignment.ps1 @@ -6,12 +6,21 @@ function Invoke-ListTenantAlignment { Tenant.Standards.Read .DESCRIPTION Lists tenant alignment data showing how well tenants conform to their assigned standards templates. + + Pass summary=true for the estate roll-up only: per-tenant averages collapsed into score + buckets, the overall average, the lowest-scoring tenants and the pending-deviation totals. + The row list is one entry per tenant per standard, so an estate-wide caller that only + renders those aggregates would otherwise pull tenants x standards rows to compute a + handful of numbers. #> [CmdletBinding()] param($Request, $TriggerMetadata) $APIName = $Request.Params.CIPPEndpoint $Granular = $Request.Query.granular -eq $true + $Summary = $Request.Query.summary -eq $true + # Granular flattens to one row per standard; it is a detail view and never a summary source. + if ($Summary) { $Granular = $false } try { # Use the new Get-CIPPTenantAlignment function to get alignment data $AlignmentData = Get-CIPPTenantAlignment @@ -110,6 +119,68 @@ function Invoke-ListTenantAlignment { } } + if ($Summary) { + # Average a tenant's rows before bucketing: five templates is still one tenant. + $ByTenant = @{} + $PendingByTenant = @{} + $PendingDeviations = 0 + foreach ($Row in @($Results)) { + $Key = [string]$Row.tenantFilter + if ([string]::IsNullOrWhiteSpace($Key)) { continue } + + $Score = [double](($Row.combinedAlignmentScore ?? $Row.alignmentScore) ?? 0) + if (-not $ByTenant.ContainsKey($Key)) { $ByTenant[$Key] = @{ Total = 0.0; Count = 0 } } + $ByTenant[$Key].Total += $Score + $ByTenant[$Key].Count++ + + $Pending = [int]($Row.pendingDeviationsCount ?? 0) + if ($Pending -gt 0) { + $PendingDeviations += $Pending + $PendingByTenant[$Key] = ($PendingByTenant[$Key] ?? 0) + $Pending + } + } + + $TenantLookupByDomain = @{} + foreach ($KnownTenant in (Get-Tenants -IncludeErrors)) { + if ($KnownTenant.defaultDomainName) { $TenantLookupByDomain[$KnownTenant.defaultDomainName] = $KnownTenant } + } + + $Scores = [System.Collections.Generic.List[object]]::new() + foreach ($Key in $ByTenant.Keys) { + $Bucket = $ByTenant[$Key] + $Average = if ($Bucket.Count) { [math]::Round($Bucket.Total / $Bucket.Count) } else { 0 } + $Scores.Add([PSCustomObject]@{ + Tenant = $Key + Name = $TenantLookupByDomain[$Key].displayName ?? $Key + Score = [int]$Average + }) + } + + $Buckets = [ordered]@{ Strong = 0; Good = 0; Weak = 0; Poor = 0 } + foreach ($Entry in $Scores) { + if ($Entry.Score -ge 90) { $Buckets['Strong']++ } + elseif ($Entry.Score -ge 75) { $Buckets['Good']++ } + elseif ($Entry.Score -ge 50) { $Buckets['Weak']++ } + else { $Buckets['Poor']++ } + } + + $Overall = if ($Scores.Count) { + [int][math]::Round((($Scores | Measure-Object -Property Score -Sum).Sum) / $Scores.Count) + } else { 0 } + + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::OK + Body = @{ + Average = $Overall + ScoredTenantCount = $Scores.Count + Buckets = [PSCustomObject]$Buckets + Lowest = @($Scores | Sort-Object -Property Score, Tenant | Select-Object -First 4) + PendingDeviations = $PendingDeviations + PendingTenantCount = $PendingByTenant.Keys.Count + } + }) + } + return ([HttpResponseContext]@{ StatusCode = [HttpStatusCode]::OK Body = @($Results) diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Tests/Invoke-ListTestResultsTenants.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Tests/Invoke-ListTestResultsTenants.ps1 index 95deffb9ba997..0c6523350c252 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Tests/Invoke-ListTestResultsTenants.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Tests/Invoke-ListTestResultsTenants.ps1 @@ -30,6 +30,7 @@ function Invoke-ListTestResultsTenants { $SummaryOnly = $Request.Query.summaryOnly ?? $Request.Body.summaryOnly $RowStatusRaw = $Request.Query.rowStatus ?? $Request.Body.rowStatus $IncludeCounts = $Request.Query.includeCounts ?? $Request.Body.includeCounts + $CountsOnly = $Request.Query.countsOnly ?? $Request.Body.countsOnly # Normalise inputs that may arrive as a single string, a comma-delimited string, or an # array of strings / {value,label} objects (the frontend autocomplete posts the latter). @@ -57,6 +58,8 @@ function Invoke-ListTestResultsTenants { if ($Category) { $Params.Category = $Category } if ([string]$SummaryOnly -eq 'true') { $Params.SummaryOnly = $true } if ([string]$IncludeCounts -eq 'true') { $Params.IncludeCounts = $true } + # countsOnly returns the aggregates with no rows, for callers that only render totals. + if ([string]$CountsOnly -eq 'true') { $Params.CountsOnly = $true } # Restrict to tenants the caller is allowed to see. Test-CIPPAccess returns the list of # permitted customerIds, or 'AllTenants' for unrestricted users. Passed into the query so @@ -70,7 +73,7 @@ function Invoke-ListTestResultsTenants { $Response = Get-CIPPTestResultsTenants @Params $StatusCode = [HttpStatusCode]::OK - if ($Params.IncludeCounts) { + if ($Params.IncludeCounts -or $Params.CountsOnly) { $Body = @{ Results = @($Response.Results); Counts = $Response.Counts } } else { $Body = @{ Results = @($Response) } diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardCopilotSettings.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardCopilotSettings.ps1 index 477a006931505..9b4795886e82d 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardCopilotSettings.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardCopilotSettings.ps1 @@ -8,7 +8,7 @@ function Invoke-CIPPStandardCopilotSettings { (Label) Configure Microsoft 365 Copilot policy settings .DESCRIPTION (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. - (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. 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. The exact accepted value per setting is a string (commonly "1"/"0") and should be validated against a Copilot-licensed tenant. + (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. 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. .NOTES CAT Copilot (M365) Standards @@ -18,8 +18,8 @@ function Invoke-CIPPStandardCopilotSettings { ADDEDCOMPONENT {"type":"autoComplete","multiple":false,"creatable":false,"label":"Pin Microsoft 365 Copilot Chat","name":"standards.CopilotSettings.copilotChatPinning","options":[{"label":"Do not configure","value":"donotconfigure"},{"label":"Enabled","value":"1"},{"label":"Disabled","value":"0"}]} {"type":"autoComplete","multiple":false,"creatable":false,"label":"Copilot Access to Open Content","name":"standards.CopilotSettings.blockAccessToOpenFiles","options":[{"label":"Do not configure","value":"donotconfigure"},{"label":"Block open content","value":"1"},{"label":"Allow open content","value":"0"}]} - {"type":"autoComplete","multiple":false,"creatable":false,"label":"Designer Image Generation","name":"standards.CopilotSettings.imageGeneration","options":[{"label":"Do not configure","value":"donotconfigure"},{"label":"Enabled","value":"1"},{"label":"Disabled","value":"0"}]} - {"type":"autoComplete","multiple":false,"creatable":false,"label":"Web Search in Copilot","name":"standards.CopilotSettings.allowWebSearch","options":[{"label":"Do not configure","value":"donotconfigure"},{"label":"Enabled in Microsoft 365 Copilot and Microsoft 365 Copilot Chat","value":"2"},{"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":"0"}]} + {"type":"autoComplete","multiple":false,"creatable":false,"label":"Designer Image Generation","name":"standards.CopilotSettings.imageGeneration","options":[{"label":"Do not configure","value":"donotconfigure"},{"label":"Disabled","value":"1"},{"label":"Enabled","value":"0"}]} + {"type":"autoComplete","multiple":false,"creatable":false,"label":"Web Search in Copilot","name":"standards.CopilotSettings.allowWebSearch","options":[{"label":"Do not configure","value":"donotconfigure"},{"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"}]} {"type":"autoComplete","multiple":false,"creatable":false,"label":"Admin Copilot in Microsoft 365 Admin Center","name":"standards.CopilotSettings.allowInAdminCenters","options":[{"label":"Do not configure","value":"donotconfigure"},{"label":"Enabled","value":"1"},{"label":"Disabled","value":"0"}]} IMPACT Medium Impact diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardDisableExchangeOnlinePowerShell.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardDisableExchangeOnlinePowerShell.ps1 index bd04a38fc7817..290032a1ff153 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardDisableExchangeOnlinePowerShell.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardDisableExchangeOnlinePowerShell.ps1 @@ -80,11 +80,9 @@ function Invoke-CIPPStandardDisableExchangeOnlinePowerShell { Write-LogMessage -API 'Standards' -tenant $Tenant -message "Started disabling Exchange Online PowerShell for $PowerShellEnabledCount users." -sev Info $Request = foreach ($User in $UsersWithPowerShell) { - # Set-User returns no body on success, and New-ExoBulkRequest only synthesises a - # { Success = $true } record when an OperationGuid was supplied. Without one every - # successful user came back as nothing at all, so a fully successful run still - # reported "0 out of N" with no errors to explain it. The UPN doubles as the batch - # correlation id so successes are counted and failures are attributable. + # New-ExoBulkRequest only emits a success record when an OperationGuid was given, + # and Set-User returns no body - so without one, a fully successful run reported + # "0 out of N". The UPN doubles as the correlation id. $Identity = if ($User.Guid) { $User.Guid } else { $User.UPN } @{ OperationGuid = $User.UPN diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardDisableM365GroupUsers.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardDisableM365GroupUsers.ps1 index 07346db2136bd..0ddfa0f5682a3 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardDisableM365GroupUsers.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardDisableM365GroupUsers.ps1 @@ -114,13 +114,10 @@ function Invoke-CIPPStandardDisableM365GroupUsers { } if (!$CurrentState) { - # The tenant has no Group.Unified directory setting yet, so create it with the - # values we want already in place. Reading /beta/settings straight back after a - # write returns nothing for ~10s (measured), so the old create-then-reread-then- - # patch sequence left $CurrentState null and blew up on the assignment below. - # New-GraphPostRequest hands back the created object, so no read-back is needed. - # Prefer the live template so the payload stays complete if Microsoft revises - # it; fall back to the values Microsoft shipped when this standard was written. + # Create it with the values already in place. /beta/settings is eventually + # consistent, so reading back after the write returned nothing and left + # $CurrentState null; the POST response is used instead. The live template + # keeps the payload complete if Microsoft revises it, with a built-in fallback. try { $Template = New-GraphGetRequest -Uri "https://graph.microsoft.com/beta/directorySettingTemplates/$GroupUnifiedTemplateId" -tenantid $Tenant $TemplateValues = @($Template.values) diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardReusableSettingsTemplate.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardReusableSettingsTemplate.ps1 index 99f4b13fa6705..6391728f0055c 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardReusableSettingsTemplate.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardReusableSettingsTemplate.ps1 @@ -41,6 +41,18 @@ function Invoke-CIPPStandardReusableSettingsTemplate { return $null } + # Dictionaries first: a Hashtable is IEnumerable, but foreach over one yields the hashtable + # itself, so the array branch below would recurse on identical input until the depth blows. + if ($InputObject -is [System.Collections.IDictionary]) { + $CleanMap = [ordered]@{} + foreach ($Key in @($InputObject.Keys)) { + if ($null -ne $InputObject[$Key]) { + $CleanMap[$Key] = Remove-CIPPNullProperties -InputObject $InputObject[$Key] + } + } + return [pscustomobject]$CleanMap + } + if ($InputObject -is [System.Collections.IEnumerable] -and $InputObject -isnot [string]) { $CleanArray = [System.Collections.Generic.List[object]]::new() foreach ($item in $InputObject) { @@ -84,17 +96,42 @@ function Invoke-CIPPStandardReusableSettingsTemplate { } $AllTemplateEntities = Get-CIPPAzDataTableEntity @Table -Filter "PartitionKey eq 'IntuneReusableSettingTemplate'" - $TemplateEntities = $AllTemplateEntities | - Where-Object { ($_.RowKey -in $SelectedTemplateIds) -and (-not [string]::IsNullOrWhiteSpace($_.JSON)) } | - ForEach-Object { $_.JSON } | - ConvertFrom-Json -ErrorAction SilentlyContinue - if (-not $TemplateEntities) { - Write-LogMessage -API 'Standards' -tenant $Tenant -message "Failed to resolve reusable settings templates: $($SelectedTemplateIds -join ', ')" -sev 'Error' - return $true + $EntityByRowKey = @{} + foreach ($Entity in @($AllTemplateEntities)) { + if ($Entity.RowKey) { $EntityByRowKey[[string]$Entity.RowKey] = $Entity } } - $CompareList = foreach ($TemplateEntity in $TemplateEntities) { + # Iterate the selected ids, not the rows that resolved. Alignment emits a key for every id in + # TemplateList, and a key with no compare row reports NOT FOUND and can never be cleared. + $CompareList = foreach ($TemplateId in $SelectedTemplateIds) { $Compare = $null + $Entity = $EntityByRowKey[[string]$TemplateId] + $TemplateEntity = if ($Entity -and -not [string]::IsNullOrWhiteSpace($Entity.JSON)) { + $Entity.JSON | ConvertFrom-Json -ErrorAction SilentlyContinue + } else { + $null + } + + if (-not $TemplateEntity) { + Write-LogMessage -API 'Standards' -tenant $Tenant -message "Failed to resolve reusable settings template $TemplateId." -sev 'Error' + [pscustomobject]@{ + MatchFailed = $true + displayname = $TemplateId + compare = [pscustomobject]@{ + MatchFailed = $true + Difference = 'The selected reusable settings template no longer exists in CIPP.' + } + rawJSON = $null + remediate = $Settings.remediate + alert = $Settings.alert + report = $Settings.report + templateId = $TemplateId + existingId = $null + Unresolved = $true + } + continue + } + $displayName = $TemplateEntity.DisplayName ?? $TemplateEntity.Name $RawJSON = $TemplateEntity.RawJSON ?? $TemplateEntity.JSON $BodyObject = $RawJSON | ConvertFrom-Json -ErrorAction SilentlyContinue @@ -126,13 +163,17 @@ function Invoke-CIPPStandardReusableSettingsTemplate { remediate = $Settings.remediate alert = $Settings.alert report = $Settings.report - templateId = $TemplateEntity.GUID + # The id the picker sent (the RowKey), never the GUID inside the stored JSON - + # alignment keys off TemplateList.value. + templateId = $TemplateId existingId = $Existing.id + Unresolved = $false } } if ($true -in $Settings.remediate) { - foreach ($Template in $CompareList | Where-Object -Property remediate -EQ $true) { + # Unresolved templates carry no body, so the create branch below would POST a null one. + foreach ($Template in $CompareList | Where-Object { $_.remediate -eq $true -and -not $_.Unresolved }) { $Body = $Template.rawJSON if ($Template.existingId) { diff --git a/Modules/CippExtensions/Public/Hudu/Invoke-HuduExtensionSync.ps1 b/Modules/CippExtensions/Public/Hudu/Invoke-HuduExtensionSync.ps1 index 5b33e81a4f8da..b2ff5e4133e91 100644 --- a/Modules/CippExtensions/Public/Hudu/Invoke-HuduExtensionSync.ps1 +++ b/Modules/CippExtensions/Public/Hudu/Invoke-HuduExtensionSync.ps1 @@ -780,7 +780,7 @@ function Invoke-HuduExtensionSync { if ($EnableCIPP) { $CIPPLinksFormatted.add((Get-HuduLinkBlock -URL "$($CIPPURL)/identity/administration/users/user?tenantFilter=$($Tenant.defaultDomainName)&userId=$($User.id)" -Icon 'far fa-eye' -Title 'CIPP - View User')) $CIPPLinksFormatted.add((Get-HuduLinkBlock -URL "$($CIPPURL)/identity/administration/users/user/edit?tenantFilter=$($Tenant.defaultDomainName)&userId=$($User.id)" -Icon 'fas fa-user-cog' -Title 'CIPP - Edit User')) - $CIPPLinksFormatted.add((Get-HuduLinkBlock -URL "$($CIPPURL)/identity/administration/users/user/bec?tenantFilter=$($Tenant.defaultDomainName)&userId=$($User.id))" -Icon 'fas fa-user-secret' -Title 'CIPP - BEC Tool')) + $CIPPLinksFormatted.add((Get-HuduLinkBlock -URL "$($CIPPURL)/identity/administration/users/user/bec?tenantFilter=$($Tenant.defaultDomainName)&userId=$($User.id)" -Icon 'fas fa-user-secret' -Title 'CIPP - BEC Tool')) } [System.Collections.Generic.List[PSCustomObject]]$UserLinksFormatted = @() diff --git a/Modules/CippExtensions/Public/NinjaOne/Invoke-NinjaOneTenantSync.ps1 b/Modules/CippExtensions/Public/NinjaOne/Invoke-NinjaOneTenantSync.ps1 index ab3d64cecf705..b6bdf1b9097f5 100644 --- a/Modules/CippExtensions/Public/NinjaOne/Invoke-NinjaOneTenantSync.ps1 +++ b/Modules/CippExtensions/Public/NinjaOne/Invoke-NinjaOneTenantSync.ps1 @@ -1557,8 +1557,10 @@ function Invoke-NinjaOneTenantSync { } catch { $SharePointTenantName = ($Customer.initialDomainName -split '\.')[0] if ($SharePointTenantName) { + # Sovereign clouds do not use sharepoint.com - map the initial domain's suffix. + $SharePointDomain = Get-CIPPSharePointDomain -TenantDomain $Customer.initialDomainName + $SharePointAdminUrl = "https://$SharePointTenantName-admin.$SharePointDomain" Write-Information "NinjaOneSync: Get-SharePointAdminLink failed for $($Customer.defaultDomainName), using fallback SharePoint admin URL '$SharePointAdminUrl'. Error: $($_.Exception.Message)" - $SharePointAdminUrl = "https://$SharePointTenantName-admin.sharepoint.com" } } @@ -1585,7 +1587,11 @@ function Invoke-NinjaOneTenantSync { }, @{ Name = 'SharePoint Admin' - Link = $SharePointAdminUrl ?? "https://$($Customer.defaultDomainName)-admin.sharepoint.com" + # No guess here: the old fallback pasted defaultDomainName in front of + # '-admin.sharepoint.com' ('contoso.onmicrosoft.com-admin.sharepoint.com') and + # assumed the commercial cloud. Unresolved links are dropped below instead - + # NinjaOne keeps whatever we write, so a bad URL sticks around in their portal. + Link = $SharePointAdminUrl Icon = 'fas fa-shapes' }, @{ @@ -1621,6 +1627,9 @@ function Invoke-NinjaOneTenantSync { ) + # Drop any portal we could not build a URL for rather than publishing a dead link. + $ManagementLinksData = @($ManagementLinksData | Where-Object { $_.Link }) + $M365LinksHTML = Get-NinjaOneLinks -Data $ManagementLinksData -Title 'Portals' -SmallCols 2 -MedCols 3 -LargeCols 3 -XLCols 3 $CIPPLinksData = @( @@ -2208,45 +2217,56 @@ function Invoke-NinjaOneTenantSync { $DeviceIdHeader = $ResolvedScanGroup.deviceIdHeader $CveIdHeader = $ResolvedScanGroup.cveIdHeader - $RawVulns = Get-CIPPDbItem -TenantFilter $TenantFilter -Type 'DefenderCVEs' | Where-Object { $_.RowKey -ne 'DefenderCVEs-Count' } - $AllVulns = $RawVulns.Data | ConvertFrom-Json - $CsvRows = [System.Collections.Generic.List[object]]::new() + $ExceptionsTable = Get-CIPPTable -TableName 'CveExceptions' + $AllExceptions = Get-CIPPAzDataTableEntity @ExceptionsTable + $ApplicableExceptions = $AllExceptions | Where-Object { $_.RowKey -eq $TenantFilter -or $_.RowKey -eq 'ALL' } + $ExceptedCveIds = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($Ex in @($ApplicableExceptions)) { + if ($Ex.cveId) { [void]$ExceptedCveIds.Add([string]$Ex.cveId) } + } + + # Fold the cached rows one at a time instead of materialising a parsed + # copy of every Data blob before the CSV build - only the CSV rows are + # needed, so each parsed graph is collectable as soon as its devices are + # folded (see Get-CIPPCVEReport for the same pattern). + $CsvRows = [System.Collections.Generic.List[object]]::new() + $VulnCount = 0 + $ExceptedCount = 0 + $SkippedCount = 0 + + foreach ($Row in Get-CIPPDbItem -TenantFilter $TenantFilter -Type 'DefenderCVEs') { + if ($Row.RowKey -eq 'DefenderCVEs-Count' -or -not $Row.Data) { continue } + $Item = $Row.Data | ConvertFrom-Json + $VulnCount++ + + if ([string]::IsNullOrWhiteSpace($Item.cveId)) { + $SkippedCount++ + continue + } + if ($ExceptedCveIds.Contains([string]$Item.cveId)) { + $ExceptedCount++ + continue + } + if ($Item.deviceDetailsJson) { + $Devices = ConvertFrom-Json $Item.deviceDetailsJson | Sort-Object -Property deviceName -Unique + foreach ($Dev in $Devices) { + [void]$CsvRows.Add([PSCustomObject]@{ + $DeviceIdHeader = $Dev.deviceName.Trim() + $CveIdHeader = $Item.cveId.Trim() + }) + } + } + } - if (-not $AllVulns) { + if ($VulnCount -eq 0) { Write-LogMessage -API 'NinjaOneSync' -tenant $TenantFilter -message 'CVE sync — no vulnerability data returned' -sev 'Warning' [void]$CsvRows.Add([PSCustomObject]@{ $DeviceIdHeader = "" $CveIdHeader = ""}) } else { - $ExceptionsTable = Get-CIPPTable -TableName 'CveExceptions' - $AllExceptions = Get-CIPPAzDataTableEntity @ExceptionsTable - $ApplicableExceptions = $AllExceptions | Where-Object { $_.RowKey -eq $TenantFilter -or $_.RowKey -eq 'ALL' } - - if ($ApplicableExceptions) { - $ExceptedCveIds = $ApplicableExceptions | Select-Object -ExpandProperty cveId -Unique - $BeforeCount = $AllVulns.Count - $AllVulns = $AllVulns | Where-Object { $_.cveId -notin $ExceptedCveIds } - Write-LogMessage -API 'NinjaOneSync' -tenant $TenantFilter -message "CVE sync — filtered $($BeforeCount - $AllVulns.Count) excepted CVEs, $($AllVulns.Count) remaining" -sev 'Info' - } - - $SkippedCount = 0 - - foreach ($Item in $AllVulns) { - if ([string]::IsNullOrWhiteSpace($Item.cveId)) { - $SkippedCount++ - continue - } - if ($Item.deviceDetailsJson) { - $Devices = ConvertFrom-Json $Item.deviceDetailsJson | Sort-Object -Property deviceName -Unique - foreach ($Dev in $Devices) { - [void]$CsvRows.Add([PSCustomObject]@{ - $DeviceIdHeader = $Dev.deviceName.Trim() - $CveIdHeader = $Item.cveId.Trim() - }) - } - } + if ($ExceptedCveIds.Count -gt 0) { + Write-LogMessage -API 'NinjaOneSync' -tenant $TenantFilter -message "CVE sync — filtered $ExceptedCount excepted CVEs, $($VulnCount - $ExceptedCount) remaining" -sev 'Info' } - if ($SkippedCount -gt 0) { Write-LogMessage -API 'NinjaOneSync' -tenant $TenantFilter -message "CVE sync — skipped $SkippedCount rows (missing deviceName or cveId)" -sev 'Warning' } diff --git a/Tests/Api/BrandingImageDelete.Tests.ps1 b/Tests/Api/BrandingImageDelete.Tests.ps1 new file mode 100644 index 0000000000000..28099b6393110 --- /dev/null +++ b/Tests/Api/BrandingImageDelete.Tests.ps1 @@ -0,0 +1,77 @@ +# Pester tests for the id-list bookkeeping in Invoke-ExecBrandingSettings' DeleteImage action. +# +# Deleting one image must remove exactly that id from the gallery list. The list is stored as JSON +# and read back through ConvertTo-CIPPCoverImageIdList, which returns the array comma-wrapped so a +# one-element result cannot unwrap to a scalar. Filtering that wrapper through a pipeline hands +# Where-Object the whole array as a single item, and the surviving ids get serialised space-joined +# into one bogus id - which resolves to nothing on the next read and empties the whole gallery. + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $ConverterPath = Get-ChildItem -Path (Join-Path $RepoRoot 'Modules') -Recurse -Filter 'ConvertTo-CIPPCoverImageIdList.ps1' -File -ErrorAction SilentlyContinue | + Select-Object -First 1 -ExpandProperty FullName + if (-not $ConverterPath) { throw 'Could not locate ConvertTo-CIPPCoverImageIdList.ps1 under Modules/' } + . $ConverterPath + + $script:EntrypointPath = Get-ChildItem -Path (Join-Path $RepoRoot 'Modules') -Recurse -Filter 'Invoke-ExecBrandingSettings.ps1' -File -ErrorAction SilentlyContinue | + Select-Object -First 1 -ExpandProperty FullName + if (-not $script:EntrypointPath) { throw 'Could not locate Invoke-ExecBrandingSettings.ps1 under Modules/' } + + # Mirror of the entrypoint's serialiser. + function ConvertTo-IdListJson { + param($Value) + $Ids = ConvertTo-CIPPCoverImageIdList -Value $Value + return ConvertTo-Json -InputObject ([string[]]$Ids) -Compress + } + + # The expression the entrypoint uses to drop one id from the stored list. + function Remove-IdFromList { + param($Stored, $ImageId) + $Remaining = [string[]]@((ConvertTo-CIPPCoverImageIdList -Value $Stored).Where({ $_ -ne $ImageId })) + return ConvertTo-IdListJson -Value $Remaining + } +} + +Describe 'DeleteImage id-list bookkeeping' { + It 'removes only the deleted id when several are stored' { + Remove-IdFromList -Stored '["aaa","bbb","ccc"]' -ImageId 'bbb' | Should -Be '["aaa","ccc"]' + } + + It 'never collapses the remaining ids into one space-joined value' { + # The regression: '["aaa bbb ccc"]' resolves to nothing on the next read, so every image + # disappears from the gallery even though the rows are still in the table. + $Result = Remove-IdFromList -Stored '["aaa","bbb","ccc"]' -ImageId 'bbb' + $Result | Should -Not -Match ' ' + @($Result | ConvertFrom-Json).Count | Should -Be 2 + } + + It 'empties the list when the only stored id is the one deleted' { + Remove-IdFromList -Stored '["aaa"]' -ImageId 'aaa' | Should -Be '[]' + } + + It 'keeps a single stored id when a different one is deleted' { + Remove-IdFromList -Stored '["aaa"]' -ImageId 'zzz' | Should -Be '["aaa"]' + } + + It 'leaves the list alone when the id is not in it' { + Remove-IdFromList -Stored '["aaa","bbb"]' -ImageId 'zzz' | Should -Be '["aaa","bbb"]' + } + + It 'handles an empty stored list' { + Remove-IdFromList -Stored '[]' -ImageId 'aaa' | Should -Be '[]' + } + + It 'filters the id list with .Where() rather than a pipeline' { + # Piping the comma-wrapped array is what caused the collapse, so the shape is pinned. + $Source = Get-Content -Path $script:EntrypointPath -Raw + $Source | Should -Not -Match 'ConvertTo-CIPPCoverImageIdList[^\r\n]*\|[^\r\n]*Where-Object' + } + + It 'keeps a single-element id list an array so [0] is the whole id' { + # Get-CIPPBrandingSettings indexes LogoImageIds[0]/CoverImageIds[0] to fetch the selected + # image. Were the one-element case to unwrap to a scalar, [0] would be the first character + # of the GUID and the lookup would silently find nothing. + $Ids = ConvertTo-CIPPCoverImageIdList -Value '["8759c53f-076c-4f5f-bcb4-996ff39adaef"]' + $Ids[0] | Should -Be '8759c53f-076c-4f5f-bcb4-996ff39adaef' + } +} diff --git a/Tests/DBCache/Push-StoreMailboxPermissions.Tests.ps1 b/Tests/DBCache/Push-StoreMailboxPermissions.Tests.ps1 new file mode 100644 index 0000000000000..b7213814682c5 --- /dev/null +++ b/Tests/DBCache/Push-StoreMailboxPermissions.Tests.ps1 @@ -0,0 +1,141 @@ +# Pester tests for Push-StoreMailboxPermissions +# +# The fan-in of the mailbox permission orchestrator: $Item.Results already holds the whole +# tenant's permission set, so rows are streamed straight into Add-CIPPDbItem rather than +# collected into intermediate lists (this job was one of two that took a production instance +# to 3.8GB). These tests lock the streaming write, the one-invocation-per-type rule that its +# orphan cleanup depends on, and the guard that keeps a rowless run from stamping a fresh +# -Count row of 0 over a cache it did not clear. + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $FunctionPath = Get-ChildItem -Path (Join-Path $RepoRoot 'Modules') -Recurse -Filter 'Push-StoreMailboxPermissions.ps1' -File -ErrorAction SilentlyContinue | + Select-Object -First 1 -ExpandProperty FullName + if (-not $FunctionPath) { throw 'Could not locate Push-StoreMailboxPermissions.ps1 under Modules/' } + + # Minimal stubs so Mock has commands to replace during tests. + function Write-LogMessage { param($API, $tenant, $message, $sev, $LogData) } + function Add-CIPPDbItem { + [CmdletBinding()] + param( + [Parameter(Mandatory)][string]$TenantFilter, + [Parameter(Mandatory)][string]$Type, + [Parameter(Mandatory, ValueFromPipeline)][AllowNull()][AllowEmptyCollection()]$InputObject, + [switch]$Count, + [switch]$AddCount, + [switch]$Append + ) + } + + . $FunctionPath + + function New-WorkItem { + param($Results) + @{ + Parameters = @{ TenantFilter = 'contoso.onmicrosoft.com' } + Results = $Results + } + } +} + +Describe 'Push-StoreMailboxPermissions' { + BeforeEach { + $script:Rows = [System.Collections.Generic.List[object]]::new() + Mock -CommandName Write-LogMessage -MockWith { } + Mock -CommandName Add-CIPPDbItem -MockWith { $script:Rows.Add(@{ Type = $Type; Row = $InputObject }) } + } + + It 'streams mailbox, recipient and send-on-behalf rows into one MailboxPermissions pipeline' { + $Item = New-WorkItem -Results @( + @{ + 'Get-MailboxPermission' = @(@{ Identity = 'shared1'; User = 'a@x.com' }, @{ Identity = 'shared2'; User = 'b@x.com' }) + 'Get-RecipientPermission' = @(@{ Identity = 'shared1'; Trustee = 'c@x.com' }) + 'Get-Mailbox' = @(@{ Identity = 'shared1'; GrantSendOnBehalfTo = 'd@x.com' }) + } + ) + + Push-StoreMailboxPermissions -Item $Item + + # Pester runs the mock body once per pipeline item: 4 single rows means the writer + # was fed a stream, not a materialised list. + $MailboxRows = @($script:Rows | Where-Object { $_.Type -eq 'MailboxPermissions' }) + $MailboxRows.Count | Should -Be 4 + $MailboxRows | ForEach-Object { @($_.Row).Count | Should -Be 1 } + + Should -Invoke Add-CIPPDbItem -Times 4 -Exactly -ParameterFilter { + $AddCount.IsPresent -and $Type -eq 'MailboxPermissions' -and $TenantFilter -eq 'contoso.onmicrosoft.com' + } + } + + It 'streams calendar rows into a separate CalendarPermissions pipeline' { + $Item = New-WorkItem -Results @( + @{ + 'Get-MailboxPermission' = @(@{ Identity = 'shared1'; User = 'a@x.com' }) + 'Get-MailboxFolderPermission' = @(@{ Identity = 'shared1:\Calendar'; User = 'b@x.com' }, @{ Identity = 'shared2:\Calendar'; User = 'c@x.com' }) + } + ) + + Push-StoreMailboxPermissions -Item $Item + + @($script:Rows | Where-Object { $_.Type -eq 'CalendarPermissions' }).Count | Should -Be 2 + Should -Invoke Add-CIPPDbItem -Times 2 -Exactly -ParameterFilter { $Type -eq 'CalendarPermissions' } + Should -Invoke Add-CIPPDbItem -Times 1 -Exactly -ParameterFilter { $Type -eq 'MailboxPermissions' } + } + + It 'unwraps a batch result shaped as [hashtable, status message]' { + $Item = New-WorkItem -Results @( + , @(@{ 'Get-MailboxPermission' = @(@{ Identity = 'shared1'; User = 'a@x.com' }) }, 'Batch completed') + ) + + Push-StoreMailboxPermissions -Item $Item + + @($script:Rows | Where-Object { $_.Type -eq 'MailboxPermissions' }).Count | Should -Be 1 + } + + It 'never invokes a writer for a type with no rows, so no -Count row is stamped' { + # Every batch failed: strings instead of cmdlet-keyed hashtables. Add-CIPPDbItem's + # end block writes the -Count row whenever -AddCount is present, so invoking it with + # an empty stream would stamp a fresh count of 0 without clearing the data rows, and + # the freshness gates that read count rows would treat the stale cache as current. + $Item = New-WorkItem -Results @('error: batch 1 failed', 'error: batch 2 failed') + + Push-StoreMailboxPermissions -Item $Item + + Should -Invoke Add-CIPPDbItem -Times 0 -Exactly + Should -Invoke Write-LogMessage -Times 1 -Exactly -ParameterFilter { + $message -eq 'No mailbox permissions found to cache' + } + Should -Invoke Write-LogMessage -Times 1 -Exactly -ParameterFilter { + $message -eq 'No calendar permissions found to cache' + } + } + + It 'still writes the type that has rows when the other has none' { + $Item = New-WorkItem -Results @( + @{ 'Get-MailboxPermission' = @(@{ Identity = 'shared1'; User = 'a@x.com' }) } + ) + + Push-StoreMailboxPermissions -Item $Item + + Should -Invoke Add-CIPPDbItem -Times 1 -Exactly -ParameterFilter { $Type -eq 'MailboxPermissions' } + Should -Invoke Add-CIPPDbItem -Times 0 -Exactly -ParameterFilter { $Type -eq 'CalendarPermissions' } + } + + It 'logs the cached totals per type' { + $Item = New-WorkItem -Results @( + @{ + 'Get-MailboxPermission' = @(@{ Identity = 'shared1'; User = 'a@x.com' }, @{ Identity = 'shared2'; User = 'b@x.com' }) + 'Get-MailboxFolderPermission' = @(@{ Identity = 'shared1:\Calendar'; User = 'c@x.com' }) + } + ) + + Push-StoreMailboxPermissions -Item $Item + + Should -Invoke Write-LogMessage -Times 1 -Exactly -ParameterFilter { + $message -eq 'Cached 2 mailbox permission records' + } + Should -Invoke Write-LogMessage -Times 1 -Exactly -ParameterFilter { + $message -eq 'Cached 1 calendar permission records' + } + } +} diff --git a/Tests/DBCache/Set-CIPPDBCacheDefenderCVEs.Flush.Tests.ps1 b/Tests/DBCache/Set-CIPPDBCacheDefenderCVEs.Flush.Tests.ps1 index 2a45e14b64507..137fd91f0444d 100644 --- a/Tests/DBCache/Set-CIPPDBCacheDefenderCVEs.Flush.Tests.ps1 +++ b/Tests/DBCache/Set-CIPPDBCacheDefenderCVEs.Flush.Tests.ps1 @@ -2,10 +2,10 @@ # # The collector streams rows into a single Add-CIPPDbItem pipeline on purpose. Add-CIPPDbItem # runs its orphan cleanup and writes DefenderCVEs-Count once per pipeline, in its end block, -# against the RunStartUtc captured in its begin block. Splitting the flush across several -# calls would make each later call's cleanup delete rows written by earlier ones once the run -# exceeded the skew margin, and would leave the stored count equal to the final chunk. These -# tests fail if anyone reintroduces a per-chunk flush. +# keyed to the run id minted in its begin block. Splitting the flush across several calls +# would give each chunk its own run id, so each later call's cleanup would treat earlier +# chunks' rows as orphans once the run exceeded the skew margin, and would leave the stored +# count equal to the final chunk. These tests fail if anyone reintroduces a per-chunk flush. BeforeAll { $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) @@ -132,19 +132,23 @@ Describe 'Set-CIPPDBCacheDefenderCVEs flush semantics' { ($Payload.deviceDetailsJson | ConvertFrom-Json).deviceName | Should -Be @('PC-d1', 'PC-d2') } - It 'deletes only rows left over from an earlier run' { + It 'deletes only rows left over from an earlier run, never rows this run wrote' { Mock -CommandName Get-DefenderTvmRaw -MockWith { New-TvmRecord -cveId 'CVE-A' -deviceId 'd1' } + # Feed one of this run's own writes back through the cleanup query alongside a + # foreign row. The delete authority is the RunId stamped on every written row - + # identity, not age - so however slow the run or however far the storage clock + # drifts, only the foreign row may go. Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { - @( - [pscustomobject]@{ PartitionKey = 'contoso.onmicrosoft.com'; RowKey = 'DefenderCVEs-stale'; Timestamp = [datetimeoffset]::UtcNow.AddDays(-1) } - [pscustomobject]@{ PartitionKey = 'contoso.onmicrosoft.com'; RowKey = 'DefenderCVEs-fresh'; Timestamp = [datetimeoffset]::UtcNow } - ) + $OwnRows = foreach ($Write in $script:Writes) { + foreach ($E in $Write) { if ($E.RowKey -notlike '*-Count') { $E } } + } + @([pscustomobject]@{ PartitionKey = 'contoso.onmicrosoft.com'; RowKey = 'DefenderCVEs-from-earlier-run'; ETag = '*' }) + @($OwnRows) } Set-CIPPDBCacheDefenderCVEs -TenantFilter $script:Tenant @($script:Removed).Count | Should -Be 1 - $script:Removed[0].RowKey | Should -Be 'DefenderCVEs-stale' + $script:Removed[0].RowKey | Should -Be 'DefenderCVEs-from-earlier-run' } It 'does not touch the table when the fetch faults mid-stream' { diff --git a/Tests/DBCache/Set-CIPPDBCacheDefenderCVEs.Tests.ps1 b/Tests/DBCache/Set-CIPPDBCacheDefenderCVEs.Tests.ps1 index c0a7944ce1640..d04d4025a0667 100644 --- a/Tests/DBCache/Set-CIPPDBCacheDefenderCVEs.Tests.ps1 +++ b/Tests/DBCache/Set-CIPPDBCacheDefenderCVEs.Tests.ps1 @@ -224,16 +224,11 @@ Describe 'Set-CIPPDBCacheDefenderCVEs' { $script:Rows | ForEach-Object { @($_).Count | Should -Be 1 } } - It 'stops building rows as soon as the consumer faults, proving rows are not pre-built' { + It 'stops feeding rows as soon as the consumer faults' { Mock -CommandName Get-DefenderTvmRaw -MockWith { foreach ($i in 1..30) { New-TvmRecord -cveId "CVE-$i" -deviceId "d$i" } } - $script:JsonCalls = 0 - # ConvertTo-Json is called once per row as that row is built. If the emit stage - # buffered into $Entities first, all 30 rows would be serialised before the - # consumer ever ran and the count would be 30 regardless of the fault. - Mock -CommandName ConvertTo-Json -MockWith { $script:JsonCalls++; '{}' } Mock -CommandName Add-CIPPDbItem -MockWith { $script:Rows.Add($InputObject) if ($script:Rows.Count -ge 3) { throw 'downstream failure' } @@ -241,10 +236,35 @@ Describe 'Set-CIPPDBCacheDefenderCVEs' { Set-CIPPDBCacheDefenderCVEs -TenantFilter $script:Tenant - $script:JsonCalls | Should -Be 3 $script:Rows.Count | Should -Be 3 } + It 'serialises each device once as it arrives, not once per row at emit' { + # Device metadata is folded into its CVE bucket as JSON text on arrival, so the + # aggregator holds strings rather than a hashtable per (device x software x CVE) + # record - the single largest thing this job used to retain on a big tenant. + # + # This assertion replaced one that counted ConvertTo-Json calls to prove the emit + # stage was lazy. That proxy only worked while row building was the only caller of + # ConvertTo-Json; now the fold does the serialising and the count reflects records + # in, not rows out. Emit-stage laziness is still enforced structurally by the + # `& { foreach ... } | Add-CIPPDbItem` pipeline and observed by the fault test + # above, but note it is no longer possible to distinguish a lazy producer from one + # that pre-builds every row and then pipes them, because nothing per-row is mockable. + Mock -CommandName Get-DefenderTvmRaw -MockWith { + foreach ($i in 1..30) { New-TvmRecord -cveId "CVE-$i" -deviceId "d$i" } + } + + $script:JsonCalls = 0 + Mock -CommandName ConvertTo-Json -MockWith { $script:JsonCalls++; '{}' } + + Set-CIPPDBCacheDefenderCVEs -TenantFilter $script:Tenant + + # One per incoming record. Anything higher means a second serialisation crept back + # into the emit stage, which is what put two copies of a CVE's devices in memory. + $script:JsonCalls | Should -Be 30 + } + It 'passes AddCount exactly once so the stored count is the run total' { Mock -CommandName Get-DefenderTvmRaw -MockWith { foreach ($i in 1..10) { New-TvmRecord -cveId "CVE-$i" -deviceId "d$i" } diff --git a/Tests/DBCache/SharePointSharingLinks.Resume.Tests.ps1 b/Tests/DBCache/SharePointSharingLinks.Resume.Tests.ps1 new file mode 100644 index 0000000000000..f2a70e567d9f6 --- /dev/null +++ b/Tests/DBCache/SharePointSharingLinks.Resume.Tests.ps1 @@ -0,0 +1,505 @@ +# Pester tests for the resumable, delta-persisted 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 +# 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] + } + + function Invoke-FakeTableFilter { + param($Rows, [string]$Filter) + $Result = @($Rows) + if ($Filter -match "PartitionKey eq '([^']*)'") { $Pk = $Matches[1]; $Result = @($Result | Where-Object { $_.PartitionKey -eq $Pk }) } + if ($Filter -match "RowKey eq '([^']*)'") { $Rk = $Matches[1]; $Result = @($Result | Where-Object { $_.RowKey -eq $Rk }) } + if ($Filter -match "RowKey ge '([^']*)'") { $Ge = $Matches[1]; $Result = @($Result | Where-Object { [string]::CompareOrdinal([string]$_.RowKey, $Ge) -ge 0 }) } + if ($Filter -match "RowKey lt '([^']*)'") { $Lt = $Matches[1]; $Result = @($Result | Where-Object { [string]::CompareOrdinal([string]$_.RowKey, $Lt) -lt 0 }) } + $Result + } + + 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 + } + + function Get-CIPPAzDataTableEntity { + param($TableName, $Filter, $Property, [switch]$Count) + $Rows = Get-FakeTableRows -TableName $TableName + foreach ($Row in (Invoke-FakeTableFilter -Rows $Rows -Filter $Filter)) { ConvertTo-FakeEntity -Entity $Row } + } + + 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 + foreach ($Item in @($Entity)) { + if ($null -eq $Item) { continue } + $New = ConvertTo-FakeEntity -Entity $Item + $Existing = $Rows | Where-Object { $_.PartitionKey -eq $New.PartitionKey -and $_.RowKey -eq $New.RowKey } | Select-Object -First 1 + if ($Existing) { + # Faithful to the real wrapper: without -Force the operation is an insert, and + # writing over an existing entity fails (first writer wins). + if (-not $Force) { + Write-Error "The specified entity already exists. Status: 409 (Conflict) ErrorCode: EntityAlreadyExists (RowKey: $($New.RowKey))" + continue + } + [void]$Rows.Remove($Existing) + } + $Rows.Add($New) + } + } + + function Remove-CIPPAzDataTableEntity { + param($TableName, $Entity, [switch]$Force) + $Rows = Get-FakeTableRows -TableName $TableName + foreach ($Item in @($Entity)) { + if ($null -eq $Item) { continue } + $Existing = $Rows | Where-Object { $_.PartitionKey -eq $Item.PartitionKey -and $_.RowKey -eq $Item.RowKey } | Select-Object -First 1 + if ($Existing) { [void]$Rows.Remove($Existing) } + } + } + + 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 + } + + # --- other dependency stubs --------------------------------------------------------------- + function Write-LogMessage { param($headers, $API, $tenant, $message, $sev, $LogData) } + function Get-CippException { param($Exception) } + function Get-Tenants { param($TenantFilter, [switch]$IncludeErrors) [pscustomobject]@{ customerId = 'tenant-guid'; defaultDomainName = 'contoso.com' } } + function ConvertTo-CIPPODataFilterValue { param($Value, $Type) [string]$Value } + function Update-CippQueueEntry { + param($RowKey, $Status, $Name, $TotalTasks, [switch]$IncrementTotalTasks) + $script:QueueUpdates.Add([pscustomobject]@{ RowKey = $RowKey; TotalTasks = $TotalTasks }) + } + function Start-CIPPOrchestrator { + param($InputObject, $InputObjectGuid, [switch]$CallerIsQueueTrigger) + $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. + function New-GraphBulkRequest { + param($tenantid, $NoAuthCheck, $scope, $asapp, $Requests, $NoPaginateIds, $Version, $Headers) + foreach ($Request in @($Requests)) { + $ItemId = ($Request.url -split '/')[3] + [pscustomobject]@{ + id = $Request.id + status = 200 + body = [pscustomobject]@{ + value = @( + [pscustomobject]@{ + id = "perm-$ItemId" + roles = @('read') + link = [pscustomobject]@{ scope = 'anonymous'; type = 'view'; webUrl = "https://share/$ItemId" } + } + ) + } + } + } + } + + . (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 + } + . (Join-Path $RepoRoot 'Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheSharePointSharingLinks.ps1') + . (Join-Path $RepoRoot 'Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/SharePoint Sharing/Push-DBCacheSharePointSiteSharingLinks.ps1') + . (Join-Path $RepoRoot 'Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/SharePoint Sharing/Push-StoreSharePointSharingLinks.ps1') + + # --- shared builders ------------------------------------------------------------------------ + function New-SiteItem { + param([string]$ScanId, [string]$SiteId = 'contoso.sharepoint.com,site1,web1', [string]$SiteUrl = 'https://contoso.sharepoint.com/sites/one') + [pscustomobject]@{ + FunctionName = 'DBCacheSharePointSiteSharingLinks' + TenantFilter = 'contoso.com' + SiteId = $SiteId + SiteName = 'Site One' + SiteUrl = $SiteUrl + IsPersonalSite = $false + InternalDomains = @('contoso.com') + ScanId = $ScanId + Slice = 1 + ForceFull = $false + QueueId = $null + QueueName = 'Sharing Links - test' + } + } + + function New-DeltaPage { + param($Items = @(), [string]$NextLink, [string]$DeltaLink) + $Page = [ordered]@{ value = @($Items) } + if ($NextLink) { $Page['@odata.nextLink'] = $NextLink } + if ($DeltaLink) { $Page['@odata.deltaLink'] = $DeltaLink } + [pscustomobject]$Page + } + + function Add-CacheRow { + param([string]$RowKey, [string]$RunId = 'previous-scan') + Add-CIPPAzDataTableEntity -TableName 'CippReportingDB' -Entity @{ + PartitionKey = 'contoso.com'; RowKey = $RowKey; Type = 'SharePointSharingLinks'; RunId = $RunId; Data = '{"id":"x"}'; ETag = '*' + } + } + + function Get-CacheRowKeys { + @((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' + } + } + + function Get-TestScanRow { + (Get-FakeTableRows -TableName 'CippSharingLinksState') | Where-Object { $_.RowKey -eq 'scan' } | Select-Object -First 1 + } +} + +Describe 'Resumable 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 + + # Default Graph: one drive with one delta page holding one shared file. + $script:GraphGetHandler = { + param($Uri) + if ($Uri -match '/sites/[^/]+/drives') { + return @([pscustomobject]@{ id = 'b!driveone'; name = 'Documents'; driveType = 'documentLibrary' }) + } + if ($Uri -match '/root/delta') { + return New-DeltaPage -Items @( + [pscustomobject]@{ id = '01ITEMA'; name = 'a.docx'; shared = [pscustomobject]@{ scope = 'anonymous' }; size = 1 } + [pscustomobject]@{ id = '01ITEMB'; name = 'b.docx'; size = 2 } # not shared + ) -DeltaLink 'https://graph.microsoft.com/beta/drives/b!driveone/root/delta?token=fresh' + } + throw "Unrouted GET: $Uri" + } + } + + Context 'full scan of a site' { + It 'writes rows stamped with the scan id and stores the drive delta token' { + $ScanId = 'scan-full-1' + Initialize-TestScan -ScanId $ScanId -TotalSites 2 + + Push-DBCacheSharePointSiteSharingLinks -Item (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 + + $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 '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' + + Push-DBCacheSharePointSiteSharingLinks -Item (New-SiteItem -ScanId $ScanId) + + Get-CacheRowKeys | Should -Not -Contain 'SharePointSharingLinks-b!driveone_01GONE_permOld' + Get-CacheRowKeys | Should -Contain 'SharePointSharingLinks-b!driveone_01ITEMA_perm-01ITEMA' + } + + It 'decrements the pending counter and only finalises on the last site' { + $ScanId = 'scan-full-3' + Initialize-TestScan -ScanId $ScanId -TotalSites 2 + + Push-DBCacheSharePointSiteSharingLinks -Item (New-SiteItem -ScanId $ScanId) + ([int](Get-TestScanRow).PendingSites) | Should -Be 1 + 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 + $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 + } + } + + Context 'incremental scan from a stored delta token' { + 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' + } + + 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 -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' } } + [pscustomobject]@{ id = '01ITEMZ'; name = 'z.docx'; deleted = [pscustomobject]@{ state = 'deleted' } } + ) -DeltaLink 'https://graph.microsoft.com/beta/drives/b!driveone/root/delta?token=newer' + } + throw "Unrouted GET: $Uri" + } + + Push-DBCacheSharePointSiteSharingLinks -Item (New-SiteItem -ScanId $script:ScanId) + + $Keys = Get-CacheRowKeys + $Keys | Should -Not -Contain 'SharePointSharingLinks-b!driveone_01ITEMX_permOld' # replaced + $Keys | Should -Contain 'SharePointSharingLinks-b!driveone_01ITEMX_perm-01ITEMX' # fresh read + $Keys | Should -Contain 'SharePointSharingLinks-b!driveone_01ITEMY_permKeep' # untouched + $Keys | Should -Not -Contain 'SharePointSharingLinks-b!driveone_01ITEMZ_permDead' # deleted item + + $DriveState = Get-CIPPSharingLinksDriveState -TenantFilter 'contoso.com' -DriveId 'b!driveone' + $DriveState.DeltaLink | Should -BeLike '*token=newer' + # Incremental completion must not claim a full scan happened. + $DriveState.LastFullScanUtc | Should -Be '2026-08-10T00:00:00Z' + } + + It 'falls back to a 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 '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 @( + [pscustomobject]@{ id = '01ITEMY'; name = 'y.docx'; shared = [pscustomobject]@{ scope = 'anonymous' } } + ) -DeltaLink 'https://graph.microsoft.com/beta/drives/b!driveone/root/delta?token=rebuilt' + } + throw "Unrouted GET: $Uri" + } + + Push-DBCacheSharePointSiteSharingLinks -Item (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' + $Keys | Should -Not -Contain 'SharePointSharingLinks-b!driveone_01ITEMY_permKeep' + + $DriveState = Get-CIPPSharingLinksDriveState -TenantFilter 'contoso.com' -DriveId 'b!driveone' + $DriveState.DeltaLink | Should -BeLike '*token=rebuilt' + $DriveState.LastFullScanUtc | Should -Not -Be '2026-08-10T00:00:00Z' + } + } + + Context 'resume from a checkpoint' { + It 'skips completed drives and resumes the current drive 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' + ScanId = $ScanId + StateJson = (@{ + CompletedDrives = @('b!drivedone') + CurrentDriveId = 'b!driveone' + CurrentUri = 'https://graph.microsoft.com/beta/drives/b!driveone/root/delta?token=page7' + CurrentMode = 'Full' + } | ConvertTo-Json -Compress) + } + + $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' } } + ) -DeltaLink 'https://graph.microsoft.com/beta/drives/b!driveone/root/delta?token=done' + } + throw "Unrouted GET: $Uri" + } + + Push-DBCacheSharePointSiteSharingLinks -Item (New-SiteItem -ScanId $ScanId) + + # No call ever targeted the completed drive or the start of the current one. + @($script:GraphGetCalls | Where-Object { $_ -match 'drivedone' }).Count | Should -Be 0 + Get-CacheRowKeys | Should -Contain 'SharePointSharingLinks-b!driveone_01ITEMC_perm-01ITEMC' + # Site finished, so the checkpoint is gone. + (Get-FakeTableRows -TableName 'CippSharingLinksState') | Where-Object { $_.RowKey -like 'chk-*' } | Should -BeNullOrEmpty + } + } + + 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 + + Push-DBCacheSharePointSiteSharingLinks -Item (New-SiteItem -ScanId 'scan-old') + + $script:GraphGetCalls.Count | Should -Be 0 + ([int](Get-TestScanRow).PendingSites) | Should -Be 5 + } + } + + 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 + + # 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) + + ([int](Get-TestScanRow).PendingSites) | Should -Be 1 + Get-CacheRowKeys | Should -Not -Contain 'SharePointSharingLinks-Count' + } + + It 'retries a lost ETag race instead of silently dropping the decrement' { + $ScanId = 'scan-race-1' + Initialize-TestScan -ScanId $ScanId -TotalSites 1 + # First conditional write of the scan row 412s, exactly like losing the race to a + # concurrently finishing site. The retry must re-read and land the decrement. + $script:FailScanRowUpdates = 1 + + Push-DBCacheSharePointSiteSharingLinks -Item (New-SiteItem -ScanId $ScanId) + + ([int](Get-TestScanRow).PendingSites) | Should -Be 0 + # Pending reached zero, so finalisation ran and wrote the count row. + Get-CacheRowKeys | Should -Contain 'SharePointSharingLinks-Count' + } + } + + Context 'site failure' { + It 'records the failed site and still decrements the counter' { + $ScanId = 'scan-fail-1' + Initialize-TestScan -ScanId $ScanId -TotalSites 2 + $script:GraphGetHandler = { param($Uri) throw 'drives listing failed' } + + Push-DBCacheSharePointSiteSharingLinks -Item (New-SiteItem -ScanId $ScanId) + + $Scan = Get-TestScanRow + ([int]$Scan.PendingSites) | Should -Be 1 + @($Scan.FailedSites | ConvertFrom-Json) | Should -Contain 'contoso.sharepoint.com,site1,web1' + } + } + + Context 'finalisation' { + It 'prunes rows and state of drives the scan never saw, but keeps failed sites intact' { + $ScanId = 'scan-final-1' + 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' + } + # 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' } + @{ RowKey = 'delta-b!unreachable'; DriveId = 'b!unreachable'; SiteId = 'contoso.sharepoint.com,siteF,webF'; LastScanId = 'previous-scan' } + )) { + Add-CIPPAzDataTableEntity -TableName 'CippSharingLinksState' -Entity (@{ PartitionKey = 'contoso.com'; DeltaLink = 'x'; LastScanUtc = 'x'; LastFullScanUtc = 'x' } + $State) + } + Add-CacheRow -RowKey 'SharePointSharingLinks-b!current_01ITEMA_p1' -RunId $ScanId + Add-CacheRow -RowKey 'SharePointSharingLinks-b!vanished_01ITEMB_p1' + Add-CacheRow -RowKey 'SharePointSharingLinks-b!unreachable_01ITEMC_p1' + + Push-StoreSharePointSharingLinks -TenantFilter 'contoso.com' -ScanId $ScanId + + $Keys = Get-CacheRowKeys + $Keys | Should -Contain 'SharePointSharingLinks-b!current_01ITEMA_p1' + $Keys | Should -Not -Contain 'SharePointSharingLinks-b!vanished_01ITEMB_p1' + $Keys | Should -Contain 'SharePointSharingLinks-b!unreachable_01ITEMC_p1' + (Get-CIPPSharingLinksDriveState -TenantFilter 'contoso.com' -DriveId 'b!vanished') | Should -BeNullOrEmpty + (Get-CIPPSharingLinksDriveState -TenantFilter 'contoso.com' -DriveId 'b!unreachable') | Should -Not -BeNullOrEmpty + + $CountRow = (Get-FakeTableRows -TableName 'CippReportingDB') | Where-Object { $_.RowKey -eq 'SharePointSharingLinks-Count' } + [int]$CountRow.DataCount | Should -Be 2 + } + + 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' + } + Add-CacheRow -RowKey 'SharePointSharingLinks-b!current_01ITEMA_p1' -RunId $ScanId + Add-CacheRow -RowKey 'SharePointSharingLinks-b!orphandrive_01ITEMO_p1' -RunId 'ancient-scan' + + Push-StoreSharePointSharingLinks -TenantFilter 'contoso.com' -ScanId $ScanId + + $Keys = Get-CacheRowKeys + $Keys | Should -Contain 'SharePointSharingLinks-b!current_01ITEMA_p1' + $Keys | Should -Not -Contain 'SharePointSharingLinks-b!orphandrive_01ITEMO_p1' + } + + 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' + } + 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' + } + Add-CacheRow -RowKey 'SharePointSharingLinks-b!inflight_01ITEMN_p1' -RunId 'scan-newer' + + Push-StoreSharePointSharingLinks -TenantFilter 'contoso.com' -ScanId 'scan-older' + + Get-CacheRowKeys | Should -Contain 'SharePointSharingLinks-b!inflight_01ITEMN_p1' + (Get-CIPPSharingLinksDriveState -TenantFilter 'contoso.com' -DriveId 'b!inflight') | Should -Not -BeNullOrEmpty + } + } +} diff --git a/Tests/Endpoint/Invoke-EditGroup.Tests.ps1 b/Tests/Endpoint/Invoke-EditGroup.Tests.ps1 index db7acbbeae379..cfdd3d265ee12 100644 --- a/Tests/Endpoint/Invoke-EditGroup.Tests.ps1 +++ b/Tests/Endpoint/Invoke-EditGroup.Tests.ps1 @@ -41,6 +41,12 @@ BeforeAll { if (-not $ResolverPath) { throw 'Could not locate Resolve-CippExoBulkResult.ps1 under Modules/' } . $ResolverPath + # The resolver delegates error-text extraction to this pure helper; use the real one too. + $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 + . $FunctionPath # The Edit Group page posts the group as an autocomplete option carrying its type. diff --git a/Tests/Endpoint/Invoke-ExecAddCippCveException.Tests.ps1 b/Tests/Endpoint/Invoke-ExecAddCippCveException.Tests.ps1 new file mode 100644 index 0000000000000..15217ccba059e --- /dev/null +++ b/Tests/Endpoint/Invoke-ExecAddCippCveException.Tests.ps1 @@ -0,0 +1,183 @@ +# Pester tests for Invoke-ExecAddCippCveException +# +# The AllAffected branch walks the whole DefenderCVEs cache to find which tenants hold the +# CVE. It shipped broken once (`| -Filter` mid-pipeline, a runtime error the catch turned +# into a 500) with nothing covering it, so these tests lock each applyTo resolution and the +# memory property of the fixed branch: rows are substring-probed and only candidates are +# deserialised, so the whole cache is never held parsed at once. + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $FunctionPath = Get-ChildItem -Path (Join-Path $RepoRoot 'Modules') -Recurse -Filter 'Invoke-ExecAddCippCveException.ps1' -File -ErrorAction SilentlyContinue | + Select-Object -First 1 -ExpandProperty FullName + if (-not $FunctionPath) { throw 'Could not locate Invoke-ExecAddCippCveException.ps1 under Modules/' } + + # Azure Functions binding types do not exist outside the Functions host - fake them. + 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]) + } + + # Stub every CIPP helper the function calls so Pester's Mock has a command to replace. + function Get-CIPPTable { param($TableName) } + function Get-CIPPAzDataTableEntity { param($Context, $Filter, $Property) } + function Add-CIPPAzDataTableEntity { param($Context, $Entity, [switch]$Force) } + function Get-CIPPDbItem { param($TenantFilter, $Type, [switch]$CountsOnly) } + function Get-CippException { param($Exception) } + function Write-LogMessage { param($headers, $API, $tenant, $message, $sev, $LogData) } + + . $FunctionPath + + function New-ExceptionRequest { + param( + $CveId = 'CVE-2024-0001', + $ApplyTo = 'Global', + $TenantFilter = 'contoso.onmicrosoft.com' + ) + [pscustomobject]@{ + Params = @{ CIPPEndpoint = 'ExecAddCippCveException' } + Headers = @{ 'x-ms-client-principal-name' = 'admin@partner.com' } + Query = [pscustomobject]@{ tenantFilter = $TenantFilter } + Body = [pscustomobject]@{ + cveId = $CveId + exceptionType = 'RiskAccepted' + applyTo = $ApplyTo + justification = 'accepted by customer' + } + } + } + + # A cached row exactly as Add-CIPPDbItem stores what Set-CIPPDBCacheDefenderCVEs emits: + # keyed by tenant, CVE payload inside the Data JSON. + function New-CachedCveRow { + param($CveId, $Tenant) + $Payload = @{ + PartitionKey = $CveId + RowKey = $Tenant + customerId = $Tenant + cveId = $CveId + deviceCount = 1 + deviceDetailsJson = '{"deviceName":"PC-1"}' + } + [pscustomobject]@{ + PartitionKey = $Tenant + RowKey = "DefenderCVEs-$([guid]::NewGuid())" + Data = [string]($Payload | ConvertTo-Json -Depth 100 -Compress) + Type = 'DefenderCVEs' + } + } +} + +Describe 'Invoke-ExecAddCippCveException' { + BeforeEach { + $script:Written = [System.Collections.Generic.List[object]]::new() + + Mock -CommandName Write-LogMessage -MockWith { } + Mock -CommandName Get-CippException -MockWith { @{ NormalizedError = $Exception.Exception.Message } } + Mock -CommandName Get-CIPPTable -MockWith { @{ TableName = $TableName } } + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { @() } + Mock -CommandName Add-CIPPAzDataTableEntity -MockWith { $script:Written.AddRange(@($Entity)) } + Mock -CommandName Get-CIPPDbItem -MockWith { @() } + } + + Context 'AllAffected' { + It 'writes one exception per tenant holding the CVE and ignores the rest of the cache' { + Mock -CommandName Get-CIPPDbItem -MockWith { + New-CachedCveRow -CveId 'CVE-2024-0001' -Tenant 'contoso.onmicrosoft.com' + New-CachedCveRow -CveId 'CVE-2024-0001' -Tenant 'fabrikam.onmicrosoft.com' + New-CachedCveRow -CveId 'CVE-2024-9999' -Tenant 'tailspin.onmicrosoft.com' + [pscustomobject]@{ PartitionKey = 'contoso.onmicrosoft.com'; RowKey = 'DefenderCVEs-Count'; DataCount = 3 } + } + + $Response = Invoke-ExecAddCippCveException -Request (New-ExceptionRequest -ApplyTo 'AllAffected') -TriggerMetadata @{} + + $Response.StatusCode | Should -Be ([HttpStatusCode]::OK) + $Response.Body.TenantsAffected | Should -Be 2 + + (@($script:Written).RowKey | Sort-Object) | Should -Be @('contoso.onmicrosoft.com', 'fabrikam.onmicrosoft.com') + @($script:Written) | ForEach-Object { + $_.PartitionKey | Should -Be 'CVE-2024-0001' + $_.exceptionType | Should -Be 'RiskAccepted' + $_.exceptionCreatedBy | Should -Be 'admin@partner.com' + } + } + + It 'deserialises only rows that pass the substring probe' { + Mock -CommandName Get-CIPPDbItem -MockWith { + New-CachedCveRow -CveId 'CVE-2024-0001' -Tenant 'contoso.onmicrosoft.com' + foreach ($i in 1..20) { New-CachedCveRow -CveId "CVE-2024-9$i" -Tenant 'tailspin.onmicrosoft.com' } + } + + # The mock does not pass through (pipeline binding inside mocks is unreliable); + # it returns a fixed parsed row, which is only correct BECAUSE the probe means + # the sole caller is the one matching row. + $script:JsonParses = 0 + Mock -CommandName ConvertFrom-Json -MockWith { + $script:JsonParses++ + [pscustomobject]@{ cveId = 'CVE-2024-0001'; customerId = 'contoso.onmicrosoft.com' } + } + + $Response = Invoke-ExecAddCippCveException -Request (New-ExceptionRequest -ApplyTo 'AllAffected') -TriggerMetadata @{} + + $Response.Body.TenantsAffected | Should -Be 1 + # One parse for the single matching row. Anything near 21 means the probe is + # gone and the whole cache is being deserialised again. + $script:JsonParses | Should -Be 1 + } + + It 'deduplicates tenants when several cached rows match the CVE for the same tenant' { + Mock -CommandName Get-CIPPDbItem -MockWith { + New-CachedCveRow -CveId 'CVE-2024-0001' -Tenant 'contoso.onmicrosoft.com' + New-CachedCveRow -CveId 'CVE-2024-0001' -Tenant 'contoso.onmicrosoft.com' + } + + $Response = Invoke-ExecAddCippCveException -Request (New-ExceptionRequest -ApplyTo 'AllAffected') -TriggerMetadata @{} + + $Response.Body.TenantsAffected | Should -Be 1 + @($script:Written).Count | Should -Be 1 + } + } + + Context 'other scopes' { + It 'writes a single ALL row for Global' { + $Response = Invoke-ExecAddCippCveException -Request (New-ExceptionRequest -ApplyTo 'Global') -TriggerMetadata @{} + + $Response.StatusCode | Should -Be ([HttpStatusCode]::OK) + @($script:Written).Count | Should -Be 1 + $script:Written[0].RowKey | Should -Be 'ALL' + $script:Written[0].customerId | Should -Be 'ALL' + } + + It 'scopes CurrentTenant to the tenant in the query' { + $Response = Invoke-ExecAddCippCveException -Request (New-ExceptionRequest -ApplyTo 'CurrentTenant') -TriggerMetadata @{} + + $Response.StatusCode | Should -Be ([HttpStatusCode]::OK) + @($script:Written).Count | Should -Be 1 + $script:Written[0].RowKey | Should -Be 'contoso.onmicrosoft.com' + } + + It 'rejects CurrentTenant when no single tenant is selected' { + $Response = Invoke-ExecAddCippCveException -Request (New-ExceptionRequest -ApplyTo 'CurrentTenant' -TenantFilter 'AllTenants') -TriggerMetadata @{} + + $Response.StatusCode | Should -Be ([HttpStatusCode]::InternalServerError) + @($script:Written).Count | Should -Be 0 + } + } + + Context 'validation' { + It 'returns BadRequest when required fields are missing' { + $Request = New-ExceptionRequest + $Request.Body.justification = '' + + $Response = Invoke-ExecAddCippCveException -Request $Request -TriggerMetadata @{} + + $Response.StatusCode | Should -Be ([HttpStatusCode]::BadRequest) + @($script:Written).Count | Should -Be 0 + } + } +} diff --git a/Tests/Endpoint/Invoke-ExecEditCAPolicyFull.Tests.ps1 b/Tests/Endpoint/Invoke-ExecEditCAPolicyFull.Tests.ps1 new file mode 100644 index 0000000000000..c46d26d87635b --- /dev/null +++ b/Tests/Endpoint/Invoke-ExecEditCAPolicyFull.Tests.ps1 @@ -0,0 +1,171 @@ +# Pester tests for Invoke-ExecEditCAPolicyFull +# The save is a PATCH, so what the body leaves out keeps whatever the tenant policy already had. +# These pin the two halves of that: emptied collections go out as [], and a condition block the +# editor emptied goes out as null rather than being dropped or sent half-populated. + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $FunctionPath = Get-ChildItem -Path (Join-Path $RepoRoot 'Modules') -Recurse -Filter 'Invoke-ExecEditCAPolicyFull.ps1' -File -ErrorAction SilentlyContinue | + Select-Object -First 1 -ExpandProperty FullName + if (-not $FunctionPath) { throw 'Could not locate Invoke-ExecEditCAPolicyFull.ps1 under Modules/' } + + # The Functions worker exposes [HttpStatusCode] as an accelerator; register it for tests. + ([PSObject].Assembly.GetType('System.Management.Automation.TypeAccelerators')).GetMethod('Add').Invoke( + $null, @('HttpStatusCode', [System.Net.HttpStatusCode])) + + class HttpResponseContext { + [object]$StatusCode + [object]$Body + } + + # The canonicalizer is the real one - that is the behaviour under test. + . (Join-Path $RepoRoot 'Modules/CIPPCore/Public/Functions/Format-CIPPCAPolicy.ps1') + + function New-GraphPOSTRequest { + [CmdletBinding()] param($uri, $tenantid, $type, $body, $asApp) + $script:LastPatch = @{ Uri = $uri; Type = $type; Body = $body } + } + function Write-LogMessage { [CmdletBinding()] param($API, $tenant, $headers, $message, $sev, $LogData) } + function Get-CippException { [CmdletBinding()] param($Exception) $Exception } + + . $FunctionPath + + function Invoke-Edit ($PolicyBody) { + $script:LastPatch = $null + $Request = @{ + Params = @{ CIPPEndpoint = 'ExecEditCAPolicyFull' } + Headers = @{} + Query = @{} + Body = [pscustomobject]@{ + tenantFilter = 'contoso.onmicrosoft.com' + PolicyId = 'policy-guid' + PolicyBody = ($PolicyBody | ConvertTo-Json -Depth 20 | ConvertFrom-Json) + } + } + $Response = Invoke-ExecEditCAPolicyFull -Request $Request + return @{ Response = $Response; Sent = ($script:LastPatch.Body | ConvertFrom-Json) } + } +} + +Describe 'Invoke-ExecEditCAPolicyFull' { + + Context 'a policy whose blocks the editor emptied' { + BeforeAll { + $script:Result = Invoke-Edit @{ + displayName = 'CA201' + state = 'disabled' + conditions = @{ + clientAppTypes = @('all') + applications = @{ includeApplications = @('All'); excludeApplications = @() } + users = @{ includeUsers = @('All'); includeGroups = @(); excludeGroups = @() } + platforms = $null + devices = $null + } + grantControls = @{ operator = 'OR'; builtInControls = @('mfa') } + sessionControls = $null + } + $script:Sent = $script:Result.Sent + } + + It 'returns OK' { + $script:Result.Response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::OK) + } + + It 'PATCHes the policy by id' { + $script:LastPatch.Type | Should -Be 'PATCH' + $script:LastPatch.Uri | Should -Be 'https://graph.microsoft.com/beta/identity/conditionalAccess/policies/policy-guid' + } + + It 'sends the emptied collections as [] so Graph clears them' { + $script:LastPatch.Body | Should -Match '"includeGroups":\[\]' + $script:LastPatch.Body | Should -Match '"excludeApplications":\[\]' + } + + It 'restores collections the body omitted as [] - a partial body must still clear them' { + # The form never sent excludeUsers at all; the canonicalizer says its absence out loud. + $script:LastPatch.Body | Should -Match '"excludeUsers":\[\]' + $script:Sent.conditions.users.PSObject.Properties.Name | Should -Contain 'excludeRoles' + } + + It 'sends the emptied block as null rather than dropping it' -ForEach @( + @{ Block = 'platforms' } + @{ Block = 'devices' } + ) { + $script:Sent.conditions.PSObject.Properties.Name | Should -Contain $Block + $script:Sent.conditions.$Block | Should -BeNullOrEmpty + } + + It 'sends sessionControls as null rather than dropping it' { + $script:Sent.PSObject.Properties.Name | Should -Contain 'sessionControls' + $script:Sent.sessionControls | Should -BeNullOrEmpty + } + + It 'leaves the assignments the user kept alone' { + $script:Sent.conditions.users.includeUsers | Should -Be @('All') + $script:Sent.grantControls.builtInControls | Should -Be @('mfa') + $script:Sent.displayName | Should -Be 'CA201' + } + } + + Context 'a half-populated block that Graph would reject' { + It 'collapses platforms with no includePlatforms to null' { + $Result = Invoke-Edit @{ + displayName = 'CA201' + conditions = @{ + users = @{ includeUsers = @('All') } + platforms = @{ includePlatforms = @(); excludePlatforms = @() } + } + } + $Result.Sent.conditions.platforms | Should -BeNullOrEmpty + } + + It 'keeps a platforms block that names a platform' { + $Result = Invoke-Edit @{ + displayName = 'CA201' + conditions = @{ + users = @{ includeUsers = @('All') } + platforms = @{ includePlatforms = @('android'); excludePlatforms = @() } + } + } + $Result.Sent.conditions.platforms.includePlatforms | Should -Be @('android') + } + } + + Context 'read-only properties' { + It 'never PATCHes id or createdDateTime back to Graph' { + $Result = Invoke-Edit @{ + displayName = 'CA201' + id = 'policy-guid' + createdDateTime = '2026-01-01T00:00:00Z' + templateId = 'template-guid' + conditions = @{ users = @{ includeUsers = @('All') } } + } + $Names = $Result.Sent.PSObject.Properties.Name + $Names | Should -Not -Contain 'id' + $Names | Should -Not -Contain 'createdDateTime' + $Names | Should -Not -Contain 'templateId' + } + } + + Context 'validation' { + It 'rejects a request with no PolicyBody' { + $Request = @{ + Params = @{ CIPPEndpoint = 'ExecEditCAPolicyFull' } + Headers = @{} + Query = @{} + Body = [pscustomobject]@{ tenantFilter = 'contoso.onmicrosoft.com'; PolicyId = 'policy-guid' } + } + (Invoke-ExecEditCAPolicyFull -Request $Request).StatusCode | Should -Be ([System.Net.HttpStatusCode]::BadRequest) + } + + It 'rejects a request with no PolicyId' { + $Request = @{ + Params = @{ CIPPEndpoint = 'ExecEditCAPolicyFull' } + Headers = @{} + Query = @{} + Body = [pscustomobject]@{ tenantFilter = 'contoso.onmicrosoft.com'; PolicyBody = [pscustomobject]@{ displayName = 'CA201' } } + } + (Invoke-ExecEditCAPolicyFull -Request $Request).StatusCode | Should -Be ([System.Net.HttpStatusCode]::BadRequest) + } + } +} diff --git a/Tests/Endpoint/Invoke-ListTenantAlignment.Summary.Tests.ps1 b/Tests/Endpoint/Invoke-ListTenantAlignment.Summary.Tests.ps1 new file mode 100644 index 0000000000000..77f0ccdcbf47c --- /dev/null +++ b/Tests/Endpoint/Invoke-ListTenantAlignment.Summary.Tests.ps1 @@ -0,0 +1,118 @@ +# Pester tests for the summary aggregate mode of Invoke-ListTenantAlignment. +# +# The row list is one entry per tenant per standard. The All Tenants dashboard rendered four bucket +# counts, an average, four low scorers and two pending totals from it, aggregating in the browser. +# summary=true returns just those aggregates. A tenant with five templates must still count once in +# the buckets, so its rows are averaged before bucketing - the same order of operations the client +# used, because getting it backwards changes the numbers. + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $FunctionPath = Get-ChildItem -Path (Join-Path $RepoRoot 'Modules') -Recurse -Filter 'Invoke-ListTenantAlignment.ps1' -File -ErrorAction SilentlyContinue | + Select-Object -First 1 -ExpandProperty FullName + if (-not $FunctionPath) { throw 'Could not locate Invoke-ListTenantAlignment.ps1 under Modules/' } + + class HttpResponseContext { + [int]$StatusCode + [object]$Body + } + $TypeAccelerators = [PowerShell].Assembly.GetType('System.Management.Automation.TypeAccelerators') + if (-not ([System.Management.Automation.PSTypeName]'HttpStatusCode').Type) { + $TypeAccelerators::Add('HttpStatusCode', [System.Net.HttpStatusCode]) + } + + function Get-CIPPTenantAlignment { [CmdletBinding()] param() } + function Get-CippTable { [CmdletBinding()] param($tablename) @{ Table = $tablename } } + function Get-CIPPAzDataTableEntity { [CmdletBinding()] param($Table, $Filter, $Property) @() } + function Get-Tenants { [CmdletBinding()] param($TenantFilter, [switch]$IncludeErrors, [switch]$IncludeAll) } + function Write-LogMessage { [CmdletBinding()] param($API, $tenant, $message, $sev, $LogData) } + function Get-CippException { [CmdletBinding()] param($Exception) @{ NormalizedError = $Exception.Exception.Message } } + + . $FunctionPath + + $script:Tenants = @( + [pscustomobject]@{ defaultDomainName = 'alpha.onmicrosoft.com'; displayName = 'Alpha'; customerId = 'aaaa-1111' } + [pscustomobject]@{ defaultDomainName = 'beta.onmicrosoft.com'; displayName = 'Beta'; customerId = 'bbbb-2222' } + [pscustomobject]@{ defaultDomainName = 'gamma.onmicrosoft.com'; displayName = 'Gamma'; customerId = 'cccc-3333' } + ) + + # alpha has two templates averaging 95 (strong), beta one at 60 (weak), gamma one at 10 (poor). + # alpha's two rows must not count as two tenants. + $script:Alignment = @( + [pscustomobject]@{ TenantFilter = 'alpha.onmicrosoft.com'; StandardName = 'T1'; StandardId = '1'; StandardType = 'Classic Standard'; AlignmentScore = 90; CombinedScore = 100; LicenseMissingPercentage = 0; PendingDeviationsCount = 2; DeniedDeviationsCount = 0; LatestDataCollection = '2026-08-12' } + [pscustomobject]@{ TenantFilter = 'alpha.onmicrosoft.com'; StandardName = 'T2'; StandardId = '2'; StandardType = 'Classic Standard'; AlignmentScore = 80; CombinedScore = 90; LicenseMissingPercentage = 0; PendingDeviationsCount = 3; DeniedDeviationsCount = 0; LatestDataCollection = '2026-08-12' } + [pscustomobject]@{ TenantFilter = 'beta.onmicrosoft.com'; StandardName = 'T1'; StandardId = '1'; StandardType = 'Classic Standard'; AlignmentScore = 50; CombinedScore = 60; LicenseMissingPercentage = 0; PendingDeviationsCount = 0; DeniedDeviationsCount = 0; LatestDataCollection = '2026-08-12' } + [pscustomobject]@{ TenantFilter = 'gamma.onmicrosoft.com'; StandardName = 'T1'; StandardId = '1'; StandardType = 'Classic Standard'; AlignmentScore = 5; CombinedScore = 10; LicenseMissingPercentage = 0; PendingDeviationsCount = 7; DeniedDeviationsCount = 0; LatestDataCollection = '2026-08-12' } + ) + + function script:New-AlignmentRequest { + param([hashtable]$Query = @{}) + [pscustomobject]@{ Query = [pscustomobject]$Query; Headers = @{}; Params = @{ CIPPEndpoint = 'ListTenantAlignment' } } + } +} + +Describe 'Invoke-ListTenantAlignment summary mode' { + BeforeEach { + Mock -CommandName Get-CIPPTenantAlignment -MockWith { $script:Alignment } + Mock -CommandName Get-Tenants -MockWith { $script:Tenants } + Mock -CommandName Write-LogMessage -MockWith { } + } + + It 'returns aggregates instead of rows' { + $Result = Invoke-ListTenantAlignment -Request (script:New-AlignmentRequest @{ summary = $true }) -TriggerMetadata @{} + + $Result.Body.ScoredTenantCount | Should -Be 3 + $Result.Body.Buckets | Should -Not -BeNullOrEmpty + # Not a row list. + $Result.Body.Count | Should -Not -Be 4 + } + + It 'averages a tenant across its templates before bucketing it' { + $Body = (Invoke-ListTenantAlignment -Request (script:New-AlignmentRequest @{ summary = $true }) -TriggerMetadata @{}).Body + + # alpha = (100 + 90) / 2 = 95 -> strong, and counts once. + $Body.Buckets.Strong | Should -Be 1 + $Body.Buckets.Good | Should -Be 0 + $Body.Buckets.Weak | Should -Be 1 + $Body.Buckets.Poor | Should -Be 1 + } + + It 'averages over tenants, not over rows' { + $Body = (Invoke-ListTenantAlignment -Request (script:New-AlignmentRequest @{ summary = $true }) -TriggerMetadata @{}).Body + + # (95 + 60 + 10) / 3 = 55. Averaging the four rows instead would give 65. + $Body.Average | Should -Be 55 + } + + It 'totals pending deviations and the tenants carrying them' { + $Body = (Invoke-ListTenantAlignment -Request (script:New-AlignmentRequest @{ summary = $true }) -TriggerMetadata @{}).Body + + $Body.PendingDeviations | Should -Be 12 + $Body.PendingTenantCount | Should -Be 2 + } + + It 'lists the lowest scorers with their display names' { + $Body = (Invoke-ListTenantAlignment -Request (script:New-AlignmentRequest @{ summary = $true }) -TriggerMetadata @{}).Body + + $Body.Lowest[0].Tenant | Should -BeExactly 'gamma.onmicrosoft.com' + $Body.Lowest[0].Score | Should -Be 10 + $Body.Lowest[0].Name | Should -BeExactly 'Gamma' + $Body.Lowest[1].Tenant | Should -BeExactly 'beta.onmicrosoft.com' + } + + It 'still returns the full row list without summary' { + $Result = Invoke-ListTenantAlignment -Request (script:New-AlignmentRequest @{}) -TriggerMetadata @{} + + @($Result.Body).Count | Should -Be 4 + @($Result.Body)[0].tenantFilter | Should -BeExactly 'alpha.onmicrosoft.com' + } + + It 'handles an estate with no alignment data' { + Mock -CommandName Get-CIPPTenantAlignment -MockWith { @() } + + $Body = (Invoke-ListTenantAlignment -Request (script:New-AlignmentRequest @{ summary = $true }) -TriggerMetadata @{}).Body + $Body.Average | Should -Be 0 + $Body.ScoredTenantCount | Should -Be 0 + @($Body.Lowest).Count | Should -Be 0 + } +} diff --git a/Tests/Private/Add-CIPPDbItem.Tests.ps1 b/Tests/Private/Add-CIPPDbItem.Tests.ps1 index c7c885ab6b35c..1a982fdbf16f0 100644 --- a/Tests/Private/Add-CIPPDbItem.Tests.ps1 +++ b/Tests/Private/Add-CIPPDbItem.Tests.ps1 @@ -59,4 +59,45 @@ Describe 'Add-CIPPDbItem authoritative empty collections' { $Entity.RowKey -eq 'IntuneIntents-old-policy' } } + + It 'never deletes rows this run wrote, even when the cleanup query returns them' { + # The delete authority is the RunId stamped on every written row - identity, not + # age. This feeds a row THE RUN ITSELF WROTE back through the cleanup query, which + # is what an arbitrarily slow run or a storage clock running behind would produce; + # only the foreign row may be deleted. + $script:Flushed = [System.Collections.Generic.List[object]]::new() + Mock Add-CIPPAzDataTableEntity { $script:Flushed.AddRange(@($Entity)) } + Mock Get-CIPPAzDataTableEntity { + @( + [PSCustomObject]@{ + PartitionKey = 'contoso.onmicrosoft.com' + RowKey = 'IntuneIntents-earlier-run' + ETag = '*' + } + ) + @($script:Flushed | Where-Object { $_.RowKey -ne 'IntuneIntents-Count' }) + } + + $Policy = [PSCustomObject]@{ id = 'new-policy'; displayName = 'New policy' } + Add-CIPPDbItem -TenantFilter 'contoso.onmicrosoft.com' -Type 'IntuneIntents' -Data @($Policy) -AddCount -ClearOnEmpty + + Should -Invoke Remove-CIPPAzDataTableEntity -Times 1 -Exactly -ParameterFilter { + @($Entity).Count -eq 1 -and @($Entity)[0].RowKey -eq 'IntuneIntents-earlier-run' + } + } + + It 'stamps every written row with the run id the cleanup keys on' { + $script:Flushed = [System.Collections.Generic.List[object]]::new() + Mock Add-CIPPAzDataTableEntity { $script:Flushed.AddRange(@($Entity)) } + Mock Get-CIPPAzDataTableEntity { @() } + + Add-CIPPDbItem -TenantFilter 'contoso.onmicrosoft.com' -Type 'IntuneIntents' -Data @( + [PSCustomObject]@{ id = 'a' } + [PSCustomObject]@{ id = 'b' } + ) + + $DataRows = @($script:Flushed | Where-Object { $_.RowKey -ne 'IntuneIntents-Count' }) + $DataRows.Count | Should -Be 2 + $DataRows | ForEach-Object { $_.RunId | Should -Not -BeNullOrEmpty } + ($DataRows.RunId | Sort-Object -Unique).Count | Should -Be 1 + } } diff --git a/Tests/Private/Add-CIPPGroupMember.Tests.ps1 b/Tests/Private/Add-CIPPGroupMember.Tests.ps1 index f5311aa555809..efc29d4d0d2c2 100644 --- a/Tests/Private/Add-CIPPGroupMember.Tests.ps1 +++ b/Tests/Private/Add-CIPPGroupMember.Tests.ps1 @@ -29,6 +29,12 @@ BeforeAll { if (-not $ResolverPath) { throw 'Could not locate Resolve-CippExoBulkResult.ps1 under Modules/' } . $ResolverPath + # The resolver delegates error-text extraction to this pure helper; use the real one too. + $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 + . $FunctionPath # Graph bulk responses for the lookup leg: one entry per requested user plus the group. diff --git a/Tests/Private/Format-CIPPCAPolicy.Tests.ps1 b/Tests/Private/Format-CIPPCAPolicy.Tests.ps1 new file mode 100644 index 0000000000000..0401056ac55fd --- /dev/null +++ b/Tests/Private/Format-CIPPCAPolicy.Tests.ps1 @@ -0,0 +1,340 @@ +# Pester tests for Format-CIPPCAPolicy +# Deploying a CA template over an existing policy is a PATCH, and PATCH merges - so what the body +# says (and what it omits) decides whether tenant-side deviations are cleared or silently survive. +# These pin the canonicalizer's two phases: absent managed keys are expanded back as their cleared +# form ([] / null), and only the containers Graph rejects when empty collapse to null. Empty +# assignment arrays always survive into the body - they are the only thing that clears one. + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + . (Join-Path $RepoRoot 'Modules/CIPPCore/Public/Functions/Format-CIPPCAPolicy.ps1') + + function Convert-Policy ($Json) { + $Object = $Json | ConvertFrom-Json + Format-CIPPCAPolicy -Policy $Object + return $Object + } +} + +Describe 'Format-CIPPCAPolicy' { + + Context 'stored templates stripped by older editors are healed (expansion)' { + BeforeAll { + # The shape an old-editor template actually has at rest: every emptied key was + # stripped at save time, so only populated assignments remain. + $script:Healed = Convert-Policy '{ + "displayName": "CA201", + "conditions": { + "clientAppTypes": ["all"], + "applications": { "includeApplications": ["All"] }, + "users": { "includeUsers": ["All"], "excludeGroups": ["Break Glass"] } + }, + "grantControls": { "operator": "OR", "builtInControls": ["mfa"] } + }' + } + + It 'restores absent user collection as an empty array' -ForEach @( + @{ Property = 'excludeUsers' } + @{ Property = 'includeGroups' } + @{ Property = 'includeRoles' } + @{ Property = 'excludeRoles' } + ) { + $Users = $script:Healed.conditions.users + $Users.PSObject.Properties.Name | Should -Contain $Property + @($Users.$Property).Count | Should -Be 0 + # An empty array piped into Should reads as null, so compare without the pipeline. + ($null -eq $Users.$Property) | Should -BeFalse + } + + It 'restores absent condition block as null' -ForEach @( + @{ Block = 'platforms' } + @{ Block = 'locations' } + @{ Block = 'devices' } + @{ Block = 'clientApplications' } + @{ Block = 'authenticationFlows' } + @{ Block = 'insiderRiskLevels' } + ) { + $script:Healed.conditions.PSObject.Properties.Name | Should -Contain $Block + $script:Healed.conditions.$Block | Should -BeNullOrEmpty + } + + It 'restores absent guest blocks as null' { + $Users = $script:Healed.conditions.users + $Users.PSObject.Properties.Name | Should -Contain 'includeGuestsOrExternalUsers' + $Users.PSObject.Properties.Name | Should -Contain 'excludeGuestsOrExternalUsers' + $Users.includeGuestsOrExternalUsers | Should -BeNullOrEmpty + $Users.excludeGuestsOrExternalUsers | Should -BeNullOrEmpty + } + + It 'restores absent application collections and filter' { + $Apps = $script:Healed.conditions.applications + @($Apps.excludeApplications).Count | Should -Be 0 + @($Apps.includeUserActions).Count | Should -Be 0 + $Apps.PSObject.Properties.Name | Should -Contain 'applicationFilter' + $Apps.applicationFilter | Should -BeNullOrEmpty + } + + It 'restores absent risk level collections as empty arrays' { + @($script:Healed.conditions.signInRiskLevels).Count | Should -Be 0 + @($script:Healed.conditions.userRiskLevels).Count | Should -Be 0 + } + + It 'adds sessionControls as null when grantControls carries the policy' { + $script:Healed.PSObject.Properties.Name | Should -Contain 'sessionControls' + $script:Healed.sessionControls | Should -BeNullOrEmpty + } + + It 'serialises the healed clears into the request body' { + $Body = ConvertTo-Json -InputObject $script:Healed -Depth 10 -Compress + $Body | Should -Match '"excludeUsers":\[\]' + $Body | Should -Match '"platforms":null' + } + + It 'leaves clientAppTypes alone - Graph requires it non-empty, absence stays a merge' { + $Policy = Convert-Policy '{ + "displayName": "CA201", + "conditions": { "users": { "includeUsers": ["All"] } } + }' + $Policy.conditions.PSObject.Properties.Name | Should -Not -Contain 'clientAppTypes' + } + + It 'leaves populated assignments untouched' { + $script:Healed.conditions.users.includeUsers | Should -Be @('All') + $script:Healed.conditions.users.excludeGroups | Should -Be @('Break Glass') + $script:Healed.grantControls.builtInControls | Should -Be @('mfa') + } + } + + Context 'grantControls/sessionControls at-least-one rule' { + It 'adds grantControls as null when only sessionControls carries the policy' { + $Policy = Convert-Policy '{ + "displayName": "CA201", + "conditions": { "users": { "includeUsers": ["All"] } }, + "sessionControls": { "signInFrequency": { "isEnabled": true, "type": "hours", "value": 4 } } + }' + $Policy.PSObject.Properties.Name | Should -Contain 'grantControls' + $Policy.grantControls | Should -BeNullOrEmpty + } + + It 'adds neither when both are absent' { + $Policy = Convert-Policy '{"displayName":"CA201","conditions":{"users":{"includeUsers":["All"]}}}' + $Policy.PSObject.Properties.Name | Should -Not -Contain 'sessionControls' + } + + It 'does not add the counterpart of an explicitly null control' { + $Policy = Convert-Policy '{"displayName":"CA201","conditions":{"users":{"includeUsers":["All"]}},"grantControls":null}' + $Policy.PSObject.Properties.Name | Should -Not -Contain 'sessionControls' + } + } + + Context 'required parents are never invented' { + It 'does not invent conditions' { + $Policy = Convert-Policy '{"displayName":"CA201"}' + $Policy.PSObject.Properties.Name | Should -Not -Contain 'conditions' + } + + It 'does not invent users or applications under conditions' { + $Policy = Convert-Policy '{"displayName":"CA201","conditions":{"clientAppTypes":["all"]}}' + $Policy.conditions.PSObject.Properties.Name | Should -Not -Contain 'users' + $Policy.conditions.PSObject.Properties.Name | Should -Not -Contain 'applications' + } + } + + Context 'whitespace-only entries normalise to a clean clear' { + It 'turns a whitespace-only user collection into an empty array' { + $Policy = Convert-Policy '{ + "displayName": "CA201", + "conditions": { "users": { "includeUsers": ["All"], "excludeUsers": [" ", ""] } } + }' + @($Policy.conditions.users.excludeUsers).Count | Should -Be 0 + } + } + + Context 'empty assignments survive so a PATCH can clear them' { + BeforeAll { + $script:Policy = Convert-Policy '{ + "displayName": "CA201", + "conditions": { + "userRiskLevels": [], + "signInRiskLevels": [], + "applications": { "includeApplications": ["All"], "excludeApplications": [] }, + "users": { + "includeUsers": ["All"], + "excludeUsers": [], + "includeGroups": [], + "excludeGroups": ["Break Glass"], + "includeRoles": [], + "excludeRoles": [] + } + }, + "grantControls": { "operator": "OR", "builtInControls": ["mfa"], "termsOfUse": [] } + }' + } + + It 'keeps as an empty array' -ForEach @( + @{ Property = 'includeGroups' } + @{ Property = 'excludeUsers' } + @{ Property = 'includeRoles' } + @{ Property = 'excludeRoles' } + ) { + $Users = $script:Policy.conditions.users + $Users.PSObject.Properties.Name | Should -Contain $Property + @($Users.$Property).Count | Should -Be 0 + } + + It 'serialises the empty assignment into the request body' { + $Body = ConvertTo-Json -InputObject $script:Policy -Depth 10 -Compress + $Body | Should -Match '"includeGroups":\[\]' + $Body | Should -Match '"excludeApplications":\[\]' + } + + It 'leaves populated assignments alone' { + $script:Policy.conditions.users.includeUsers | Should -Be @('All') + $script:Policy.conditions.users.excludeGroups | Should -Be @('Break Glass') + $script:Policy.grantControls.builtInControls | Should -Be @('mfa') + } + } + + Context 'containers Graph rejects when empty collapse to null' { + BeforeAll { + $script:Collapsed = Convert-Policy '{ + "displayName": "CA201", + "conditions": { + "platforms": { "includePlatforms": [], "excludePlatforms": [] }, + "locations": { "includeLocations": [], "excludeLocations": [] }, + "devices": { "deviceFilter": null, "includeDevices": [], "excludeDevices": [] }, + "clientApplications": { "includeServicePrincipals": [], "excludeServicePrincipals": [] }, + "users": { + "includeUsers": ["All"], + "excludeGuestsOrExternalUsers": { + "guestOrExternalUserTypes": "", + "externalTenants": { "@odata.type": "#microsoft.graph.conditionalAccessAllExternalTenants", "membershipKind": "all" } + } + } + }, + "grantControls": { "operator": "OR", "builtInControls": [], "customAuthenticationFactors": [], "termsOfUse": [], "authenticationStrength": null }, + "sessionControls": {} + }' + } + + It 'nulls rather than removing the property' -ForEach @( + @{ Path = 'platforms' } + @{ Path = 'locations' } + @{ Path = 'devices' } + @{ Path = 'clientApplications' } + ) { + $script:Collapsed.conditions.PSObject.Properties.Name | Should -Contain $Path + $script:Collapsed.conditions.$Path | Should -BeNullOrEmpty + } + + It 'nulls an excludeGuestsOrExternalUsers block that only carries an @odata.type' { + $script:Collapsed.conditions.users.excludeGuestsOrExternalUsers | Should -BeNullOrEmpty + } + + It 'nulls grantControls that carry no actual control' { + $script:Collapsed.grantControls | Should -BeNullOrEmpty + } + + It 'nulls an empty sessionControls object' { + $script:Collapsed.PSObject.Properties.Name | Should -Contain 'sessionControls' + $script:Collapsed.sessionControls | Should -BeNullOrEmpty + } + } + + Context 'populated containers are left intact' { + BeforeAll { + $script:Kept = Convert-Policy '{ + "displayName": "CA201", + "conditions": { + "platforms": { "includePlatforms": ["android"], "excludePlatforms": [] }, + "locations": { "includeLocations": ["All"], "excludeLocations": [] }, + "devices": { "deviceFilter": { "mode": "exclude", "rule": "device.isCompliant -eq True" } }, + "clientApplications": { "includeServicePrincipals": ["ServicePrincipalsInMyTenant"], "excludeServicePrincipals": [] }, + "users": { + "includeUsers": ["None"], + "includeGuestsOrExternalUsers": { + "guestOrExternalUserTypes": "b2bCollaborationGuest", + "externalTenants": { "@odata.type": "#microsoft.graph.conditionalAccessAllExternalTenants", "membershipKind": "all" } + } + } + }, + "grantControls": { "operator": "OR", "builtInControls": [], "customAuthenticationFactors": [], "termsOfUse": [], "authenticationStrength": { "id": "00000000-0000-0000-0000-000000000002" } } + }' + } + + It 'keeps a platform condition that names a platform' { + $script:Kept.conditions.platforms.includePlatforms | Should -Be @('android') + } + + It 'keeps a device condition that only has a deviceFilter' { + $script:Kept.conditions.devices.deviceFilter.rule | Should -Be 'device.isCompliant -eq True' + } + + It 'keeps a location and clientApplications condition' { + $script:Kept.conditions.locations.includeLocations | Should -Be @('All') + $script:Kept.conditions.clientApplications.includeServicePrincipals | Should -Be @('ServicePrincipalsInMyTenant') + } + + It 'keeps a guest block that names a guest type' { + $script:Kept.conditions.users.includeGuestsOrExternalUsers.guestOrExternalUserTypes | Should -Be 'b2bCollaborationGuest' + } + + It 'keeps grantControls held up only by an authentication strength' { + $script:Kept.grantControls.authenticationStrength.id | Should -Be '00000000-0000-0000-0000-000000000002' + } + } + + Context 'filters that select nothing' { + It 'nulls a that has a mode but no rule' -ForEach @( + @{ Filter = 'applicationFilter'; Parent = 'applications' } + @{ Filter = 'deviceFilter'; Parent = 'devices' } + @{ Filter = 'servicePrincipalFilter'; Parent = 'clientApplications' } + ) { + $Policy = Convert-Policy ('{ + "displayName": "CA201", + "conditions": { + "users": { "includeUsers": ["All"] }, + "applications": { "includeApplications": ["All"], "applicationFilter": null }, + "devices": { "deviceFilter": null, "includeDevices": ["fake"] }, + "clientApplications": { "includeServicePrincipals": ["fake"], "servicePrincipalFilter": null } + } + }' -replace "`"$Filter`": null", ('"{0}": {{ "mode": "include", "rule": "" }}' -f $Filter)) + + $Policy.conditions.$Parent.$Filter | Should -BeNullOrEmpty + } + + It 'collapses a devices block whose only content was a ruleless filter' { + $Policy = Convert-Policy '{ + "displayName": "CA201", + "conditions": { + "users": { "includeUsers": ["All"] }, + "devices": { "deviceFilter": { "mode": "exclude", "rule": " " }, "includeDevices": [] } + } + }' + $Policy.conditions.devices | Should -BeNullOrEmpty + } + + It 'keeps a filter that has a rule' { + $Policy = Convert-Policy '{ + "displayName": "CA201", + "conditions": { + "users": { "includeUsers": ["All"] }, + "applications": { "includeApplications": ["All"], "applicationFilter": { "mode": "include", "rule": "application.tag -eq \"x\"" } } + } + }' + $Policy.conditions.applications.applicationFilter.rule | Should -Be 'application.tag -eq "x"' + } + } + + Context 'edge cases' { + It 'does nothing to a policy with no conditions at all' { + { Convert-Policy '{"displayName":"CA201"}' } | Should -Not -Throw + } + + It 'leaves an already-null container null' { + $Policy = Convert-Policy '{"displayName":"CA201","conditions":{"platforms":null,"users":{"includeUsers":["All"]}}}' + $Policy.conditions.PSObject.Properties.Name | Should -Contain 'platforms' + $Policy.conditions.platforms | Should -BeNullOrEmpty + } + } +} diff --git a/Tests/Private/Get-CIPPCVEReport.Tests.ps1 b/Tests/Private/Get-CIPPCVEReport.Tests.ps1 new file mode 100644 index 0000000000000..a17aa0c425012 --- /dev/null +++ b/Tests/Private/Get-CIPPCVEReport.Tests.ps1 @@ -0,0 +1,228 @@ +# Pester tests for Get-CIPPCVEReport +# +# This is the report-database read path behind Invoke-ListCVEManagement (UseReportDB and +# AllTenants). It folds cached rows one at a time - each row's Data blob is parsed and merged +# before the next is touched - so the parsed object graphs never all exist at once on the HTTP +# worker pool's shared heap. These tests lock the response shape the frontend renders and the +# tenant-validation / exception-merge semantics. + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $FunctionPath = Join-Path $RepoRoot 'Modules/CIPPCore/Public/Get-CIPPCVEReport.ps1' + + # Minimal stubs so Mock has commands to replace during tests. + function Get-CIPPTable { param($TableName) } + function Get-CIPPAzDataTableEntity { param($Context, $Filter, $Property) } + function Get-CIPPDbItem { param($TenantFilter, $Type, [switch]$CountsOnly) } + function Get-Tenants { param($TenantFilter, [switch]$IncludeErrors) } + function Write-LogMessage { param($API, $tenant, $message, $sev, $LogData) } + + . $FunctionPath + + # A cached row exactly as Add-CIPPDbItem stores what Set-CIPPDBCacheDefenderCVEs emits: + # the table row is keyed by tenant, and the CVE payload lives in the Data JSON with the + # CVE id as ITS PartitionKey. + function New-CveRow { + param( + $CveId = 'CVE-2024-0001', + $Tenant = 'contoso.onmicrosoft.com', + $Devices = @(@{ deviceId = 'd1'; deviceName = 'PC-1'; osVersion = '10.0.19045'; softwareVersion = '120.0.0'; diskPaths = ''; registryPaths = '' }), + $LastUpdated = '2026-08-12T00:00:00.000Z' + ) + $Payload = @{ + PartitionKey = $CveId + RowKey = $Tenant + customerId = $Tenant + cveId = $CveId + softwareVendor = 'microsoft' + softwareName = 'edge' + vulnerabilitySeverityLevel = 'High' + recommendedSecurityUpdate = 'KB5034123' + recommendedSecurityUpdateUrl = 'https://support.microsoft.com/kb/5034123' + exploitabilityLevel = 'ExploitIsPublic' + deviceCount = @($Devices).Count + # Piped, not -InputObject: one device stays a bare object, several become an + # array - the exact shape the collector writes. + deviceDetailsJson = [string]($Devices | ConvertTo-Json -Compress) + lastUpdated = $LastUpdated + } + [pscustomobject]@{ + PartitionKey = $Tenant + RowKey = "DefenderCVEs-$([guid]::NewGuid())" + Data = [string]($Payload | ConvertTo-Json -Depth 100 -Compress) + Type = 'DefenderCVEs' + } + } + + function New-CountRow { + param($Tenant = 'contoso.onmicrosoft.com') + [pscustomobject]@{ + PartitionKey = $Tenant + RowKey = 'DefenderCVEs-Count' + DataCount = 1 + Type = 'DefenderCVEs' + } + } +} + +Describe 'Get-CIPPCVEReport' { + BeforeEach { + $script:Tenant = 'contoso.onmicrosoft.com' + + Mock -CommandName Write-LogMessage -MockWith { } + Mock -CommandName Get-CIPPTable -MockWith { @{} } + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { @() } + Mock -CommandName Get-Tenants -MockWith { + @([pscustomobject]@{ defaultDomainName = 'contoso.onmicrosoft.com' }) + } + } + + Context 'single tenant' { + It 'returns one aggregated entry per CVE with every field the frontend reads' { + Mock -CommandName Get-CIPPDbItem -MockWith { + New-CveRow -CveId 'CVE-B' -Devices @( + @{ deviceId = 'd1'; deviceName = 'PC-1'; osVersion = ''; softwareVersion = ''; diskPaths = 'C:\a\edge.exe'; registryPaths = 'HKLM\SOFTWARE\X' } + @{ deviceId = 'd2'; deviceName = 'PC-2'; osVersion = ''; softwareVersion = ''; diskPaths = ''; registryPaths = '' } + ) + New-CveRow -CveId 'CVE-A' + New-CountRow + } + + $Result = @(Get-CIPPCVEReport -TenantFilter $script:Tenant) + + # Sorted by cveId, count row ignored. + $Result.Count | Should -Be 2 + $Result[0].cveId | Should -Be 'CVE-A' + $Result[1].cveId | Should -Be 'CVE-B' + + $B = $Result[1] + $B.vulnerabilitySeverityLevel | Should -Be 'High' + $B.exploitabilityLevel | Should -Be 'ExploitIsPublic' + $B.softwareName | Should -Be 'edge' + $B.softwareVendor | Should -Be 'microsoft' + $B.deviceCount | Should -Be 2 + $B.tenantCount | Should -Be 1 + @($B.affectedTenants).customerId | Should -Be @($script:Tenant) + (@($B.affectedDevices).deviceName | Sort-Object) | Should -Be @('PC-1', 'PC-2') + @($B.diskPaths).Count | Should -Be 1 + @($B.diskPaths)[0].diskPaths | Should -Be 'C:\a\edge.exe' + @($B.registryPaths)[0].registryPaths | Should -Be 'HKLM\SOFTWARE\X' + $B.exceptionStatus | Should -Be 'None' + $B.hasException | Should -BeFalse + # ConvertFrom-Json turns the ISO stamp in the Data blob into a DateTime, so the + # report carries the instant, not the original string formatting. + ([datetime]$B.cacheTimeStamp).ToUniversalTime().Ticks | Should -Be ([datetime]::Parse('2026-08-12T00:00:00Z', [cultureinfo]::InvariantCulture, [System.Globalization.DateTimeStyles]::AdjustToUniversal)).Ticks + } + + It 'deduplicates devices by name within a row' { + Mock -CommandName Get-CIPPDbItem -MockWith { + New-CveRow -CveId 'CVE-A' -Devices @( + @{ deviceId = 'd1'; deviceName = 'PC-1'; osVersion = ''; softwareVersion = '1.0'; diskPaths = ''; registryPaths = '' } + @{ deviceId = 'd1'; deviceName = 'PC-1'; osVersion = ''; softwareVersion = '2.0'; diskPaths = ''; registryPaths = '' } + ) + } + + $Result = @(Get-CIPPCVEReport -TenantFilter $script:Tenant) + + $Result[0].deviceCount | Should -Be 1 + @($Result[0].affectedDevices).Count | Should -Be 1 + } + + It 'returns a bare empty array when the cache only holds the count row' { + Mock -CommandName Get-CIPPDbItem -MockWith { New-CountRow } + + $Result = Get-CIPPCVEReport -TenantFilter $script:Tenant + + @($Result).Count | Should -Be 0 + } + } + + Context 'AllTenants' { + BeforeEach { + Mock -CommandName Get-Tenants -MockWith { + @( + [pscustomobject]@{ defaultDomainName = 'contoso.onmicrosoft.com' } + [pscustomobject]@{ defaultDomainName = 'fabrikam.onmicrosoft.com' } + ) + } + } + + It 'merges the same CVE across tenants into one entry' { + Mock -CommandName Get-CIPPDbItem -MockWith { + New-CveRow -CveId 'CVE-A' -Tenant 'contoso.onmicrosoft.com' + New-CveRow -CveId 'CVE-A' -Tenant 'fabrikam.onmicrosoft.com' -Devices @( + @{ deviceId = 'd9'; deviceName = 'PC-9'; osVersion = ''; softwareVersion = ''; diskPaths = ''; registryPaths = '' } + ) + } + + $Result = @(Get-CIPPCVEReport -TenantFilter 'AllTenants') + + $Result.Count | Should -Be 1 + $Result[0].tenantCount | Should -Be 2 + $Result[0].deviceCount | Should -Be 2 + (@($Result[0].affectedTenants).customerId | Sort-Object) | Should -Be @('contoso.onmicrosoft.com', 'fabrikam.onmicrosoft.com') + } + + It 'drops rows belonging to tenants that are no longer managed' { + Mock -CommandName Get-CIPPDbItem -MockWith { + New-CveRow -CveId 'CVE-A' -Tenant 'contoso.onmicrosoft.com' + New-CveRow -CveId 'CVE-ORPHAN' -Tenant 'departed.onmicrosoft.com' + } + + $Result = @(Get-CIPPCVEReport -TenantFilter 'AllTenants') + + $Result.Count | Should -Be 1 + $Result[0].cveId | Should -Be 'CVE-A' + } + } + + Context 'exceptions' { + It 'marks a CVE All when an ALL-scoped exception matches and Partial for tenant-scoped' { + Mock -CommandName Get-CIPPDbItem -MockWith { + New-CveRow -CveId 'CVE-GLOBAL' + New-CveRow -CveId 'CVE-LOCAL' + } + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { + @( + [pscustomobject]@{ cveId = 'CVE-GLOBAL'; customerId = 'ALL'; exceptionType = 'RiskAccepted'; exceptionSource = 'CIPP'; exceptionComment = 'global'; exceptionCreatedBy = 'admin'; exceptionReadableDate = 'today'; exceptionExpiry = '' } + [pscustomobject]@{ cveId = 'CVE-LOCAL'; customerId = 'contoso.onmicrosoft.com'; exceptionType = 'Mitigated'; exceptionSource = 'CIPP'; exceptionComment = 'local'; exceptionCreatedBy = 'admin'; exceptionReadableDate = 'today'; exceptionExpiry = '' } + ) + } + + $Result = @(Get-CIPPCVEReport -TenantFilter $script:Tenant) + + $Global = $Result | Where-Object { $_.cveId -eq 'CVE-GLOBAL' } + $Global.exceptionStatus | Should -Be 'All' + $Global.hasException | Should -BeTrue + $Global.exceptionType.exceptionType | Should -Be 'RiskAccepted' + + $Local = $Result | Where-Object { $_.cveId -eq 'CVE-LOCAL' } + $Local.exceptionStatus | Should -Be 'Partial' + $Local.exceptionComment.exceptionComment | Should -Be 'local' + } + + It 'ignores exceptions scoped to tenants outside the filter' { + Mock -CommandName Get-CIPPDbItem -MockWith { New-CveRow -CveId 'CVE-A' } + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { + @([pscustomobject]@{ cveId = 'CVE-A'; customerId = 'fabrikam.onmicrosoft.com'; exceptionType = 'Mitigated'; exceptionSource = 'CIPP'; exceptionComment = 'other tenant'; exceptionCreatedBy = 'admin'; exceptionReadableDate = 'today'; exceptionExpiry = '' }) + } + + $Result = @(Get-CIPPCVEReport -TenantFilter $script:Tenant) + + $Result[0].exceptionStatus | Should -Be 'None' + $Result[0].hasException | Should -BeFalse + } + } + + Context 'failures' { + It 'logs and rethrows when the cache read fails' { + Mock -CommandName Get-CIPPDbItem -MockWith { throw 'table unavailable' } + + { Get-CIPPCVEReport -TenantFilter $script:Tenant } | Should -Throw + + Should -Invoke Write-LogMessage -Times 1 -Exactly -ParameterFilter { + $message -like 'Failed to generate CVE report*' -and $sev -eq 'Error' + } + } + } +} diff --git a/Tests/Private/Get-CIPPTable.Tests.ps1 b/Tests/Private/Get-CIPPTable.Tests.ps1 new file mode 100644 index 0000000000000..ace3d9e5c818f --- /dev/null +++ b/Tests/Private/Get-CIPPTable.Tests.ps1 @@ -0,0 +1,240 @@ +# Pester tests for Get-CIPPTable. +# +# Get-CIPPTable used to call New-AzDataTable unconditionally, which 409s once the table exists - +# billed like any other request, on nearly every code path. These tests protect the properties +# that make caching that safe: CreateTable happens once per account+table, a dropped table is +# recreated on next use, and the key includes the account. + +BeforeAll { + $BackendRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $FunctionPath = Join-Path $BackendRoot 'Modules/CIPPCore/Public/GraphHelper/Get-CIPPTable.ps1' + if (-not (Test-Path $FunctionPath)) { throw "Could not locate Get-CIPPTable.ps1 at $FunctionPath" } + + # Records every CreateTable that would have gone to the wire. + $script:CreateCalls = [System.Collections.Generic.List[string]]::new() + + function New-AzDataTableContext { + param($ConnectionString, $TableName, $MaxConnectionsPerServer) + [pscustomobject]@{ TableName = $TableName; ConnectionString = $ConnectionString } + } + function New-AzDataTable { + param($Context) + $script:CreateCalls.Add($Context.TableName) + } + + # Account-scoped ListTables, as used by Initialize-CIPPTables. + $script:ExistingTables = @() + function Get-AzDataTable { + param($Context, $Filter, $MaxRetries) + $script:ExistingTables + } + + . $FunctionPath + + $InitPath = Join-Path $BackendRoot 'Modules/CIPPCore/Public/GraphHelper/Initialize-CIPPTables.ps1' + if (-not (Test-Path $InitPath)) { throw "Could not locate Initialize-CIPPTables.ps1 at $InitPath" } + . $InitPath + + $UnregisterPath = Join-Path $BackendRoot 'Modules/CIPPCore/Public/GraphHelper/Unregister-CIPPTable.ps1' + if (-not (Test-Path $UnregisterPath)) { throw "Could not locate Unregister-CIPPTable.ps1 at $UnregisterPath" } + . $UnregisterPath + + function Set-StorageAccount { + param([string]$Name) + $env:AzureWebJobsStorage = "DefaultEndpointsProtocol=https;AccountName=$Name;AccountKey=Zm9v;EndpointSuffix=core.windows.net" + } + + # Each test needs a clean cache. + function Reset-TableCache { + $script:CIPPEnsuredTables = $null + $script:CreateCalls.Clear() + $script:ExistingTables = @() + } +} + +Describe 'Get-CIPPTable' { + BeforeEach { + Reset-TableCache + Set-StorageAccount 'acctone' + } + + It 'creates the table on the first call' { + Get-CIPPTable -tablename 'CippLogs' | Out-Null + $script:CreateCalls | Should -HaveCount 1 + $script:CreateCalls[0] | Should -Be 'CippLogs' + } + + It 'does not re-issue CreateTable for a table it already created' { + 1..25 | ForEach-Object { Get-CIPPTable -tablename 'CippLogs' | Out-Null } + $script:CreateCalls | Should -HaveCount 1 + } + + It 'creates each distinct table exactly once' { + foreach ($Iteration in 1..10) { + Get-CIPPTable -tablename 'CippLogs' | Out-Null + Get-CIPPTable -tablename 'CippQueue' | Out-Null + Get-CIPPTable -tablename 'Config' | Out-Null + } + $script:CreateCalls | Should -HaveCount 3 + $script:CreateCalls | Sort-Object | Should -Be @('CippLogs', 'CippQueue', 'Config') + } + + It 'still returns a usable context on cached calls' { + Get-CIPPTable -tablename 'CippLogs' | Out-Null + $Result = Get-CIPPTable -tablename 'CippLogs' + $Result.Context | Should -Not -BeNullOrEmpty + $Result.Context.TableName | Should -Be 'CippLogs' + } + + It 'defaults to CippLogs' { + Get-CIPPTable | Out-Null + $script:CreateCalls[0] | Should -Be 'CippLogs' + } + + It 'creates the table again when AzureWebJobsStorage is repointed at another account' { + # A new account has none of these tables; keying on name alone would skip the create. + Get-CIPPTable -tablename 'CippLogs' | Out-Null + Set-StorageAccount 'accttwo' + 1..4 | ForEach-Object { Get-CIPPTable -tablename 'CippLogs' | Out-Null } + + $script:CreateCalls | Should -HaveCount 2 + } + + It 'issues no CreateTable at all for tables Initialize-CIPPTables already found' { + # The two functions build cache keys independently; if either drifts, the cache + # silently never hits. This catches that. + $script:ExistingTables = @('CippLogs', 'CippQueue', 'cachereportsgetMailboxUsageDetailperiodD7') + Initialize-CIPPTables + + foreach ($Iteration in 1..10) { + Get-CIPPTable -tablename 'CippLogs' | Out-Null + Get-CIPPTable -tablename 'CippQueue' | Out-Null + # A runtime-derived name no hardcoded list could contain. + Get-CIPPTable -tablename 'cachereportsgetMailboxUsageDetailperiodD7' | Out-Null + } + + $script:CreateCalls | Should -HaveCount 0 + } + + It 'still creates a table that did not exist at warmup' { + # Absent from the listing means absent from storage, so first use must create it. + $script:ExistingTables = @('CippLogs') + Initialize-CIPPTables + + Get-CIPPTable -tablename 'CippLogs' | Out-Null + Get-CIPPTable -tablename 'BrandNewFeatureTable' | Out-Null + Get-CIPPTable -tablename 'BrandNewFeatureTable' | Out-Null + + $script:CreateCalls | Should -Be @('BrandNewFeatureTable') + } + + It 'does not let a warmup seed leak across storage accounts' { + $script:ExistingTables = @('CippLogs') + Initialize-CIPPTables + Get-CIPPTable -tablename 'CippLogs' | Out-Null + $script:CreateCalls | Should -HaveCount 0 + + Set-StorageAccount 'accttwo' + Get-CIPPTable -tablename 'CippLogs' | Out-Null + $script:CreateCalls | Should -Be @('CippLogs') + } + + It 'survives a storage listing failure by falling back to create-on-first-use' { + Mock Get-AzDataTable { throw 'storage not ready' } + { Initialize-CIPPTables } | Should -Not -Throw + + Get-CIPPTable -tablename 'CippLogs' | Out-Null + Get-CIPPTable -tablename 'CippLogs' | Out-Null + $script:CreateCalls | Should -Be @('CippLogs') + } + + It 'recreates a table on next use after Unregister-CIPPTable' { + # Without invalidation the cache keeps claiming a dropped table exists. + Get-CIPPTable -tablename 'CippQueue' | Out-Null + Get-CIPPTable -tablename 'CippQueue' | Out-Null + $script:CreateCalls | Should -HaveCount 1 + + Unregister-CIPPTable -TableName 'CippQueue' + Get-CIPPTable -tablename 'CippQueue' | Out-Null + + $script:CreateCalls | Should -Be @('CippQueue', 'CippQueue') + } + + It 'forgets only the named table' { + Get-CIPPTable -tablename 'CippQueue' | Out-Null + Get-CIPPTable -tablename 'CippLogs' | Out-Null + Unregister-CIPPTable -TableName 'CippQueue' + + Get-CIPPTable -tablename 'CippQueue' | Out-Null + Get-CIPPTable -tablename 'CippLogs' | Out-Null + + $script:CreateCalls | Should -Be @('CippQueue', 'CippLogs', 'CippQueue') + } + + It 'accepts several tables at once' { + foreach ($Name in @('a', 'b', 'c')) { Get-CIPPTable -tablename $Name | Out-Null } + Unregister-CIPPTable -TableName @('a', 'c') + foreach ($Name in @('a', 'b', 'c')) { Get-CIPPTable -tablename $Name | Out-Null } + + $script:CreateCalls | Should -Be @('a', 'b', 'c', 'a', 'c') + } + + It 'forgets everything with -All' { + foreach ($Name in @('a', 'b', 'c')) { Get-CIPPTable -tablename $Name | Out-Null } + Unregister-CIPPTable -All + foreach ($Name in @('a', 'b', 'c')) { Get-CIPPTable -tablename $Name | Out-Null } + + $script:CreateCalls | Should -HaveCount 6 + } + + It 'tolerates unregistering something that was never cached' { + { Unregister-CIPPTable -TableName 'NeverSeen' } | Should -Not -Throw + { Unregister-CIPPTable -All } | Should -Not -Throw + } + + It 'does not cache a failed creation' { + # A failed create must not be remembered as done. + Mock New-AzDataTable { throw 'storage unavailable' } + { Get-CIPPTable -tablename 'CippLogs' } | Should -Throw + + # The retry must actually attempt it. + $script:Attempts = 0 + Mock New-AzDataTable { $script:Attempts++ } + Get-CIPPTable -tablename 'CippLogs' | Out-Null + $script:Attempts | Should -Be 1 + } +} + +Describe 'Table deletion call sites' { + # The coupling is invisible at the call site, so assert it rather than rely on review. + It 'every file that drops a table also unregisters it' { + $ModuleRoot = Join-Path (Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath))) 'Modules' + if (-not (Test-Path $ModuleRoot)) { throw "Module root not found at $ModuleRoot" } + + $Offenders = [System.Collections.Generic.List[string]]::new() + $Scanned = 0 + $WithDeletions = 0 + + Get-ChildItem -Path $ModuleRoot -Filter '*.ps1' -Recurse -File | + Where-Object { $_.FullName -notmatch [regex]::Escape([IO.Path]::DirectorySeparatorChar + 'AzBobbyTables' + [IO.Path]::DirectorySeparatorChar) } | + ForEach-Object { + $Scanned++ + $Content = Get-Content -Path $_.FullName -Raw + # Only real invocations. A quoted 'Remove-AzDataTable' is an allow/block list + # entry, not a call, and several files legitimately contain those. + $Invocations = [regex]::Matches($Content, "(?m)^\s*[^#'`"\r\n]*(? to ' -TestCases @( + @{ TenantDomain = 'contoso.onmicrosoft.com'; Expected = 'sharepoint.com' } + @{ TenantDomain = 'contoso.onmicrosoft.de'; Expected = 'sharepoint.de' } + @{ TenantDomain = 'contoso.onmicrosoft.us'; Expected = 'sharepoint.us' } + @{ TenantDomain = 'contoso.partner.onmschina.cn'; Expected = 'sharepoint.cn' } + ) { + param($TenantDomain, $Expected) + Get-CIPPSharePointDomain -TenantDomain $TenantDomain | Should -Be $Expected + } + + It 'falls back to the commercial domain for a vanity or empty domain' { + Get-CIPPSharePointDomain -TenantDomain 'dev.contoso.com' | Should -Be 'sharepoint.com' + Get-CIPPSharePointDomain -TenantDomain '' | Should -Be 'sharepoint.com' + } +} + +Describe 'Get-SharePointAdminLink' { + Context 'resolving through Graph' { + It 'takes the domain from the root site host rather than assuming .com' { + Mock -CommandName New-GraphGetRequest -MockWith { + [PSCustomObject]@{ + id = 'consoso.sharepoint.de,11111111-1111-1111-1111-111111111111,22222222-2222-2222-2222-222222222222' + webUrl = 'https://consoso.sharepoint.de' + siteCollection = [PSCustomObject]@{ hostname = 'consoso.sharepoint.de' } + } + } + + $Result = Get-SharePointAdminLink -Public $false -TenantFilter 'consoso.onmicrosoft.de' + + $Result.TenantName | Should -Be 'consoso' + $Result.SharePointDomain | Should -Be 'sharepoint.de' + $Result.AdminUrl | Should -Be 'https://consoso-admin.sharepoint.de' + $Result.SharePointUrl | Should -Be 'https://consoso.sharepoint.de' + } + + It 'still resolves when siteCollection is absent and only the id carries the host' { + Mock -CommandName New-GraphGetRequest -MockWith { + [PSCustomObject]@{ id = 'consoso.sharepoint.de,11111111-1111-1111-1111-111111111111,22222222-2222-2222-2222-222222222222' } + } + + (Get-SharePointAdminLink -Public $false -TenantFilter 'consoso.onmicrosoft.de').AdminUrl | + Should -Be 'https://consoso-admin.sharepoint.de' + } + + It 'still resolves when only webUrl is present' { + Mock -CommandName New-GraphGetRequest -MockWith { + [PSCustomObject]@{ webUrl = 'https://consoso.sharepoint.de/' } + } + + (Get-SharePointAdminLink -Public $false -TenantFilter 'consoso.onmicrosoft.de').AdminUrl | + Should -Be 'https://consoso-admin.sharepoint.de' + } + + It 'keeps the commercial domain for a commercial tenant' { + Mock -CommandName New-GraphGetRequest -MockWith { + [PSCustomObject]@{ siteCollection = [PSCustomObject]@{ hostname = 'contoso.sharepoint.com' } } + } + + (Get-SharePointAdminLink -Public $false -TenantFilter 'contoso.onmicrosoft.com').AdminUrl | + Should -Be 'https://contoso-admin.sharepoint.com' + } + + It 'keeps the DoD domain, which no domain-name mapping could derive' { + Mock -CommandName New-GraphGetRequest -MockWith { + [PSCustomObject]@{ siteCollection = [PSCustomObject]@{ hostname = 'contoso.sharepoint-mil.us' } } + } + + (Get-SharePointAdminLink -Public $false -TenantFilter 'contoso.onmicrosoft.us').AdminUrl | + Should -Be 'https://contoso-admin.sharepoint-mil.us' + } + + It 'falls back to the commercial domain when the host is not a SharePoint one' { + Mock -CommandName New-GraphGetRequest -MockWith { + [PSCustomObject]@{ siteCollection = [PSCustomObject]@{ hostname = 'contoso.example.org' } } + } + + (Get-SharePointAdminLink -Public $false -TenantFilter 'contoso.onmicrosoft.com').SharePointDomain | + Should -Be 'sharepoint.com' + } + + It 'throws instead of returning a link to nowhere when the root site has no host' { + Mock -CommandName New-GraphGetRequest -MockWith { [PSCustomObject]@{} } + + { Get-SharePointAdminLink -Public $false -TenantFilter 'contoso.onmicrosoft.com' } | + Should -Throw '*Could not determine the SharePoint tenant name*' + } + } + + Context 'resolving through autodiscover' { + BeforeEach { + # The SOAP response shape Invoke-RestMethod returns, down to the domain list. + function New-AutodiscoverResponse { + param([string[]]$Domains) + [PSCustomObject]@{ + Envelope = [PSCustomObject]@{ + body = [PSCustomObject]@{ + GetFederationInformationResponseMessage = [PSCustomObject]@{ + response = [PSCustomObject]@{ + Domains = [PSCustomObject]@{ Domain = $Domains } + } + } + } + } + } + } + } + + It 'resolves a single onmicrosoft.de domain to the .de SharePoint domain' { + Mock -CommandName Invoke-RestMethod -MockWith { + New-AutodiscoverResponse -Domains @('meyerrechtsanwaelte.onmicrosoft.de', 'meyer.de') + } + + $Result = Get-SharePointAdminLink -Public $true -TenantFilter 'meyerrechtsanwaelte.onmicrosoft.de' + + $Result.TenantName | Should -Be 'meyerrechtsanwaelte' + $Result.AdminUrl | Should -Be 'https://meyerrechtsanwaelte-admin.sharepoint.de' + } + + # A single match comes back from Where-Object as a bare string; indexing it with [0] yields + # a [char], whose .Split() does not exist. Every single-domain tenant hit this. + It 'handles a lone matching domain without indexing into the string' { + Mock -CommandName Invoke-RestMethod -MockWith { + New-AutodiscoverResponse -Domains @('contoso.onmicrosoft.com') + } + + { Get-SharePointAdminLink -Public $true -TenantFilter 'contoso.onmicrosoft.com' } | Should -Not -Throw + (Get-SharePointAdminLink -Public $true -TenantFilter 'contoso.onmicrosoft.com').AdminUrl | + Should -Be 'https://contoso-admin.sharepoint.com' + } + + It 'throws when no onmicrosoft domain comes back' { + Mock -CommandName Invoke-RestMethod -MockWith { New-AutodiscoverResponse -Domains @('contoso.com') } + + { Get-SharePointAdminLink -Public $true -TenantFilter 'contoso.com' } | + Should -Throw '*Could not find onmicrosoft domain*' + } + } +} diff --git a/Tests/Private/Remove-CIPPGroupMember.Tests.ps1 b/Tests/Private/Remove-CIPPGroupMember.Tests.ps1 index cb0a8de141dc5..50d1d86f051ac 100644 --- a/Tests/Private/Remove-CIPPGroupMember.Tests.ps1 +++ b/Tests/Private/Remove-CIPPGroupMember.Tests.ps1 @@ -24,6 +24,12 @@ BeforeAll { if (-not $ResolverPath) { throw 'Could not locate Resolve-CippExoBulkResult.ps1 under Modules/' } . $ResolverPath + # The resolver delegates error-text extraction to this pure helper; use the real one too. + $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 + . $FunctionPath function New-LookupResponse { diff --git a/Tests/Private/Remove-CIPPGroups.Tests.ps1 b/Tests/Private/Remove-CIPPGroups.Tests.ps1 index fa07929a82bb2..cecad9ad806e8 100644 --- a/Tests/Private/Remove-CIPPGroups.Tests.ps1 +++ b/Tests/Private/Remove-CIPPGroups.Tests.ps1 @@ -27,6 +27,12 @@ BeforeAll { if (-not $ResolverPath) { throw 'Could not locate Resolve-CippExoBulkResult.ps1 under Modules/' } . $ResolverPath + # The resolver delegates error-text extraction to this pure helper; use the real one too. + $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 + . $FunctionPath function New-Group { diff --git a/Tests/Private/Resolve-CippExoBulkResult.Tests.ps1 b/Tests/Private/Resolve-CippExoBulkResult.Tests.ps1 index 12cef2ec7b347..c9a87605db659 100644 --- a/Tests/Private/Resolve-CippExoBulkResult.Tests.ps1 +++ b/Tests/Private/Resolve-CippExoBulkResult.Tests.ps1 @@ -22,6 +22,13 @@ BeforeAll { Select-Object -First 1 -ExpandProperty FullName if (-not $FunctionPath) { throw 'Could not locate Resolve-CippExoBulkResult.ps1 under Modules/' } + # Real helper, not a stub: Resolve-CippExoBulkResult delegates error-text extraction to it, + # and it is pure string handling with no external calls. + $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 + . $FunctionPath function New-Operation { diff --git a/Tests/Reports/Get-CIPPSecureScoreReport.Tests.ps1 b/Tests/Reports/Get-CIPPSecureScoreReport.Tests.ps1 new file mode 100644 index 0000000000000..8026ff340f5d5 --- /dev/null +++ b/Tests/Reports/Get-CIPPSecureScoreReport.Tests.ps1 @@ -0,0 +1,115 @@ +# Pester tests for Get-CIPPSecureScoreReport. +# +# Issue #264: excluded tenants showed up in the estate-wide secure score view (and its Top/Bottom +# 5). Get-CIPPDbItem's allTenants read is deliberately unfiltered, and Add-CIPPDbItem's orphan +# cleanup only runs for tenants still being written — so an excluded tenant keeps its cached rows +# indefinitely. This function built the known-tenant lookup but only used it for display, letting +# misses fall through as rows with an empty TenantId and the domain in place of a display name. + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $FunctionPath = Get-ChildItem -Path (Join-Path $RepoRoot 'Modules') -Recurse -Filter 'Get-CIPPSecureScoreReport.ps1' -File -ErrorAction SilentlyContinue | + Select-Object -First 1 -ExpandProperty FullName + if (-not $FunctionPath) { throw 'Could not locate Get-CIPPSecureScoreReport.ps1 under Modules/' } + + # The function parses row data through the compiled projection helper, which cannot be mocked + # (it is a static method). Load the real assembly so the parse path under test is the real one. + $CippSharp = Join-Path $RepoRoot 'Shared/CIPPSharp/bin/CIPPSharp.dll' + if (-not (Test-Path $CippSharp)) { throw "Could not locate CIPPSharp.dll at $CippSharp" } + Add-Type -Path $CippSharp -ErrorAction SilentlyContinue + + function Get-CIPPDbItem { [CmdletBinding()] param($TenantFilter, $Type, [switch]$CountsOnly) } + function Get-Tenants { [CmdletBinding()] param($TenantFilter, [switch]$IncludeErrors, [switch]$IncludeAll, [switch]$SkipDomains, [switch]$TriggerRefresh) } + function Write-LogMessage { [CmdletBinding()] param($API, $tenant, $message, $sev, $LogData) } + + . $FunctionPath + + $script:Known = @( + [pscustomobject]@{ defaultDomainName = 'alpha.onmicrosoft.com'; displayName = 'Alpha Corp'; customerId = 'aaaaaaaa-0000-0000-0000-000000000001' } + [pscustomobject]@{ defaultDomainName = 'beta.onmicrosoft.com'; displayName = 'Beta Ltd'; customerId = 'bbbbbbbb-0000-0000-0000-000000000002' } + ) + + # 'ghost.onmicrosoft.com' is the excluded tenant: it still has cached rows but Get-Tenants no + # longer returns it, exactly like a tenant excluded after its cache was populated. + function script:New-ScoreRow { + param([string]$Partition, [double]$Current, [double]$Max = 100, [string]$Date = '2026-08-11T00:00:00Z') + [pscustomobject]@{ + PartitionKey = $Partition + RowKey = "SecureScore-$Date" + Data = (ConvertTo-Json -Compress -InputObject @( + @{ currentScore = $Current; maxScore = $Max; createdDateTime = $Date } + )) + } + } +} + +Describe 'Get-CIPPSecureScoreReport tenant scoping' { + BeforeEach { + Mock -CommandName Write-LogMessage -MockWith { } + Mock -CommandName Get-Tenants -MockWith { $script:Known } + Mock -CommandName Get-CIPPDbItem -MockWith { + @( + script:New-ScoreRow -Partition 'alpha.onmicrosoft.com' -Current 80 + script:New-ScoreRow -Partition 'beta.onmicrosoft.com' -Current 40 + script:New-ScoreRow -Partition 'ghost.onmicrosoft.com' -Current 10 + [pscustomobject]@{ PartitionKey = 'alpha.onmicrosoft.com'; RowKey = 'SecureScore-Count'; Data = '' } + ) + } + } + + It 'excludes cached rows for tenants Get-Tenants no longer returns' { + $Result = @(Get-CIPPSecureScoreReport -TenantFilter 'AllTenants') + + $Result.Tenant | Should -Not -Contain 'ghost.onmicrosoft.com' + $Result.Count | Should -Be 2 + } + + It 'still returns the tenants that are managed' { + $Result = @(Get-CIPPSecureScoreReport -TenantFilter 'AllTenants') + + ($Result.Tenant | Sort-Object) | Should -Be @('alpha.onmicrosoft.com', 'beta.onmicrosoft.com') + ($Result | Where-Object Tenant -EQ 'alpha.onmicrosoft.com').TenantName | Should -BeExactly 'Alpha Corp' + ($Result | Where-Object Tenant -EQ 'alpha.onmicrosoft.com').PercentageScore | Should -Be 80 + } + + It 'never emits a row with an empty TenantId' { + # The leaked rows were identifiable by exactly this: no TenantId, domain as the name. + $Result = @(Get-CIPPSecureScoreReport -TenantFilter 'AllTenants') + + foreach ($Row in $Result) { + $Row.TenantId | Should -Not -BeNullOrEmpty + $Row.TenantName | Should -Not -Be $Row.Tenant + } + } + + It 'returns empty when every cached partition is unknown' { + Mock -CommandName Get-CIPPDbItem -MockWith { + @(script:New-ScoreRow -Partition 'ghost.onmicrosoft.com' -Current 10) + } + + $Result = @(Get-CIPPSecureScoreReport -TenantFilter 'AllTenants') + $Result.Count | Should -Be 0 + } + + It 'still skips the Count bookkeeping row' { + $Result = @(Get-CIPPSecureScoreReport -TenantFilter 'AllTenants') + $Result.Tenant | Should -Not -Contain 'SecureScore-Count' + # alpha contributed both a score row and a Count row, but must appear once. + @($Result | Where-Object Tenant -EQ 'alpha.onmicrosoft.com').Count | Should -Be 1 + } + + It 'keeps working for a single managed tenant' { + Mock -CommandName Get-Tenants -MockWith { + param($TenantFilter, [switch]$IncludeErrors) + if ($TenantFilter) { return $script:Known | Where-Object defaultDomainName -EQ $TenantFilter } + return $script:Known + } + Mock -CommandName Get-CIPPDbItem -MockWith { + @(script:New-ScoreRow -Partition 'alpha.onmicrosoft.com' -Current 80) + } + + $Result = @(Get-CIPPSecureScoreReport -TenantFilter 'alpha.onmicrosoft.com') + $Result.Count | Should -Be 1 + $Result[0].Tenant | Should -BeExactly 'alpha.onmicrosoft.com' + } +} diff --git a/Tests/Reports/Get-CIPPTestResultsTenants.CountsOnly.Tests.ps1 b/Tests/Reports/Get-CIPPTestResultsTenants.CountsOnly.Tests.ps1 new file mode 100644 index 0000000000000..193ca8a06060f --- /dev/null +++ b/Tests/Reports/Get-CIPPTestResultsTenants.CountsOnly.Tests.ps1 @@ -0,0 +1,124 @@ +# Pester tests for the CountsOnly aggregate mode of Get-CIPPTestResultsTenants. +# +# The dashboard used to pull every failed test row for the estate and aggregate in the browser. +# CountsOnly does it server-side: same numbers, no rows, blob columns projected away. + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $FunctionPath = Get-ChildItem -Path (Join-Path $RepoRoot 'Modules') -Recurse -Filter 'Get-CIPPTestResultsTenants.ps1' -File -ErrorAction SilentlyContinue | + Select-Object -First 1 -ExpandProperty FullName + if (-not $FunctionPath) { throw 'Could not locate Get-CIPPTestResultsTenants.ps1 under Modules/' } + + function Get-CippTable { [CmdletBinding()] param($tablename) @{ Table = $tablename } } + function Get-CIPPAzDataTableEntity { [CmdletBinding()] param($Table, $Filter, $Property) } + function Get-Tenants { [CmdletBinding()] param($TenantFilter, [switch]$IncludeErrors, [switch]$IncludeAll) } + function Get-CippTestSuitePatterns { [CmdletBinding()] param() @{ CIS = 'CippTestCIS*'; ZTNA = 'CippTestZTNA*' } } + function Write-LogMessage { [CmdletBinding()] param($API, $tenant, $message, $sev, $LogData) } + function Get-CippException { [CmdletBinding()] param($Exception) @{ NormalizedError = $Exception.Exception.Message } } + + . $FunctionPath + + $script:Tenants = @( + [pscustomobject]@{ defaultDomainName = 'alpha.onmicrosoft.com'; displayName = 'Alpha'; customerId = 'aaaa-1111' } + [pscustomobject]@{ defaultDomainName = 'beta.onmicrosoft.com'; displayName = 'Beta'; customerId = 'bbbb-2222' } + ) + + # Two tenants. 'Shared identity check' fails for both, 'Alpha only check' for one, so the + # TopChecks ranking has a real ordering rather than a single flat tie. + $script:Rows = @( + [pscustomobject]@{ PartitionKey = 'alpha.onmicrosoft.com'; RowKey = 'CippTestA'; Status = 'Failed'; Risk = 'High'; Name = 'Shared identity check'; TestType = 'Identity'; Timestamp = '2026-08-12T00:00:00Z' } + [pscustomobject]@{ PartitionKey = 'beta.onmicrosoft.com'; RowKey = 'CippTestA'; Status = 'Failed'; Risk = 'High'; Name = 'Shared identity check'; TestType = 'Identity'; Timestamp = '2026-08-12T00:00:00Z' } + [pscustomobject]@{ PartitionKey = 'alpha.onmicrosoft.com'; RowKey = 'CippTestB'; Status = 'Failed'; Risk = 'Low'; Name = 'Alpha only check'; TestType = 'Identity'; Timestamp = '2026-08-12T00:00:00Z' } + [pscustomobject]@{ PartitionKey = 'alpha.onmicrosoft.com'; RowKey = 'CippTestC'; Status = 'Failed'; Risk = 'High'; Name = 'Device check'; TestType = 'Devices'; Timestamp = '2026-08-12T00:00:00Z' } + [pscustomobject]@{ PartitionKey = 'beta.onmicrosoft.com'; RowKey = 'CippTestD'; Status = 'Passed'; Risk = 'High'; Name = 'Passing check'; TestType = 'Identity'; Timestamp = '2026-08-12T00:00:00Z' } + ) +} + +Describe 'Get-CIPPTestResultsTenants -CountsOnly' { + BeforeEach { + Mock -CommandName Get-CippTable -MockWith { @{ Table = 'CippTestResults' } } + Mock -CommandName Get-Tenants -MockWith { $script:Tenants } + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { + param($Table, $Filter, $Property) + $script:LastProperty = $Property + # The caller queries one partition at a time. + $Match = [regex]::Match([string]$Filter, "PartitionKey eq '([^']+)'") + if ($Match.Success) { return @($script:Rows | Where-Object PartitionKey -EQ $Match.Groups[1].Value) } + return $script:Rows + } + } + + It 'returns the aggregates with no rows' { + $Result = Get-CIPPTestResultsTenants -CountsOnly + + @($Result.Results).Count | Should -Be 0 + $Result.Counts | Should -Not -BeNullOrEmpty + $Result.Counts.TotalResults | Should -Be 5 + } + + It 'counts high risk failures and the tenants they belong to' { + $Counts = (Get-CIPPTestResultsTenants -CountsOnly).Counts + + # Passed rows never count as failures even when flagged High. + $Counts.HighRiskFailed | Should -Be 3 + $Counts.HighRiskTenants | Should -Be 2 + $Counts.Failed | Should -Be 4 + $Counts.TenantsFailing | Should -Be 2 + } + + It 'breaks failures down by test type' { + $ByType = (Get-CIPPTestResultsTenants -CountsOnly).Counts.ByTestType + + $ByType.Identity.Failed | Should -Be 3 + $ByType.Identity.Tenants | Should -Be 2 + $ByType.Devices.Failed | Should -Be 1 + $ByType.Devices.Tenants | Should -Be 1 + } + + It 'ranks checks by the number of distinct tenants failing them' { + $Top = (Get-CIPPTestResultsTenants -CountsOnly).Counts.ByTestType.Identity.TopChecks + + $Top[0].Name | Should -BeExactly 'Shared identity check' + $Top[0].TenantCount | Should -Be 2 + $Top[1].Name | Should -BeExactly 'Alpha only check' + $Top[1].TenantCount | Should -Be 1 + } + + It 'projects the blob columns away even without SummaryOnly' { + $null = Get-CIPPTestResultsTenants -CountsOnly + + $script:LastProperty | Should -Not -BeNullOrEmpty + $script:LastProperty | Should -Not -Contain 'ResultMarkdown' + $script:LastProperty | Should -Not -Contain 'ResultDataJson' + $script:LastProperty | Should -Contain 'TestType' + $script:LastProperty | Should -Contain 'Risk' + } + + It 'agrees with the row-returning path it replaces' { + # The aggregates must equal what a caller would compute from the rows themselves, + # otherwise moving the maths server-side would silently change the dashboard. + $Rows = @(Get-CIPPTestResultsTenants -SummaryOnly) + $Counts = (Get-CIPPTestResultsTenants -CountsOnly).Counts + + $ExpectedHigh = @($Rows | Where-Object { $_.Status -eq 'Failed' -and $_.Risk -eq 'High' }) + $Counts.HighRiskFailed | Should -Be $ExpectedHigh.Count + $Counts.HighRiskTenants | Should -Be (@($ExpectedHigh.Tenant | Sort-Object -Unique)).Count + $Counts.TotalResults | Should -Be $Rows.Count + } + + It 'still returns rows when only IncludeCounts is asked for' { + $Result = Get-CIPPTestResultsTenants -IncludeCounts + + @($Result.Results).Count | Should -Be 5 + $Result.Counts.TotalResults | Should -Be 5 + } + + It 'returns a zeroed shape when nothing matches' { + Mock -CommandName Get-CIPPAzDataTableEntity -MockWith { @() } + + $Result = Get-CIPPTestResultsTenants -CountsOnly + @($Result.Results).Count | Should -Be 0 + $Result.Counts.TotalResults | Should -Be 0 + $Result.Counts.HighRiskTenants | Should -Be 0 + } +} diff --git a/Tests/Standards/Compare-CIPPIntuneObject.Catalog.Tests.ps1 b/Tests/Standards/Compare-CIPPIntuneObject.Catalog.Tests.ps1 index 33a5fd4667ca1..7483bfc84ef44 100644 --- a/Tests/Standards/Compare-CIPPIntuneObject.Catalog.Tests.ps1 +++ b/Tests/Standards/Compare-CIPPIntuneObject.Catalog.Tests.ps1 @@ -22,7 +22,12 @@ BeforeAll { # function reads with, and the parent is derived from that string, so the test and the function # agree on where the file lives on both Windows and Linux. $script:FakeRoot = Join-Path ([System.IO.Path]::GetTempPath()) "cipp-defidx-$([guid]::NewGuid())" - $script:CollectionPath = "$script:FakeRoot\Config\intuneCollection.json" + # Join-Path, not an interpolated backslash: the collection is written below with + # [System.IO.File]::WriteAllText, which takes the path literally on Linux instead of + # normalising '\' the way PowerShell's provider does. Building it by hand put the fixture at a + # filename containing backslashes while the function looked in the real directory, so every + # test here failed on macOS and in Linux CI. + $script:CollectionPath = Join-Path $script:FakeRoot 'Config/intuneCollection.json' New-Item -ItemType Directory -Path (Split-Path -Parent $script:CollectionPath) -Force | Out-Null $env:CIPPRootPath = $script:FakeRoot diff --git a/Tests/Standards/Compare-CIPPIntuneObject.ReusablePolicySetting.Tests.ps1 b/Tests/Standards/Compare-CIPPIntuneObject.ReusablePolicySetting.Tests.ps1 new file mode 100644 index 0000000000000..223b518ac1bfe --- /dev/null +++ b/Tests/Standards/Compare-CIPPIntuneObject.ReusablePolicySetting.Tests.ps1 @@ -0,0 +1,109 @@ +# Pester tests for the ReusablePolicySetting branch of Compare-CIPPIntuneObject. +# +# Intune mints a per-entry instance id on create, stored in the child whose settingDefinitionId +# ends in '_id'. A template keeps the ids from the tenant it was captured in, so a correctly +# deployed reusable setting differed on every entry and reported drift forever. + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $FunctionPath = Get-ChildItem -Path (Join-Path $RepoRoot 'Modules') -Recurse -Filter 'Compare-CIPPIntuneObject.ps1' -File -ErrorAction SilentlyContinue | + Select-Object -First 1 -ExpandProperty FullName + if (-not $FunctionPath) { throw 'Could not locate Compare-CIPPIntuneObject.ps1 under Modules/' } + + $ExclusionsPath = Get-ChildItem -Path (Join-Path $RepoRoot 'Modules') -Recurse -Filter 'Get-CIPPIntuneCompareExclusions.ps1' -File -ErrorAction SilentlyContinue | + Select-Object -First 1 -ExpandProperty FullName + . $ExclusionsPath + . $FunctionPath + + # One dynamic-keyword entry: instance id, autoresolve, keyword - the shape the firewall + # address-list reusable settings use. + function script:New-Entry { + param([string]$InstanceId, [string]$Keyword) + [pscustomobject]@{ + children = @( + [pscustomobject]@{ + settingDefinitionId = 'vendor_msft_firewall_mdmstore_dynamickeywords_addresses_{id}_id' + simpleSettingValue = [pscustomobject]@{ value = $InstanceId } + } + [pscustomobject]@{ + settingDefinitionId = 'vendor_msft_firewall_mdmstore_dynamickeywords_addresses_{id}_autoresolve' + choiceSettingValue = [pscustomobject]@{ value = 'autoresolve_true'; children = @() } + } + [pscustomobject]@{ + settingDefinitionId = 'vendor_msft_firewall_mdmstore_dynamickeywords_addresses_{id}_keyword' + simpleSettingValue = [pscustomobject]@{ value = $Keyword } + } + ) + } + } + function script:New-Setting { + param([object[]]$Entries, [string]$DisplayName = 'Usually Malicious TLDs') + [pscustomobject]@{ + displayName = $DisplayName + description = 'List of foreign TLDs that are generally malicious.' + settingDefinitionId = 'vendor_msft_firewall_mdmstore_dynamickeywords_addresses_{id}' + settingInstance = [pscustomobject]@{ + settingDefinitionId = 'vendor_msft_firewall_mdmstore_dynamickeywords_addresses_{id}' + groupSettingCollectionValue = @($Entries) + } + } + } +} + +Describe 'Compare-CIPPIntuneObject ReusablePolicySetting instance ids' { + It 'ignores instance ids that differ while every keyword matches' { + $Template = script:New-Setting -Entries @( + (script:New-Entry -InstanceId '{aaaaaaaa-0000-0000-0000-000000000001}' -Keyword '*.ru') + (script:New-Entry -InstanceId '{aaaaaaaa-0000-0000-0000-000000000002}' -Keyword '*.tk') + ) + $InTenant = script:New-Setting -Entries @( + (script:New-Entry -InstanceId '{bbbbbbbb-1111-1111-1111-111111111111}' -Keyword '*.ru') + (script:New-Entry -InstanceId '{bbbbbbbb-2222-2222-2222-222222222222}' -Keyword '*.tk') + ) + + $Diffs = @(Compare-CIPPIntuneObject -ReferenceObject $Template -DifferenceObject $InTenant -CompareType 'ReusablePolicySetting') | + Where-Object { $null -ne $_ } + $Diffs.Count | Should -Be 0 + } + + It 'still reports a keyword that genuinely differs' { + $Template = script:New-Setting -Entries @(script:New-Entry -InstanceId '{aaaa}' -Keyword '*.ru') + $InTenant = script:New-Setting -Entries @(script:New-Entry -InstanceId '{bbbb}' -Keyword '*.example') + + $Diffs = @(Compare-CIPPIntuneObject -ReferenceObject $Template -DifferenceObject $InTenant -CompareType 'ReusablePolicySetting') | + Where-Object { $null -ne $_ } + $Diffs.Count | Should -BeGreaterThan 0 + ($Diffs.Property -join ' ') | Should -Match 'keyword|children' + } + + It 'still reports a changed display name' { + $Template = script:New-Setting -Entries @(script:New-Entry -InstanceId '{aaaa}' -Keyword '*.ru') + $InTenant = script:New-Setting -Entries @(script:New-Entry -InstanceId '{bbbb}' -Keyword '*.ru') -DisplayName 'Renamed' + + $Diffs = @(Compare-CIPPIntuneObject -ReferenceObject $Template -DifferenceObject $InTenant -CompareType 'ReusablePolicySetting') | + Where-Object { $null -ne $_ } + ($Diffs.Property -join ' ') | Should -Match 'displayName' + } + + It 'does not mutate the caller objects' { + # The standard reuses the template body to build the remediation payload, where the real + # instance ids still matter. + $Template = script:New-Setting -Entries @(script:New-Entry -InstanceId '{keep-me}' -Keyword '*.ru') + $InTenant = script:New-Setting -Entries @(script:New-Entry -InstanceId '{other}' -Keyword '*.ru') + + $null = Compare-CIPPIntuneObject -ReferenceObject $Template -DifferenceObject $InTenant -CompareType 'ReusablePolicySetting' + + $Template.settingInstance.groupSettingCollectionValue[0].children[0].simpleSettingValue.value | + Should -BeExactly '{keep-me}' + } + + It 'leaves other compare types alone' { + # Without the compare type the ids are ordinary values and must still be reported. + $Template = script:New-Setting -Entries @(script:New-Entry -InstanceId '{aaaa}' -Keyword '*.ru') + $InTenant = script:New-Setting -Entries @(script:New-Entry -InstanceId '{bbbb}' -Keyword '*.ru') + + $Diffs = @(Compare-CIPPIntuneObject -ReferenceObject $Template -DifferenceObject $InTenant) | + Where-Object { $null -ne $_ } + $Diffs.Count | Should -BeGreaterThan 0 + } +} diff --git a/Tests/Standards/Invoke-CIPPStandardReusableSettingsTemplate.Tests.ps1 b/Tests/Standards/Invoke-CIPPStandardReusableSettingsTemplate.Tests.ps1 index 82f3fbf7b12f5..ce0f57f681e9a 100644 --- a/Tests/Standards/Invoke-CIPPStandardReusableSettingsTemplate.Tests.ps1 +++ b/Tests/Standards/Invoke-CIPPStandardReusableSettingsTemplate.Tests.ps1 @@ -165,4 +165,119 @@ Describe 'Invoke-CIPPStandardReusableSettingsTemplate' { $compareFields[0].Expected.isCompliant | Should -BeTrue Should -Invoke -CommandName Write-StandardsAlert -Times 0 } + + # Alignment emits a key for every selected id. A key with no compare row reports NOT FOUND, and + # stays in ValidDriftKeys, which the drift prune skips - so it could never be cleared. + Context 'compare rows always cover every selected template' { + It 'writes a compare row for a template whose row no longer exists' { + Mock -CommandName Get-CippAzDataTableEntity -MockWith { @() } + + $settings = @( + [pscustomobject]@{ TemplateList = [pscustomobject]@{ value = 'template-deleted' }; remediate = $false; alert = $false; report = $true } + ) + + Invoke-CIPPStandardReusableSettingsTemplate -Tenant $tenant -Settings $settings + + $compareFields.Field | Should -Contain 'standards.ReusableSettingsTemplate.template-deleted' + ($compareFields | Where-Object Field -EQ 'standards.ReusableSettingsTemplate.template-deleted').Current.isCompliant | + Should -BeFalse + } + + It 'writes a compare row for a template whose stored JSON is empty' { + Mock -CommandName Get-CippAzDataTableEntity -MockWith { + @([pscustomobject]@{ RowKey = 'template-empty'; JSON = ''; DisplayName = 'Empty' }) + } + + $settings = @( + [pscustomobject]@{ TemplateList = [pscustomobject]@{ value = 'template-empty' }; remediate = $false; alert = $false; report = $true } + ) + + Invoke-CIPPStandardReusableSettingsTemplate -Tenant $tenant -Settings $settings + + $compareFields.Field | Should -Contain 'standards.ReusableSettingsTemplate.template-empty' + } + + It 'covers the resolvable ids when only some of a selection resolve' { + # The unresolved id used to produce nothing at all, which is harder to spot. + Mock -CommandName Get-CippAzDataTableEntity -MockWith { + @([pscustomobject]@{ + RowKey = 'template-good' + JSON = '{"DisplayName":"Reusable Good","RawJSON":"{\"displayName\":\"Reusable Good\"}"}' + DisplayName = 'Reusable Good' + }) + } + + $settings = @( + [pscustomobject]@{ TemplateList = [pscustomobject]@{ value = @('template-good', 'template-missing') }; remediate = $false; alert = $false; report = $true } + ) + + Invoke-CIPPStandardReusableSettingsTemplate -Tenant $tenant -Settings $settings + + $compareFields.Field | Should -Contain 'standards.ReusableSettingsTemplate.template-good' + $compareFields.Field | Should -Contain 'standards.ReusableSettingsTemplate.template-missing' + } + + It 'never pushes an empty body for an unresolved template' { + Mock -CommandName Get-CippAzDataTableEntity -MockWith { @() } + + $settings = @( + [pscustomobject]@{ TemplateList = [pscustomobject]@{ value = 'template-deleted' }; remediate = $true; alert = $false; report = $false } + ) + + Invoke-CIPPStandardReusableSettingsTemplate -Tenant $tenant -Settings $settings + + Should -Invoke -CommandName New-GraphPOSTRequest -Times 0 + } + } + + Context 'compare key matches the id the picker sent' { + It 'keys off TemplateList.value, not the GUID inside the stored JSON' { + # The JSON blob's GUID matches the RowKey for CIPP-created templates but not for + # imported rows, where it wrote the row under a key nobody reads. + Mock -CommandName Get-CippAzDataTableEntity -MockWith { + @([pscustomobject]@{ + RowKey = 'row-key-id' + JSON = '{"DisplayName":"Reusable A","GUID":"a-different-guid","RawJSON":"{\"displayName\":\"Reusable A\"}"}' + DisplayName = 'Reusable A' + }) + } + Mock -CommandName New-GraphGETRequest -MockWith { + @([pscustomobject]@{ id = 'existing-9'; displayName = 'Reusable A' }) + } + Mock -CommandName Compare-CIPPIntuneObject -MockWith { $null } + + $settings = @( + [pscustomobject]@{ TemplateList = [pscustomobject]@{ value = 'row-key-id' }; remediate = $false; alert = $false; report = $true } + ) + + Invoke-CIPPStandardReusableSettingsTemplate -Tenant $tenant -Settings $settings + + $compareFields.Field | Should -Contain 'standards.ReusableSettingsTemplate.row-key-id' + $compareFields.Field | Should -Not -Contain 'standards.ReusableSettingsTemplate.a-different-guid' + } + + It 'raises its alert against the picker id too' { + Mock -CommandName Get-CippAzDataTableEntity -MockWith { + @([pscustomobject]@{ + RowKey = 'row-key-id' + JSON = '{"DisplayName":"Reusable A","GUID":"a-different-guid","RawJSON":"{\"displayName\":\"Reusable A\"}"}' + DisplayName = 'Reusable A' + }) + } + Mock -CommandName New-GraphGETRequest -MockWith { + @([pscustomobject]@{ id = 'existing-9'; displayName = 'Reusable A' }) + } + # [pscustomobject] to match what Compare-CIPPIntuneObject really returns. + Mock -CommandName Compare-CIPPIntuneObject -MockWith { [pscustomobject]@{ Difference = 'drift' } } + + $settings = @( + [pscustomobject]@{ TemplateList = [pscustomobject]@{ value = 'row-key-id' }; remediate = $false; alert = $true; report = $false } + ) + + Invoke-CIPPStandardReusableSettingsTemplate -Tenant $tenant -Settings $settings + + $alerts | Should -HaveCount 1 + $alerts[0].Id | Should -BeExactly 'row-key-id' + } + } } diff --git a/version_latest.txt b/version_latest.txt index f60f38dc9bbe5..0b04f50e23194 100644 --- a/version_latest.txt +++ b/version_latest.txt @@ -1 +1 @@ -10.8.3 \ No newline at end of file +10.8.4 \ No newline at end of file